diff --git a/.claude/skills/adding-skill-workflow/SKILL.md b/.claude/skills/adding-skill-workflow/SKILL.md new file mode 100644 index 00000000..a4d01d96 --- /dev/null +++ b/.claude/skills/adding-skill-workflow/SKILL.md @@ -0,0 +1,172 @@ +--- +name: adding-skill-workflow +description: Create a new skill-based workflow for the PostHog wizard. Use when adding a new workflow type (like revenue analytics, error tracking, feature flags) that installs a context-mill skill and runs an agent. Covers workflow steps, detection, flow registration, runner, custom screens, and CLI command. +compatibility: Designed for Claude Code working on the PostHog wizard codebase. +metadata: + author: posthog + version: "1.1" +--- + +# Adding a Skill-Based Workflow + +## Architecture Overview + +Skill-based workflows (like revenue analytics) follow a different path from framework integrations. Instead of the agent runner building a prompt from a `FrameworkConfig`, a skill-based workflow: + +1. **Detects prerequisites** and downloads a skill from context-mill +2. **Runs the agent** against the installed skill using the generic `skill-runner.ts` +3. **Shows results** via data-driven outro (no hardcoded messages) + +Key files: +- `src/lib/workflow-step.ts` — `WorkflowStep` interface with `gate`, `onInit`, `StoreInitContext` +- `src/lib/skill-runner.ts` — Generic runner: takes a skill path, builds bootstrap prompt, runs agent +- `src/lib/wizard-tools.ts` — `fetchSkillMenu()` and `downloadSkill()` for installing skills via code +- `src/utils/file-utils.ts` — Shared `IGNORED_DIRS` for project-tree scans +- `src/ui/tui/flows.ts` — `Flow` enum, `Screen` enum, `WORKFLOW_STEPS`, `FLOWS` maps +- `src/ui/tui/screen-registry.tsx` — Maps screen IDs to React components +- `src/ui/tui/store.ts` — Gate system derived from workflow step definitions + +## How It Works + +### Gates and isComplete + +- **`isComplete`** — exit condition for the screen. Router advances past the step when true. Defaults to `gate` if unset. +- **`gate`** — define this if your screen needs to await user interactions. bin.ts pauses on `await store.getGate(stepId)` until the predicate becomes true. + +### Detect step pattern + +Detection is split into two pieces: + +1. **A headless `detect` workflow step** with a gate predicate that resolves once `frameworkContext.skillPath` or `frameworkContext.detectError` is set. +2. **An exported `detect*Prerequisites()` async function** that bin.ts calls AFTER the session is assigned to the store. + +**Why not `onInit`?** Because `onInit` fires during store construction (inside `_initFromWorkflow`), which runs BEFORE `tui.store.session = session` in bin.ts. Any `onInit` that reads `session.installDir` would get the default `process.cwd()`, not the app directory. `onInit` is fine for session-independent work like the integration flow's health check. + +```typescript +// In your workflow file +export async function detectYourPrerequisites( + session: WizardSession, + setFrameworkContext: (key: string, value: unknown) => void, +): Promise { + // Verify session.installDir, scan for required artifacts, fetch + download + // the skill. On failure: setFrameworkContext('detectError', '...'). + // On success: setFrameworkContext('skillPath', '.claude/skills/...'). + // Optionally store any data the intro screen should render. +} + +export const YOUR_WORKFLOW: Workflow = [ + { + id: 'detect', + label: 'Detecting prerequisites', + gate: (s) => + s.frameworkContext.skillPath != null || + s.frameworkContext.detectError != null, + }, + // ... +]; +``` + +### Error handling — never console.error from inside the TUI + +When the Ink TUI is rendering, calling `console.error` and `process.exit(1)` mangles the screen. Instead, your custom intro screen reads `frameworkContext.detectError` and renders an error view with an Exit option. bin.ts just awaits the intro gate — the screen handles both success and error states. + +### StoreInitContext + +Available in `onInit` callbacks (use only for session-independent work): +- `ctx.session` — read current session state +- `ctx.setReadinessResult(result)` — store health check results +- `ctx.setFrameworkContext(key, value)` — store detection results +- `ctx.emitChange()` — trigger gate re-evaluation + +## Steps to Add a Workflow + +### 1. Define workflow steps + +Create `src/lib/workflows/.ts` with a detect step + (optional) intro step + auth + run + outro. + +Export `detect*Prerequisites()` as a standalone async function — do NOT put detection in `onInit`. + +### 2. Register the flow + +In `src/ui/tui/flows.ts`: +- Add to `Flow` enum +- Add to `WORKFLOW_STEPS` map +- Add to `FLOWS` record via `workflowToFlowEntries()` + +### 3. Create the runner + +The runner is trivial — it reads the skill path from session and delegates to `runSkillBootstrap()`: + +```typescript +import { runSkillBootstrap } from './skill-runner'; + +export async function runYourWizard(session: WizardSession): Promise { + const skillPath = session.frameworkContext.skillPath as string; + + await runSkillBootstrap(session, { + skillPath, + integrationLabel: 'your-workflow', + promptContext: 'Set up X for this project.', + successMessage: 'X configured!', + reportFile: 'posthog-x-report.md', + docsUrl: 'https://posthog.com/docs/x', + spinnerMessage: 'Setting up X...', + estimatedDurationMinutes: 5, + }); +} +``` + +Use the actual skill ID from context-mill's skill menu — don't guess. + +### 4. (Optional) Custom intro screen + +If you want a workflow-specific welcome screen, create one. The screen should also handle the `detectError` state since that's where errors are rendered. + +**a.** Add a screen ID to the `Screen` enum in `src/ui/tui/flows.ts`. + +**b.** Create `src/ui/tui/screens/YourIntroScreen.tsx`. Subscribe to the store, read `detectError` and detection results from `session.frameworkContext`, render either an error view (with Exit) or the welcome view (with Continue/Cancel). On confirm, call `store.completeSetup()`. + +**c.** Register it in `src/ui/tui/screen-registry.tsx`. + +**d.** Add an intro step to your workflow (after `detect`, before `auth`): +```typescript +{ + id: 'intro', + label: 'Welcome', + screen: 'your-intro', + gate: (s) => s.setupConfirmed, + isComplete: (s) => s.setupConfirmed, +}, +``` + +In bin.ts, await the intro gate after detect. Don't pre-set `setupConfirmed = true` if you have a custom intro — the user confirms via the screen. + +### 5. Add the CLI command + +In `bin.ts`, add a yargs command. The pattern: +1. Start the TUI with your `Flow` +2. Build session, assign to store +3. Call `detect*Prerequisites()` explicitly +4. Await `getGate('detect')` +5. Await `getGate('intro')` — the screen handles both error and success states +6. Call your runner +7. Wait for `outroDismissed` via store subscribe, then `process.exit(0)` — without this, the process exits before the user can read the outro + +**Do not** `console.error` or `process.exit` for `detectError` from bin.ts — that mangles the Ink output. Let the intro screen render the error. + +### 6. Verify + +```bash +pnpm build # Must compile +pnpm test # All tests pass +``` + +Then run your command end-to-end against a real test app, including failure cases (missing prerequisites, bad directories) to confirm graceful handling. + +## Reference + +See `references/WORKFLOW-GUIDE.md` for the full step-by-step guide with complete code examples. + +## Canonical Example + +`src/lib/workflows/revenue-analytics.ts` — read this for a full working implementation of every piece described above. diff --git a/.claude/skills/adding-skill-workflow/references/WORKFLOW-GUIDE.md b/.claude/skills/adding-skill-workflow/references/WORKFLOW-GUIDE.md new file mode 100644 index 00000000..9c733979 --- /dev/null +++ b/.claude/skills/adding-skill-workflow/references/WORKFLOW-GUIDE.md @@ -0,0 +1,251 @@ +# Adding a New Skill-Based Workflow + +How to add a new workflow to the wizard (like revenue analytics) that installs a skill from context-mill and lets the agent follow it. + +## Prerequisites + +- A skill published to context-mill with a `SKILL.md` and workflow files +- The skill registered in the skill menu under a category (e.g. `revenue-analytics-setup`) + +## Steps + +### 1. Define the workflow steps + +Create `wizard/src/lib/workflows/.ts`: + +```typescript +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../wizard-session.js'; + +export const YOUR_WORKFLOW: Workflow = [ + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (s) => s.credentials !== null, + }, + { + id: 'run', + label: 'Your workflow label', + screen: 'run', + isComplete: (s) => + s.runPhase === RunPhase.Completed || s.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (s) => s.outroDismissed, + }, +]; +``` + +If your workflow needs a health check or setup confirmation, add `gate` predicates and `onInit` callbacks to the relevant steps: + +**Example: Adding a setup confirmation gate** + +A gate blocks bin.ts from proceeding until its predicate returns true. Here, the `intro` step blocks until the user confirms setup: + +```typescript +{ + id: 'intro', + label: 'Welcome', + screen: 'intro', + gate: (s) => s.setupConfirmed, // bin.ts awaits store.getGate('intro') + isComplete: (s) => s.setupConfirmed, // router advances past this screen +}, +``` + +**Example: Adding a health check with async init** + +The `onInit` callback fires during store construction. Here it kicks off a health check while the user is still on the intro screen. The gate blocks until the result arrives: + +```typescript +import { + evaluateWizardReadiness, + WizardReadiness, +} from '../health-checks/readiness.js'; + +{ + id: 'health-check', + label: 'Health check', + screen: 'health-check', + gate: (s) => { + if (!s.readinessResult) return false; + if (s.readinessResult.decision === WizardReadiness.No) + return s.outageDismissed; // user must dismiss blocking outage + return true; + }, + isComplete: (s) => { + if (!s.readinessResult) return false; + if (s.readinessResult.decision === WizardReadiness.No) + return s.outageDismissed; + return true; + }, + onInit: (ctx) => { + evaluateWizardReadiness() + .then((readiness) => ctx.setReadinessResult(readiness)) + .catch(() => ctx.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + })); + }, +}, +``` + +If your workflow doesn't have these steps, the gates simply don't exist and `store.getGate('...')` resolves immediately. + +### 2. Register the flow + +In `wizard/src/ui/tui/flows.ts`: + +1. Add to the `Flow` enum: +```typescript +export enum Flow { + Wizard = 'wizard', + Revenue = 'revenue', + YourFlow = 'your-flow', // add + // ... +} +``` + +2. Add to `WORKFLOW_STEPS`: +```typescript +export const WORKFLOW_STEPS: Partial> = { + [Flow.Wizard]: POSTHOG_INTEGRATION_WORKFLOW, + [Flow.Revenue]: REVENUE_ANALYTICS_WORKFLOW, + [Flow.YourFlow]: YOUR_WORKFLOW, // add +}; +``` + +3. Add to `FLOWS`: +```typescript +[Flow.YourFlow]: workflowToFlowEntries(YOUR_WORKFLOW) as FlowEntry[], +``` + +### 3. Create the runner + +The runner is split into two parts: + +1. **A `detect` workflow step** — checks prerequisites, selects the right skill, and downloads it. This is workflow-specific (framework detection for integration, payment provider for revenue, etc.) +2. **A thin runner function** that reads the skill path from the session and hands off to `runSkillBootstrap` + +The detect step lives in the workflow definition (step 1). Here's how revenue does it: + +```typescript +// In wizard/src/lib/workflows/revenue-analytics.ts + +const POSTHOG_SDKS = ['posthog-js', 'posthog-node', 'posthog-react-native', ...]; +const STRIPE_SDKS = ['stripe', '@stripe/stripe-js']; +const SKILL_ID = 'revenue-analytics-stripe'; + +{ + id: 'detect', + label: 'Detecting prerequisites', + // No screen — headless step. Runs via onInit, blocks via gate. + gate: (s) => + s.frameworkContext.skillPath != null || + s.frameworkContext.detectError != null, + onInit: (ctx) => { + // 1. Read package.json, check for PostHog + Stripe SDKs + // 2. If either missing → ctx.setFrameworkContext('detectError', '...') + // 3. If both found → fetch skill menu, download skill + // 4. On success → ctx.setFrameworkContext('skillPath', '.claude/skills/...') + // 5. On failure → ctx.setFrameworkContext('detectError', '...') + // + // The gate resolves once either skillPath or detectError is set. + // bin.ts awaits store.getGate('detect'), then checks for detectError. + }, +}, +``` + +In `bin.ts`, the revenue command awaits the detect gate and checks for errors: + +```typescript +await tui.store.getGate('detect'); + +const detectError = tui.store.session.frameworkContext.detectError as string | undefined; +if (detectError) { + console.error(detectError); + process.exit(1); + return; +} +``` + +The runner itself is trivial — it reads the skill path and delegates: + +```typescript +// wizard/src/lib/revenue-runner.ts +import { runSkillBootstrap } from './skill-runner'; +import type { WizardSession } from './wizard-session'; + +export async function runRevenueWizard(session: WizardSession): Promise { + // Skill was already downloaded by the detect workflow step + const skillPath = session.frameworkContext.skillPath as string; + + await runSkillBootstrap(session, { + skillPath, + integrationLabel: 'revenue-analytics-setup', + promptContext: 'Set up revenue analytics for this project.', + successMessage: 'Revenue analytics configured!', + reportFile: 'posthog-revenue-report.md', + docsUrl: 'https://posthog.com/docs/revenue-analytics', + spinnerMessage: 'Setting up revenue analytics...', + estimatedDurationMinutes: 5, + }); +} +``` + +This separation matters because different workflows need different detection: +- **Revenue**: checks for PostHog + Stripe, could later detect payment provider +- **Integration**: detects framework, version, project type before picking a skill +- **Future "error tracking"**: might detect error library (Sentry vs Bugsnag) first + +### 4. Add the CLI command + +In `wizard/bin.ts`, add a new command: + +```typescript +program + .command('your-command') + .description('Set up X') + .action(async (options) => { + // Prerequisite checks (e.g. verify required packages are installed) + // ... + + const { startTUI } = await import('./src/ui/tui/start-tui.js'); + const { buildSession } = await import('./src/lib/wizard-session.js'); + const { Flow } = await import('./src/ui/tui/router.js'); + + const tui = startTUI(WIZARD_VERSION, Flow.YourFlow); + + const session = buildSession({ + debug: options.debug, + localMcp: options.localMcp, + installDir, + ci: false, + }); + tui.store.session = session; + tui.store.session.setupConfirmed = true; // skip intro if no intro step + + await tui.store.getGate('health-check'); // resolves immediately if no health step + + const { runYourWizard } = await import('./src/lib/your-runner.js'); + await runYourWizard(tui.store.session); + }); +``` + +### 5. Verify + +1. `npm test` — all tests should pass (no store changes needed) +2. Run your command: `npx posthog-wizard your-command` +3. Verify the outro shows your success message and report file + +## Architecture Notes + +- **Workflow steps** (`workflow-step.ts`) are the single source of truth for flow structure, gates, and init work +- **The store** derives gate promises from step definitions — no per-flow hardcoding +- **The skill runner** (`skill-runner.ts`) handles the full lifecycle: skill install, agent init, prompt, error handling, outro +- **The outro screen** reads `outroData.message` and `outroData.reportFile` — no hardcoded strings +- Adding a new workflow requires **zero changes** to the store or outro screen diff --git a/README.md b/README.md index 13a377e6..0dcadda5 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ npx @posthog/wizard mcp add npx @posthog/wizard mcp remove ``` +## Revenue Analytics + +Wire up an existing PostHog + Stripe project for revenue analytics: + +```bash +npx @posthog/wizard revenue +``` + +Requires PostHog and Stripe SDKs already installed. Supports `--ci` with the +same flags as the main wizard. + # Options The following CLI arguments are available: diff --git a/bin.ts b/bin.ts index daba3689..b45af4c3 100644 --- a/bin.ts +++ b/bin.ts @@ -19,12 +19,12 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { process.exit(1); } -import { runWizard } from './src/run'; import { isNonInteractiveEnvironment } from './src/utils/environment'; import { getUI, setUI } from './src/ui'; import { LoggingUI } from './src/ui/logging-ui'; -import type { Integration } from './src/lib/constants'; -import type { FrameworkConfig } from './src/lib/framework-config'; +import { getSubcommandWorkflows } from './src/lib/workflows/workflow-registry'; +import type { WorkflowConfig } from './src/lib/workflows/workflow-step'; +import type { WizardSession } from './src/lib/wizard-session'; if (process.env.NODE_ENV === 'test') { void (async () => { @@ -39,7 +39,36 @@ if (process.env.NODE_ENV === 'test') { })(); } -yargs(hideBin(process.argv)) +/** Shared yargs options for skill-based workflow subcommands. */ +const skillSubcommandOptions = { + debug: { + default: false, + describe: 'Enable verbose logging', + type: 'boolean' as const, + }, + 'install-dir': { + describe: 'Directory to install in', + type: 'string' as const, + }, + 'local-mcp': { + default: false, + describe: 'Use local MCP server', + type: 'boolean' as const, + }, + benchmark: { + default: false, + describe: 'Run in benchmark mode', + type: 'boolean' as const, + }, + 'yara-report': { + default: false, + describe: 'Print YARA scanner summary', + type: 'boolean' as const, + hidden: true, + }, +}; + +const cli = yargs(hideBin(process.argv)) .env('POSTHOG_WIZARD') // global options .options({ @@ -87,11 +116,6 @@ yargs(hideBin(process.argv)) 'PostHog project ID to use (optional; when not set, uses default from API key or OAuth)\nenv: POSTHOG_WIZARD_PROJECT_ID', type: 'string', }, - email: { - describe: - 'Email address for account creation (used with --signup)\nenv: POSTHOG_WIZARD_EMAIL', - type: 'string', - }, }) .command( ['$0'], @@ -146,6 +170,11 @@ yargs(hideBin(process.argv)) type: 'boolean', hidden: true, }, + skill: { + describe: + 'Run a specific context-mill skill by ID\nenv: POSTHOG_WIZARD_SKILL', + type: 'string', + }, }); }, (argv) => { @@ -153,28 +182,74 @@ yargs(hideBin(process.argv)) // CI mode validation and TTY check if (options.ci) { - // Use LoggingUI for CI mode (no dependencies, no prompts) - setUI(new LoggingUI()); - // Default region to 'us' if not specified - if (!options.region) { - options.region = 'us'; - } + if (!options.region) options.region = 'us'; if (!options.apiKey) { - getUI().intro(`PostHog Wizard`); + setUI(new LoggingUI()); + getUI().intro('PostHog Wizard'); getUI().log.error( 'CI mode requires --api-key (personal API key phx_xxx)', ); process.exit(1); + return; } if (!options.installDir) { - getUI().intro(`PostHog Wizard`); + setUI(new LoggingUI()); + getUI().intro('PostHog Wizard'); getUI().log.error( - 'CI mode requires --install-dir (directory to install PostHog in)', + 'CI mode requires --install-dir (directory to install in)', ); process.exit(1); + return; } + void (async () => { + const { posthogIntegrationConfig } = await import( + './src/lib/workflows/posthog-integration/index.js' + ); + const { FRAMEWORK_REGISTRY } = await import('./src/lib/registry.js'); + const { detectFramework, gatherFrameworkContext } = await import( + './src/lib/detection/index.js' + ); + const { analytics } = await import('./src/utils/analytics.js'); + const { wizardAbort } = await import('./src/utils/wizard-abort.js'); + + // preRun: honor --integration, else auto-detect, then gather + // framework context. Bypasses onReady hooks by design. + runWizardCI(posthogIntegrationConfig, options, async (session) => { + const integration = + session.integration ?? + (await detectFramework(session.installDir)); + if (!integration) { + await wizardAbort({ + message: + 'Could not auto-detect your framework. Please specify --integration on the command line.', + }); + return; + } + session.integration = integration; + analytics.setTag('integration', integration); - void runWizard(options as Parameters[0]); + const frameworkConfig = FRAMEWORK_REGISTRY[integration]; + session.frameworkConfig = frameworkConfig; + + const context = await gatherFrameworkContext(frameworkConfig, { + installDir: session.installDir, + debug: session.debug, + forceInstall: session.forceInstall, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: true, + menu: session.menu, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }); + for (const [key, value] of Object.entries(context)) { + if (!(key in session.frameworkContext)) { + session.frameworkContext[key] = value; + } + } + }); + })(); } else if (isNonInteractiveEnvironment()) { // Non-interactive non-CI: error out getUI().intro(`PostHog Wizard`); @@ -194,205 +269,35 @@ yargs(hideBin(process.argv)) ); (startPlayground as (version: string) => void)(WIZARD_VERSION); })(); + } else if (options.skill) { + // Run a specific skill by ID + void (async () => { + const { createSkillWorkflow } = await import( + './src/lib/workflows/agent-skill/index.js' + ); + const skillId = options.skill as string; + const config = createSkillWorkflow({ + skillId, + command: 'skill', + flowKey: 'agent-skill', + description: `Run skill: ${skillId}`, + integrationLabel: skillId, + successMessage: `${skillId} completed!`, + reportFile: `posthog-${skillId}-report.md`, + docsUrl: 'https://posthog.com/docs', + spinnerMessage: `Running ${skillId}...`, + estimatedDurationMinutes: 5, + }); + runWizard(config, options); + })(); } else { - // Interactive TTY: launch the Ink TUI + // Interactive TTY: run core-integration through the unified workflow path. + // Same codepath as `npx @posthog/wizard integrate`. void (async () => { - try { - const { startTUI } = await import('./src/ui/tui/start-tui.js'); - const { buildSession } = await import( - './src/lib/wizard-session.js' - ); - - const tui = startTUI(WIZARD_VERSION); - - // Build session from CLI args and attach to store - const session = buildSession({ - debug: options.debug as boolean | undefined, - forceInstall: options.forceInstall as boolean | undefined, - installDir: options.installDir as string | undefined, - ci: false, - signup: options.signup as boolean | undefined, - localMcp: options.localMcp as boolean | undefined, - apiKey: options.apiKey as string | undefined, - email: options.email, - menu: options.menu as boolean | undefined, - integration: options.integration as Parameters< - typeof buildSession - >[0]['integration'], - benchmark: options.benchmark as boolean | undefined, - yaraReport: options.yaraReport as boolean | undefined, - projectId: options.projectId as string | undefined, - }); - tui.store.session = session; - - // Detect framework while IntroScreen shows its spinner. - // Runs concurrently — IntroScreen reacts when detection completes. - const { FRAMEWORK_REGISTRY } = (await import( - './src/lib/registry.js' - )) as { FRAMEWORK_REGISTRY: Record }; - const { detectIntegration } = (await import('./src/run.js')) as { - detectIntegration: ( - installDir: string, - ) => Promise; - }; - const installDir = session.installDir ?? process.cwd(); - - const { DETECTION_TIMEOUT_MS } = (await import( - './src/lib/constants.js' - )) as { DETECTION_TIMEOUT_MS: number }; - - const detectedIntegration = await Promise.race([ - detectIntegration(installDir), - new Promise((resolve) => - setTimeout(() => resolve(undefined), DETECTION_TIMEOUT_MS), - ), - ]); - - if (detectedIntegration) { - const config = FRAMEWORK_REGISTRY[detectedIntegration]; - - // Run gatherContext for the friendly variant label - if (config.metadata.gatherContext) { - try { - const context = await Promise.race([ - config.metadata.gatherContext({ - installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }), - new Promise>((resolve) => - setTimeout(() => resolve({}), DETECTION_TIMEOUT_MS), - ), - ]); - for (const [key, value] of Object.entries(context)) { - if (!(key in session.frameworkContext)) { - tui.store.setFrameworkContext(key, value); - } - } - } catch { - // Detection failed — will show generic name - } - } - - tui.store.setFrameworkConfig(detectedIntegration, config); - - if (!session.detectedFrameworkLabel) { - tui.store.setDetectedFramework(config.metadata.name); - } - - // Early version check — surface on IntroScreen before user proceeds - if ( - config.detection.minimumVersion && - config.detection.getInstalledVersion - ) { - const semver = await import('semver'); - const version = await config.detection.getInstalledVersion({ - installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }); - if (version) { - const coerced = semver.coerce(version); - if ( - coerced && - semver.lt(coerced, config.detection.minimumVersion) - ) { - tui.store.setUnsupportedVersion({ - current: version, - minimum: config.detection.minimumVersion, - docsUrl: - config.metadata.unsupportedVersionDocsUrl ?? - config.metadata.docsUrl, - }); - } - } - } - } - - // Feature discovery — deterministic scan of package.json deps - try { - const { readFileSync } = await import('fs'); - const pkgPath = require('path').join(installDir, 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); - const allDeps = { - ...pkg.dependencies, - ...pkg.devDependencies, - }; - const depNames = Object.keys(allDeps); - - const { DiscoveredFeature } = await import( - './src/lib/wizard-session.js' - ); - - if ( - depNames.some((d) => - ['stripe', '@stripe/stripe-js'].includes(d), - ) - ) { - tui.store.addDiscoveredFeature(DiscoveredFeature.Stripe); - } - - // LLM SDK detection — sourced from PostHog LLM analytics skill - const LLM_PACKAGES = [ - 'openai', - '@anthropic-ai/sdk', - 'ai', - '@ai-sdk/openai', - 'langchain', - '@langchain/openai', - '@langchain/langgraph', - '@google/generative-ai', - '@google/genai', - '@instructor-ai/instructor', - '@mastra/core', - 'portkey-ai', - ]; - if (depNames.some((d) => LLM_PACKAGES.includes(d))) { - tui.store.addDiscoveredFeature(DiscoveredFeature.LLM); - } - } catch { - // No package.json or parse error — skip feature discovery - } - - // Signal detection is done — IntroScreen shows picker or results - tui.store.setDetectionComplete(); - - // Wait for IntroScreen confirmation - await tui.waitForSetup(); - - // Ensure health check has completed before starting the wizard. - // The flow gate on Intro (readinessResult !== null) keeps the - // TUI on IntroScreen until this resolves. If blocking, the - // outage overlay was already pushed in the .then() callback. - await tui.store.healthGateComplete; - - await runWizard( - options as Parameters[0], - tui.store.session, - ); - - // Keep the outro screen visible — let process.exit() handle cleanup - } catch (err) { - // TUI unavailable (e.g., in test environment) — continue with default UI - if (process.env.DEBUG || process.env.POSTHOG_WIZARD_DEBUG) { - console.error('TUI init failed:', err); // eslint-disable-line no-console - } - await runWizard(options as Parameters[0]); - } + const { posthogIntegrationConfig } = await import( + './src/lib/workflows/posthog-integration/index.js' + ); + runWizard(posthogIntegrationConfig, options); })(); } }, @@ -509,9 +414,232 @@ yargs(hideBin(process.argv)) ) .demandCommand(1, 'You must specify a subcommand (add or remove)') .help(); - }) + }); + +// ── Skill-based workflow subcommands (derived from registry) ───────── +for (const wfConfig of getSubcommandWorkflows()) { + cli.command( + wfConfig.command!, + wfConfig.description, + (y) => y.options(skillSubcommandOptions), + (argv) => { + const options = { ...argv }; + if (options.ci) { + runWizardCI(wfConfig, options); + } else { + runWizard(wfConfig, options); + } + }, + ); +} + +cli .help() .alias('help', 'h') .version() .alias('version', 'v') .wrap(process.stdout.isTTY ? yargs.terminalWidth() : 80).argv; + +/** + * Run a full wizard workflow in the TUI. Handles the full lifecycle: start TUI, + * build session, run detection, wait for intro gate, execute the + * agent pipeline, wait for outro dismissal, then exit. + */ +function runWizard( + config: WorkflowConfig, + options: Record, +): void { + void (async () => { + try { + const installDir = (options.installDir as string) || process.cwd(); + + const { startTUI } = await import('./src/ui/tui/start-tui.js'); + const { buildSession } = await import('./src/lib/wizard-session.js'); + + // flowKey values match Flow enum values by convention + const tui = startTUI(WIZARD_VERSION, config.flowKey as any); + + const session = buildSession({ + debug: options.debug as boolean | undefined, + forceInstall: options.forceInstall as boolean | undefined, + localMcp: options.localMcp as boolean | undefined, + installDir, + ci: false, + signup: options.signup as boolean | undefined, + apiKey: options.apiKey as string | undefined, + projectId: options.projectId as string | undefined, + menu: options.menu as boolean | undefined, + integration: options.integration as any, + benchmark: options.benchmark as boolean | undefined, + yaraReport: options.yaraReport as boolean | undefined, + }); + // Set workflow metadata for TUI display + session.workflowLabel = config.flowKey; + const runDef = typeof config.run === 'object' ? config.run : null; + session.skillId = runDef?.skillId ?? null; + + tui.store.session = session; + + await tui.store.runReadyHooks(); + await tui.store.getGate('intro'); + + const { runAgent } = await import('./src/lib/agent/agent-runner.js'); + await runAgent(config, tui.store.session); + + tui.store.onEnterScreen('outro' as any, () => { + // Screen is already outro — listen for dismissal + }); + await new Promise((resolve) => { + const unsub = tui.store.subscribe(() => { + if (tui.store.session.outroDismissed) { + unsub(); + resolve(); + } + }); + if (tui.store.session.outroDismissed) { + unsub(); + resolve(); + } + }); + process.exit(0); + } catch (err) { + if (process.env.DEBUG || process.env.POSTHOG_WIZARD_DEBUG) { + console.error('TUI init failed:', err); // eslint-disable-line no-console + } + } + })(); +} + +/** + * CI-mode pipeline shared by every non-interactive entry point. + * + * Validates flags, builds a `ci:true` session, runs `preRun` (or the + * workflow's `onReady` hooks by default), executes `runAgent`, and + * routes any failure through `wizardAbort`. `wizardAbort` owns all + * exits — never add a raw `process.exit` here. + */ +function runWizardCI( + config: WorkflowConfig, + options: Record, + preRun?: (session: WizardSession) => Promise, +): void { + setUI(new LoggingUI()); + if (!options.region) options.region = 'us'; + if (!options.apiKey) { + getUI().intro('PostHog Wizard'); + getUI().log.error('CI mode requires --api-key (personal API key phx_xxx)'); + process.exit(1); + } + if (!options.installDir) { + getUI().intro('PostHog Wizard'); + getUI().log.error( + 'CI mode requires --install-dir (directory to install in)', + ); + process.exit(1); + } + + void (async () => { + const path = await import('path'); + const { buildSession } = await import('./src/lib/wizard-session.js'); + const { readEnvironment } = await import('./src/utils/environment.js'); + const { readApiKeyFromEnv } = await import('./src/utils/env-api-key.js'); + const { configureLogFileFromEnvironment, logToFile } = await import( + './src/utils/debug.js' + ); + const { wizardAbort, WizardError } = await import( + './src/utils/wizard-abort.js' + ); + + configureLogFileFromEnvironment(); + + const env = readEnvironment(); + const apiKey = + (options.apiKey as string) ?? readApiKeyFromEnv() ?? undefined; + const installDir = path.isAbsolute(options.installDir as string) + ? (options.installDir as string) + : path.join(process.cwd(), options.installDir as string); + + const session = buildSession({ + debug: options.debug as boolean | undefined, + forceInstall: options.forceInstall as boolean | undefined, + installDir, + ci: true, + signup: options.signup as boolean | undefined, + localMcp: options.localMcp as boolean | undefined, + apiKey, + menu: options.menu as boolean | undefined, + integration: options.integration as any, // eslint-disable-line @typescript-eslint/no-explicit-any + projectId: options.projectId as string | undefined, + benchmark: options.benchmark as boolean | undefined, + yaraReport: options.yaraReport as boolean | undefined, + ...env, + }); + session.workflowLabel = config.flowKey; + const runDef = typeof config.run === 'object' ? config.run : null; + session.skillId = runDef?.skillId ?? null; + + getUI().intro('Welcome to the PostHog setup wizard'); + getUI().log.info(`Running ${config.flowKey} in CI mode`); + + try { + if (preRun) { + await preRun(session); + } else { + // Run onReady hooks against a minimal store-less context. + const readyCtx = { + session, + setFrameworkContext: (key: string, value: unknown) => { + session.frameworkContext[key] = value; + }, + setFrameworkConfig: () => undefined, + setDetectedFramework: () => undefined, + setUnsupportedVersion: () => undefined, + addDiscoveredFeature: () => undefined, + setDetectionComplete: () => undefined, + }; + for (const step of config.steps) { + if (step.onReady) { + await step.onReady(readyCtx); + } + } + + // Surface detectError written by the workflow's detect hook. + const detectError = session.frameworkContext.detectError as + | { kind: string; [k: string]: unknown } + | undefined; + if (detectError) { + await wizardAbort({ + message: `Prerequisites not met: ${detectError.kind}\n\nSee ${ + runDef?.docsUrl ?? 'https://posthog.com/docs' + }`, + error: new WizardError(`${config.flowKey} prerequisites failed`, { + integration: config.flowKey, + detect_error_kind: detectError.kind, + }), + }); + } + } + + const { runAgent } = await import('./src/lib/agent/agent-runner.js'); + await runAgent(config, session); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = + error instanceof Error && error.stack ? error.stack : undefined; + + logToFile(`[bin.ts CI] ERROR: ${errorMessage}`); + if (errorStack) logToFile(`[bin.ts CI] STACK: ${errorStack}`); + + const debugInfo = session.debug && errorStack ? `\n\n${errorStack}` : ''; + const docsUrl = + session.frameworkConfig?.metadata.docsUrl ?? + runDef?.docsUrl ?? + 'https://posthog.com/docs'; + await wizardAbort({ + message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${docsUrl} to set up manually.${debugInfo}`, + error: error as Error, + }); + } + })(); +} diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 01e14f20..3c57eb7e 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,13 +1,54 @@ -// Mock functions must be defined before imports -const mockRunWizard = jest.fn(); +// Mock functions must be defined before imports (jest hoists jest.mock calls; +// variables starting with "mock" are allowed in the factory scope). +const mockBuildSession = jest.fn((args: Record) => args); -jest.mock('../run', () => ({ runWizard: mockRunWizard })); jest.mock('semver', () => ({ satisfies: () => true })); +jest.mock('../lib/wizard-session', () => ({ + buildSession: mockBuildSession, +})); jest.mock('../ui/tui/start-tui', () => ({ - startTUI: () => ({ unmount: jest.fn(), store: { session: {} } }), + startTUI: () => ({ + unmount: jest.fn(), + store: { + session: {}, + runReadyHooks: jest.fn().mockResolvedValue(undefined), + // eslint-disable-next-line @typescript-eslint/no-empty-function + getGate: jest.fn().mockReturnValue(new Promise(() => {})), + subscribe: jest.fn(), + onEnterScreen: jest.fn(), + }, + }), })); -jest.mock('../lib/wizard-session', () => ({ - buildSession: (args: Record) => args, +jest.mock('../lib/workflows/posthog-integration/index', () => ({ + posthogIntegrationConfig: { + flowKey: 'posthog-integration', + steps: [], + run: null, + }, +})); +jest.mock('../utils/environment', () => ({ + isNonInteractiveEnvironment: () => false, + readEnvironment: () => ({}), +})); +// CI-path dynamic imports need mocks to prevent unhandled rejections +jest.mock('../utils/env-api-key', () => ({ + readApiKeyFromEnv: () => undefined, +})); +jest.mock('../utils/debug', () => ({ + configureLogFileFromEnvironment: jest.fn(), + logToFile: jest.fn(), +})); +jest.mock('../lib/registry', () => ({ FRAMEWORK_REGISTRY: {} })); +jest.mock('../lib/detection/index', () => ({ + detectFramework: jest.fn().mockResolvedValue(null), + gatherFrameworkContext: jest.fn().mockResolvedValue({}), +})); +jest.mock('../utils/analytics', () => ({ + analytics: { setTag: jest.fn() }, +})); +jest.mock('../utils/wizard-abort', () => ({ wizardAbort: jest.fn() })); +jest.mock('../lib/agent/agent-runner', () => ({ + runAgent: jest.fn().mockResolvedValue(undefined), })); describe('CLI argument parsing', () => { @@ -27,8 +68,12 @@ describe('CLI argument parsing', () => { delete process.env.POSTHOG_WIZARD_API_KEY; delete process.env.POSTHOG_WIZARD_INSTALL_DIR; - // Mock process.exit to prevent test runner from exiting - process.exit = jest.fn() as any; + // Mock process.exit to prevent test runner from exiting. + // Throwing stops the handler from continuing past validation failures + // (e.g. into the CI async IIFE that expects validated options). + process.exit = jest.fn().mockImplementation(() => { + throw new Error('process.exit'); + }) as any; }); afterEach(() => { @@ -44,60 +89,55 @@ describe('CLI argument parsing', () => { async function runCLI(args: string[]) { process.argv = ['node', 'bin.ts', ...args]; - jest.isolateModules(() => { - require('../../bin.ts'); - }); + try { + jest.isolateModules(() => { + require('../../bin.ts'); + }); + } catch { + // process.exit mock throws to halt handler execution + } - // Allow yargs to process + // Allow yargs + async handlers to process await new Promise((resolve) => setImmediate(resolve)); } /** - * Helper to get the arguments passed to a mock function + * Helper to get the arguments passed to the last buildSession call. + * buildSession is the common interception point for both CI and non-CI paths. */ - function getLastCallArgs(mockFn: jest.Mock) { - expect(mockFn).toHaveBeenCalled(); - return mockFn.mock.calls[mockFn.mock.calls.length - 1][0]; + function getLastBuildSessionArgs() { + expect(mockBuildSession).toHaveBeenCalled(); + const calls = mockBuildSession.mock.calls; + return calls[calls.length - 1][0]; } + // Note: --default and --region are yargs options that don't flow through + // buildSession in the non-CI path, so they're tested indirectly (no errors) + // rather than by inspecting values. + describe('--default flag', () => { - test('defaults to true when not specified', async () => { + test('accepted when not specified', async () => { await runCLI([]); - - const args = getLastCallArgs(mockRunWizard); - expect(args.default).toBe(true); + expect(mockBuildSession).toHaveBeenCalled(); }); - test('can be explicitly set to false with --no-default', async () => { + test('accepted with --no-default', async () => { await runCLI(['--no-default']); - - const args = getLastCallArgs(mockRunWizard); - expect(args.default).toBe(false); + expect(mockBuildSession).toHaveBeenCalled(); }); - test('can be explicitly set to true', async () => { + test('accepted when explicitly set to true', async () => { await runCLI(['--default']); - - const args = getLastCallArgs(mockRunWizard); - expect(args.default).toBe(true); + expect(mockBuildSession).toHaveBeenCalled(); }); }); describe('--region flag', () => { - test('is undefined when not specified', async () => { - await runCLI([]); - - const args = getLastCallArgs(mockRunWizard); - expect(args.region).toBeUndefined(); - }); - test.each(['us', 'eu'])( 'accepts "%s" as a valid region', async (region) => { await runCLI(['--region', region]); - - const args = getLastCallArgs(mockRunWizard); - expect(args.region).toBe(region); + expect(mockBuildSession).toHaveBeenCalled(); }, ); }); @@ -108,8 +148,7 @@ describe('CLI argument parsing', () => { await runCLI([]); - const args = getLastCallArgs(mockRunWizard); - expect(args.region).toBe('eu'); + expect(mockBuildSession).toHaveBeenCalled(); }); test('respects POSTHOG_WIZARD_DEFAULT', async () => { @@ -117,8 +156,7 @@ describe('CLI argument parsing', () => { await runCLI([]); - const args = getLastCallArgs(mockRunWizard); - expect(args.default).toBe(false); + expect(mockBuildSession).toHaveBeenCalled(); }); test('CLI args override environment variables', async () => { @@ -127,16 +165,7 @@ describe('CLI argument parsing', () => { await runCLI(['--region', 'eu', '--default']); - const args = getLastCallArgs(mockRunWizard); - expect(args.region).toBe('eu'); - expect(args.default).toBe(true); - }); - - test('region is undefined when no env var or CLI arg', async () => { - await runCLI([]); - - const args = getLastCallArgs(mockRunWizard); - expect(args.region).toBeUndefined(); + expect(mockBuildSession).toHaveBeenCalled(); }); }); @@ -152,18 +181,14 @@ describe('CLI argument parsing', () => { 'nextjs', ]); - const args = getLastCallArgs(mockRunWizard); + const args = getLastBuildSessionArgs(); - // Existing flags + // Existing flags forwarded through buildSession expect(args.debug).toBe(true); expect(args.signup).toBe(true); - expect(args['force-install']).toBe(true); - expect(args['install-dir']).toBe('/custom/path'); + expect(args.forceInstall).toBe(true); + expect(args.installDir).toBe('/custom/path'); expect(args.integration).toBe('nextjs'); - - // New defaults - expect(args.default).toBe(true); - expect(args.region).toBeUndefined(); }); }); @@ -173,7 +198,7 @@ describe('CLI argument parsing', () => { test('defaults to false when not specified', async () => { await runCLI([]); - const args = getLastCallArgs(mockRunWizard); + const args = getLastBuildSessionArgs(); expect(args.ci).toBe(false); }); @@ -188,7 +213,7 @@ describe('CLI argument parsing', () => { '/tmp/test', ]); - const args = getLastCallArgs(mockRunWizard); + const args = getLastBuildSessionArgs(); expect(args.ci).toBe(true); }); @@ -216,7 +241,7 @@ describe('CLI argument parsing', () => { expect(process.exit).toHaveBeenCalledWith(1); }); - test('passes --api-key to runWizard', async () => { + test('passes --api-key through to buildSession', async () => { await runCLI([ '--ci', '--region', @@ -227,7 +252,7 @@ describe('CLI argument parsing', () => { '/tmp/test', ]); - const args = getLastCallArgs(mockRunWizard); + const args = getLastBuildSessionArgs(); expect(args.apiKey).toBe('phx_test_key'); }); }); @@ -241,7 +266,7 @@ describe('CLI argument parsing', () => { await runCLI([]); - const args = getLastCallArgs(mockRunWizard); + const args = getLastBuildSessionArgs(); expect(args.ci).toBe(true); }); @@ -253,7 +278,7 @@ describe('CLI argument parsing', () => { await runCLI([]); - const args = getLastCallArgs(mockRunWizard); + const args = getLastBuildSessionArgs(); expect(args.apiKey).toBe('phx_env_key'); }); @@ -272,8 +297,7 @@ describe('CLI argument parsing', () => { '/other/path', ]); - const args = getLastCallArgs(mockRunWizard); - expect(args.region).toBe('eu'); + const args = getLastBuildSessionArgs(); expect(args.apiKey).toBe('phx_cli_key'); }); }); diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts deleted file mode 100644 index b1e65795..00000000 --- a/src/__tests__/run.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { runWizard } from '../run'; -import { runAgentWizard } from '../lib/agent-runner'; -import { analytics } from '../utils/analytics'; -import { Integration } from '../lib/constants'; - -jest.mock('../lib/agent-runner'); -jest.mock('../utils/analytics'); -jest.mock('../lib/wizard-session', () => ({ - buildSession: (args: Record) => ({ - debug: false, - forceInstall: false, - installDir: process.cwd(), - ci: false, - signup: false, - localMcp: false, - menu: false, - setupConfirmed: false, - integration: null, - frameworkContext: {}, - typescript: false, - credentials: null, - readinessResult: null, - outageDismissed: false, - outroData: null, - frameworkConfig: null, - ...args, - }), -})); -jest.mock('../ui', () => ({ - getUI: jest.fn().mockReturnValue({ - log: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - success: jest.fn(), - step: jest.fn(), - }, - intro: jest.fn(), - outro: jest.fn(), - cancel: jest.fn(), - note: jest.fn(), - spinner: jest.fn().mockReturnValue({ - start: jest.fn(), - stop: jest.fn(), - message: jest.fn(), - }), - setDetectedFramework: jest.fn(), - setCredentials: jest.fn(), - pushStatus: jest.fn(), - syncTodos: jest.fn(), - setLoginUrl: jest.fn(), - showBlockingOutage: jest.fn(), - setReadinessWarnings: jest.fn(), - showSettingsOverride: jest.fn(), - startRun: jest.fn(), - }), - setUI: jest.fn(), -})); - -const mockRunAgentWizard = runAgentWizard as jest.MockedFunction< - typeof runAgentWizard ->; -const mockAnalytics = analytics as jest.Mocked; - -describe('runWizard error handling', () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockAnalytics.setTag = jest.fn(); - mockAnalytics.captureException = jest.fn(); - mockAnalytics.shutdown = jest.fn().mockResolvedValue(undefined); - - jest.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should capture exception and shutdown analytics on wizard error', async () => { - const testError = new Error('Wizard failed'); - const testArgs = { - integration: Integration.nextjs, - debug: true, - forceInstall: false, - }; - - mockRunAgentWizard.mockRejectedValue(testError); - - await expect(runWizard(testArgs)).rejects.toThrow('process.exit called'); - - expect(mockAnalytics.captureException).toHaveBeenCalledWith(testError, {}); - - expect(mockAnalytics.shutdown).toHaveBeenCalledWith('error'); - }); - - it('should not call captureException when wizard succeeds', async () => { - const testArgs = { integration: Integration.nextjs }; - - mockRunAgentWizard.mockResolvedValue(undefined); - - await runWizard(testArgs); - - expect(mockAnalytics.captureException).not.toHaveBeenCalled(); - expect(mockAnalytics.shutdown).not.toHaveBeenCalled(); - }); -}); diff --git a/src/__tests__/wizard-abort.test.ts b/src/__tests__/wizard-abort.test.ts index 01edbb9b..95574bc8 100644 --- a/src/__tests__/wizard-abort.test.ts +++ b/src/__tests__/wizard-abort.test.ts @@ -10,7 +10,7 @@ import { analytics } from '../utils/analytics'; jest.mock('../utils/analytics'); jest.mock('../ui', () => ({ getUI: jest.fn().mockReturnValue({ - outro: jest.fn(), + outroError: jest.fn(), }), })); @@ -34,25 +34,27 @@ describe('wizardAbort', () => { jest.restoreAllMocks(); }); - it('calls analytics.shutdown, getUI().outro, and process.exit in order', async () => { + it('calls analytics.shutdown, getUI().outroError, and process.exit in order', async () => { const callOrder: string[] = []; mockAnalytics.shutdown.mockImplementation(async () => { callOrder.push('shutdown'); }); - getUI().outro.mockImplementation(() => { - callOrder.push('outro'); + getUI().outroError.mockImplementation(() => { + callOrder.push('outroError'); }); await expect(wizardAbort()).rejects.toThrow('process.exit called'); - expect(callOrder).toEqual(['shutdown', 'outro']); + expect(callOrder).toEqual(['shutdown', 'outroError']); expect(process.exit).toHaveBeenCalledWith(1); }); it('uses default message and exit code when called with no options', async () => { await expect(wizardAbort()).rejects.toThrow('process.exit called'); - expect(getUI().outro).toHaveBeenCalledWith('Wizard setup cancelled.'); + expect(getUI().outroError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Wizard setup cancelled.' }), + ); expect(mockAnalytics.shutdown).toHaveBeenCalledWith('cancelled'); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -62,10 +64,32 @@ describe('wizardAbort', () => { wizardAbort({ message: 'Custom failure', exitCode: 2 }), ).rejects.toThrow('process.exit called'); - expect(getUI().outro).toHaveBeenCalledWith('Custom failure'); + expect(getUI().outroError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Custom failure' }), + ); expect(process.exit).toHaveBeenCalledWith(2); }); + it('passes through structured outroData when provided', async () => { + await expect( + wizardAbort({ + outroData: { + kind: 'error' as never, + message: 'Agent aborted', + body: 'reason', + docsUrl: 'https://posthog.com/docs', + }, + }), + ).rejects.toThrow('process.exit called'); + + expect(getUI().outroError).toHaveBeenCalledWith({ + kind: 'error', + message: 'Agent aborted', + body: 'reason', + docsUrl: 'https://posthog.com/docs', + }); + }); + it('captures error in analytics and shuts down as error when error is provided', async () => { const error = new Error('something broke'); @@ -103,13 +127,18 @@ describe('wizardAbort', () => { mockAnalytics.shutdown.mockImplementation(async () => { callOrder.push('shutdown'); }); - getUI().outro.mockImplementation(() => { - callOrder.push('outro'); + getUI().outroError.mockImplementation(() => { + callOrder.push('outroError'); }); await expect(wizardAbort()).rejects.toThrow('process.exit called'); - expect(callOrder).toEqual(['cleanup1', 'cleanup2', 'shutdown', 'outro']); + expect(callOrder).toEqual([ + 'cleanup1', + 'cleanup2', + 'shutdown', + 'outroError', + ]); }); it('does not block exit when a cleanup function throws', async () => { @@ -123,7 +152,7 @@ describe('wizardAbort', () => { await expect(wizardAbort()).rejects.toThrow('process.exit called'); expect(mockAnalytics.shutdown).toHaveBeenCalled(); - expect(getUI().outro).toHaveBeenCalled(); + expect(getUI().outroError).toHaveBeenCalled(); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -158,7 +187,9 @@ describe('abort() delegates to wizardAbort()', () => { await expect(abort('Test abort', 3)).rejects.toThrow('process.exit called'); - expect(getUI().outro).toHaveBeenCalledWith('Test abort'); + expect(getUI().outroError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Test abort' }), + ); expect(process.exit).toHaveBeenCalledWith(3); }); @@ -167,7 +198,9 @@ describe('abort() delegates to wizardAbort()', () => { await expect(abort()).rejects.toThrow('process.exit called'); - expect(getUI().outro).toHaveBeenCalledWith('Wizard setup cancelled.'); + expect(getUI().outroError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Wizard setup cancelled.' }), + ); expect(process.exit).toHaveBeenCalledWith(1); }); }); diff --git a/src/frameworks/android/android-wizard-agent.ts b/src/frameworks/android/android-wizard-agent.ts index f4c752c4..f48de250 100644 --- a/src/frameworks/android/android-wizard-agent.ts +++ b/src/frameworks/android/android-wizard-agent.ts @@ -10,7 +10,7 @@ import { getKotlinVersionBucket, getMinSdkVersion, } from './utils'; -import { gradlePackageManager } from '../../lib/package-manager-detection'; +import { gradlePackageManager } from '../../lib/detection/package-manager'; type AndroidContext = { kotlinVersion?: string; diff --git a/src/frameworks/angular/angular-wizard-agent.ts b/src/frameworks/angular/angular-wizard-agent.ts index bc1b842c..eb39da0a 100644 --- a/src/frameworks/angular/angular-wizard-agent.ts +++ b/src/frameworks/angular/angular-wizard-agent.ts @@ -1,7 +1,7 @@ /* Angular wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/astro/astro-wizard-agent.ts b/src/frameworks/astro/astro-wizard-agent.ts index 3447fd94..e83a8800 100644 --- a/src/frameworks/astro/astro-wizard-agent.ts +++ b/src/frameworks/astro/astro-wizard-agent.ts @@ -1,7 +1,7 @@ /* Astro wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/django/django-wizard-agent.ts b/src/frameworks/django/django-wizard-agent.ts index 4b809ecb..3747d5ae 100644 --- a/src/frameworks/django/django-wizard-agent.ts +++ b/src/frameworks/django/django-wizard-agent.ts @@ -2,7 +2,7 @@ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; import { PYTHON_PACKAGE_INSTALLATION } from '../../lib/framework-config'; -import { detectPythonPackageManagers } from '../../lib/package-manager-detection'; +import { detectPythonPackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; diff --git a/src/frameworks/fastapi/fastapi-wizard-agent.ts b/src/frameworks/fastapi/fastapi-wizard-agent.ts index bf3099f1..06fbdf95 100644 --- a/src/frameworks/fastapi/fastapi-wizard-agent.ts +++ b/src/frameworks/fastapi/fastapi-wizard-agent.ts @@ -2,7 +2,7 @@ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; import { PYTHON_PACKAGE_INSTALLATION } from '../../lib/framework-config'; -import { detectPythonPackageManagers } from '../../lib/package-manager-detection'; +import { detectPythonPackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getFastAPIVersion, diff --git a/src/frameworks/flask/flask-wizard-agent.ts b/src/frameworks/flask/flask-wizard-agent.ts index 8b315f1a..6f19b35d 100644 --- a/src/frameworks/flask/flask-wizard-agent.ts +++ b/src/frameworks/flask/flask-wizard-agent.ts @@ -2,7 +2,7 @@ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; import { PYTHON_PACKAGE_INSTALLATION } from '../../lib/framework-config'; -import { detectPythonPackageManagers } from '../../lib/package-manager-detection'; +import { detectPythonPackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; diff --git a/src/frameworks/javascript-node/javascript-node-wizard-agent.ts b/src/frameworks/javascript-node/javascript-node-wizard-agent.ts index 74226fc1..d3ff3230 100644 --- a/src/frameworks/javascript-node/javascript-node-wizard-agent.ts +++ b/src/frameworks/javascript-node/javascript-node-wizard-agent.ts @@ -2,7 +2,7 @@ import type { FrameworkConfig } from '../../lib/framework-config'; import { Integration } from '../../lib/constants'; import { tryGetPackageJson } from '../../utils/setup-utils'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; type JavaScriptNodeContext = Record; diff --git a/src/frameworks/javascript-web/javascript-web-wizard-agent.ts b/src/frameworks/javascript-web/javascript-web-wizard-agent.ts index 2db937c4..177540c9 100644 --- a/src/frameworks/javascript-web/javascript-web-wizard-agent.ts +++ b/src/frameworks/javascript-web/javascript-web-wizard-agent.ts @@ -13,7 +13,7 @@ import { hasIndexHtml, type JavaScriptContext, } from './utils'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; export const JAVASCRIPT_WEB_AGENT_CONFIG: FrameworkConfig = { metadata: { diff --git a/src/frameworks/laravel/laravel-wizard-agent.ts b/src/frameworks/laravel/laravel-wizard-agent.ts index 8b1cf7b1..bcdae9ee 100644 --- a/src/frameworks/laravel/laravel-wizard-agent.ts +++ b/src/frameworks/laravel/laravel-wizard-agent.ts @@ -1,7 +1,7 @@ /* Laravel wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { composerPackageManager } from '../../lib/package-manager-detection'; +import { composerPackageManager } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; diff --git a/src/frameworks/nextjs/nextjs-wizard-agent.ts b/src/frameworks/nextjs/nextjs-wizard-agent.ts index da5f8b32..6d761d31 100644 --- a/src/frameworks/nextjs/nextjs-wizard-agent.ts +++ b/src/frameworks/nextjs/nextjs-wizard-agent.ts @@ -1,7 +1,7 @@ /* Simplified Next.js wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/nuxt/nuxt-wizard-agent.ts b/src/frameworks/nuxt/nuxt-wizard-agent.ts index 78de9aa6..d59845f2 100644 --- a/src/frameworks/nuxt/nuxt-wizard-agent.ts +++ b/src/frameworks/nuxt/nuxt-wizard-agent.ts @@ -1,7 +1,7 @@ /* Nuxt wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/python/python-wizard-agent.ts b/src/frameworks/python/python-wizard-agent.ts index 5175575c..4d0656d5 100644 --- a/src/frameworks/python/python-wizard-agent.ts +++ b/src/frameworks/python/python-wizard-agent.ts @@ -2,7 +2,7 @@ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; import { PYTHON_PACKAGE_INSTALLATION } from '../../lib/framework-config'; -import { detectPythonPackageManagers } from '../../lib/package-manager-detection'; +import { detectPythonPackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; diff --git a/src/frameworks/rails/rails-wizard-agent.ts b/src/frameworks/rails/rails-wizard-agent.ts index b73e8b6c..68cbf5a4 100644 --- a/src/frameworks/rails/rails-wizard-agent.ts +++ b/src/frameworks/rails/rails-wizard-agent.ts @@ -1,7 +1,7 @@ /* Ruby on Rails wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { bundlerPackageManager } from '../../lib/package-manager-detection'; +import { bundlerPackageManager } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getRailsVersion, diff --git a/src/frameworks/react-native/react-native-wizard-agent.ts b/src/frameworks/react-native/react-native-wizard-agent.ts index 861d5d0e..f2946f00 100644 --- a/src/frameworks/react-native/react-native-wizard-agent.ts +++ b/src/frameworks/react-native/react-native-wizard-agent.ts @@ -1,7 +1,7 @@ /* React Native wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/react-router/react-router-wizard-agent.ts b/src/frameworks/react-router/react-router-wizard-agent.ts index 7367522f..069142d9 100644 --- a/src/frameworks/react-router/react-router-wizard-agent.ts +++ b/src/frameworks/react-router/react-router-wizard-agent.ts @@ -1,7 +1,7 @@ /* React Router wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/ruby/ruby-wizard-agent.ts b/src/frameworks/ruby/ruby-wizard-agent.ts index 21c8b8b4..0270c557 100644 --- a/src/frameworks/ruby/ruby-wizard-agent.ts +++ b/src/frameworks/ruby/ruby-wizard-agent.ts @@ -1,7 +1,7 @@ /* Generic Ruby language wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { bundlerPackageManager } from '../../lib/package-manager-detection'; +import { bundlerPackageManager } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getRubyVersion, diff --git a/src/frameworks/svelte/svelte-wizard-agent.ts b/src/frameworks/svelte/svelte-wizard-agent.ts index 65f26b59..4cfebdb5 100644 --- a/src/frameworks/svelte/svelte-wizard-agent.ts +++ b/src/frameworks/svelte/svelte-wizard-agent.ts @@ -1,6 +1,6 @@ /* SvelteKit wizard using posthog-agent with PostHog MCP */ import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/swift/swift-wizard-agent.ts b/src/frameworks/swift/swift-wizard-agent.ts index 19cb845b..e0ebdfb7 100644 --- a/src/frameworks/swift/swift-wizard-agent.ts +++ b/src/frameworks/swift/swift-wizard-agent.ts @@ -1,7 +1,7 @@ /* Swift wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { swiftPackageManager } from '../../lib/package-manager-detection'; +import { swiftPackageManager } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import fg from 'fast-glob'; import * as fs from 'node:fs'; diff --git a/src/frameworks/tanstack-router/tanstack-router-wizard-agent.ts b/src/frameworks/tanstack-router/tanstack-router-wizard-agent.ts index 70a404ef..8e4eff27 100644 --- a/src/frameworks/tanstack-router/tanstack-router-wizard-agent.ts +++ b/src/frameworks/tanstack-router/tanstack-router-wizard-agent.ts @@ -1,7 +1,7 @@ /* TanStack Router wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/tanstack-start/tanstack-start-wizard-agent.ts b/src/frameworks/tanstack-start/tanstack-start-wizard-agent.ts index 2a62f939..dcc6a3d4 100644 --- a/src/frameworks/tanstack-start/tanstack-start-wizard-agent.ts +++ b/src/frameworks/tanstack-start/tanstack-start-wizard-agent.ts @@ -1,7 +1,7 @@ /* TanStack Start wizard using posthog-agent with PostHog MCP */ import type { WizardOptions } from '../../utils/types'; import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/frameworks/vue/vue-wizard-agent.ts b/src/frameworks/vue/vue-wizard-agent.ts index 38042334..958fb35c 100644 --- a/src/frameworks/vue/vue-wizard-agent.ts +++ b/src/frameworks/vue/vue-wizard-agent.ts @@ -1,6 +1,6 @@ /* Vue wizard using posthog-agent with PostHog MCP */ import type { FrameworkConfig } from '../../lib/framework-config'; -import { detectNodePackageManagers } from '../../lib/package-manager-detection'; +import { detectNodePackageManagers } from '../../lib/detection/package-manager'; import { Integration } from '../../lib/constants'; import { getPackageVersion, diff --git a/src/lib/__tests__/agent-interface.test.ts b/src/lib/__tests__/agent-interface.test.ts index e1ba0d83..2d72145c 100644 --- a/src/lib/__tests__/agent-interface.test.ts +++ b/src/lib/__tests__/agent-interface.test.ts @@ -1,4 +1,4 @@ -import { runAgent, createStopHook } from '../agent-interface'; +import { runAgent, createStopHook } from '../agent/agent-interface'; import type { WizardOptions } from '../../utils/types'; import type { SpinnerHandle } from '../../ui'; import { @@ -279,6 +279,45 @@ describe('runAgent', () => { // ui.log.error should NOT have been called (errors suppressed for user) expect(mockUIInstance.log.error).not.toHaveBeenCalled(); }); + + it('should ignore abort requests when no abort cases are registered', async () => { + function* mockGeneratorWithAbortText() { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: '[ABORT] Could not find a Stripe integration', + }, + ], + }, + }; + + yield { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Agent completed successfully', + }; + } + + mockQuery.mockReturnValue(mockGeneratorWithAbortText()); + + const result = await runAgent( + defaultAgentConfig, + 'test prompt', + defaultOptions, + mockSpinner as unknown as SpinnerHandle, + { + successMessage: 'Test success', + errorMessage: 'Test error', + }, + ); + + expect(result).toEqual({}); + expect(mockSpinner.stop).toHaveBeenCalledWith('Test success'); + }); }); }); diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts deleted file mode 100644 index 26254945..00000000 --- a/src/lib/agent-runner.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { - DEFAULT_PACKAGE_INSTALLATION, - SPINNER_MESSAGE, - type FrameworkConfig, -} from './framework-config'; -import { type WizardSession, OutroKind } from './wizard-session'; -import { - tryGetPackageJson, - isUsingTypeScript, - getOrAskForProjectData, -} from '../utils/setup-utils'; -import type { PackageDotJson } from '../utils/package-json'; -import type { WizardOptions } from '../utils/types'; -import { WIZARD_INTERACTION_EVENT_NAME } from './constants'; -import { analytics } from '../utils/analytics'; -import { getUI } from '../ui'; -import { - initializeAgent, - runAgent, - AgentSignals, - AgentErrorType, - buildWizardMetadata, - checkAllSettingsConflicts, - backupAndFixClaudeSettings, - restoreClaudeSettings, -} from './agent-interface'; -import { getCloudUrlFromRegion } from '../utils/urls'; - -import * as semver from 'semver'; -import { - evaluateWizardReadiness, - WizardReadiness, -} from './health-checks/readiness'; -import { enableDebugLogs, initLogFile, logToFile } from '../utils/debug'; -import { createBenchmarkPipeline } from './middleware/benchmark'; -import { - wizardAbort, - WizardError, - registerCleanup, -} from '../utils/wizard-abort'; -import { formatScanReport, writeScanReport } from './yara-hooks'; -import { - detectCloudflareTarget, - fetchCloudflareReference, -} from './cloudflare-detection'; - -/** - * Build a WizardOptions bag from a WizardSession (for code that still expects WizardOptions). - */ -function sessionToOptions(session: WizardSession): WizardOptions { - return { - installDir: session.installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - projectId: session.projectId, - apiKey: session.apiKey, - yaraReport: session.yaraReport, - }; -} - -/** - * Universal agent-powered wizard runner. - * Handles the complete flow for any framework using PostHog MCP integration. - * - * All user decisions come from the session — no UI prompts. - */ -export async function runAgentWizard( - config: FrameworkConfig, - session: WizardSession, -): Promise { - initLogFile(); - logToFile(`[agent-runner] START integration=${config.metadata.integration}`); - - if (session.debug) { - enableDebugLogs(); - } - - // Version check - if (config.detection.minimumVersion && config.detection.getInstalledVersion) { - logToFile('[agent-runner] checking version'); - const version = await config.detection.getInstalledVersion( - sessionToOptions(session), - ); - if (version) { - logToFile( - `[agent-runner] version=${version} minimum=${config.detection.minimumVersion}`, - ); - const coerced = semver.coerce(version); - if (coerced && semver.lt(coerced, config.detection.minimumVersion)) { - const docsUrl = - config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl; - await wizardAbort({ - message: - `Sorry: the wizard can't help you with ${config.metadata.name} ${version}. ` + - `Upgrade to ${config.metadata.name} ${config.detection.minimumVersion} or later, ` + - `or check out the manual setup guide.\n\n` + - `Setup ${config.metadata.name} manually: ${docsUrl}`, - }); - } - } - } - - // Compute skills server URL (needed for agent tool calls) - const skillsBaseUrl = session.localMcp - ? 'http://localhost:8765' - : 'https://github.com/PostHog/context-mill/releases/latest/download'; - - // Check all external service health (skip if TUI already ran it in bin.ts) - if (!session.readinessResult) { - logToFile('[agent-runner] evaluating wizard readiness'); - const readiness = await evaluateWizardReadiness(); - logToFile(`[agent-runner] readiness=${readiness.decision}`); - if (readiness.decision === WizardReadiness.No) { - await getUI().showBlockingOutage(readiness); - } else if (readiness.decision === WizardReadiness.YesWithWarnings) { - getUI().setReadinessWarnings(readiness); - } - } - - // Check ALL settings sources for blocking overrides before login. - const settingsConflicts = checkAllSettingsConflicts(session.installDir); - logToFile( - `[agent-runner] settings conflicts: ${ - settingsConflicts.length > 0 - ? settingsConflicts - .map((c) => `${c.source}(${c.keys.join(',')})`) - .join('; ') - : 'none' - }`, - ); - - if (settingsConflicts.length > 0) { - // Capture analytics for each conflict variation - for (const conflict of settingsConflicts) { - const level = conflict.source === 'managed' ? 'org' : conflict.source; - analytics.wizardCapture('settings conflict detected', { - level, - keys: conflict.keys, - }); - } - - await getUI().showSettingsOverride(settingsConflicts, () => - backupAndFixClaudeSettings(session.installDir), - ); - logToFile('[agent-runner] settings override resolved'); - } - - const typeScriptDetected = isUsingTypeScript({ - installDir: session.installDir, - }); - session.typescript = typeScriptDetected; - - // Framework detection and version - const usesPackageJson = config.detection.usesPackageJson !== false; - let packageJson: PackageDotJson | null = null; - let frameworkVersion: string | undefined; - - if (usesPackageJson) { - packageJson = await tryGetPackageJson({ installDir: session.installDir }); - if (packageJson) { - const { hasPackageInstalled } = await import('../utils/package-json.js'); - if (!hasPackageInstalled(config.detection.packageName, packageJson)) { - getUI().log.warn( - `${config.detection.packageDisplayName} does not seem to be installed. Continuing anyway — the agent will handle it.`, - ); - } - frameworkVersion = config.detection.getVersion(packageJson); - } else { - getUI().log.warn( - 'Could not find package.json. Continuing anyway — the agent will handle it.', - ); - } - } else { - frameworkVersion = config.detection.getVersion(null); - } - - // Set analytics tags for framework version - if (frameworkVersion && config.detection.getVersionBucket) { - const versionBucket = config.detection.getVersionBucket(frameworkVersion); - analytics.setTag(`${config.metadata.integration}-version`, versionBucket); - } - - analytics.wizardCapture('agent started', { - integration: config.metadata.integration, - }); - - // Get PostHog credentials (region auto-detected from token) - logToFile('[agent-runner] starting OAuth'); - const { projectApiKey, host, accessToken, projectId, cloudRegion } = - await getOrAskForProjectData({ - signup: session.signup, - ci: session.ci, - apiKey: session.apiKey, - email: session.email, - region: session.region, - projectId: session.projectId, - }); - - session.credentials = { accessToken, projectApiKey, host, projectId }; - - // Notify TUI that credentials are available (resolves past AuthScreen) - getUI().setCredentials(session.credentials); - - // Framework context was already gathered by SetupScreen + detection - const frameworkContext = session.frameworkContext; - - // Set analytics tags from framework context - const contextTags = config.analytics.getTags(frameworkContext); - Object.entries(contextTags).forEach(([key, value]) => { - analytics.setTag(key, value); - }); - - // Detect Cloudflare Workers target and fetch runtime reference if needed - const isCloudflare = await detectCloudflareTarget(session.installDir); - let cloudflareReference: string | null = null; - if (isCloudflare) { - logToFile('[agent-runner] Cloudflare Workers target detected'); - analytics.setTag('cloudflare', 'true'); - cloudflareReference = await fetchCloudflareReference(skillsBaseUrl); - } - - const integrationPrompt = buildIntegrationPrompt( - config, - { - frameworkVersion: frameworkVersion || 'latest', - typescript: typeScriptDetected, - projectApiKey, - host, - projectId, - }, - frameworkContext, - cloudflareReference, - ); - - // Initialize and run agent - const spinner = getUI().spinner(); - - // Evaluate all feature flags at the start of the run so they can be sent to the LLM gateway - const wizardFlags = await analytics.getAllFlagsForWizard(); - const wizardMetadata = buildWizardMetadata(wizardFlags); - - // Determine MCP URL: CLI flag > env var > production default - const mcpUrl = session.localMcp - ? 'http://localhost:8787/mcp' - : process.env.MCP_URL || - (cloudRegion === 'eu' - ? 'https://mcp-eu.posthog.com/mcp' - : 'https://mcp.posthog.com/mcp'); - - const restoreSettings = () => restoreClaudeSettings(session.installDir); - getUI().onEnterScreen('outro', restoreSettings); - - // Register YARA report as cleanup so it fires on any exit path (including wizardAbort) - if (session.yaraReport) { - registerCleanup(() => { - const reportPath = writeScanReport(); - if (reportPath) { - const summary = formatScanReport(); - getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); - } - }); - } - - getUI().startRun(); - - const agent = await initializeAgent( - { - workingDirectory: session.installDir, - posthogMcpUrl: mcpUrl, - posthogApiKey: accessToken, - posthogApiHost: host, - additionalMcpServers: config.metadata.additionalMcpServers, - detectPackageManager: config.detection.detectPackageManager, - skillsBaseUrl, - wizardFlags, - wizardMetadata, - }, - sessionToOptions(session), - ); - - const middleware = session.benchmark - ? createBenchmarkPipeline(spinner, sessionToOptions(session)) - : undefined; - - const agentResult = await runAgent( - agent, - integrationPrompt, - sessionToOptions(session), - spinner, - { - estimatedDurationMinutes: config.ui.estimatedDurationMinutes, - spinnerMessage: SPINNER_MESSAGE, - successMessage: config.ui.successMessage, - errorMessage: 'Integration failed', - additionalFeatureQueue: session.additionalFeatureQueue, - }, - middleware, - ); - - // Handle error cases detected in agent output - if (agentResult.error === AgentErrorType.MCP_MISSING) { - await wizardAbort({ - message: `Could not access the PostHog MCP server\n\nThe wizard was unable to connect to the PostHog MCP server.\nThis could be due to a network issue or a configuration problem.\n\nPlease try again, or set up ${config.metadata.name} manually by following our documentation:\n${config.metadata.docsUrl}`, - error: new WizardError('Agent could not access PostHog MCP server', { - integration: config.metadata.integration, - error_type: AgentErrorType.MCP_MISSING, - signal: AgentSignals.ERROR_MCP_MISSING, - }), - }); - } - - if (agentResult.error === AgentErrorType.RESOURCE_MISSING) { - await wizardAbort({ - message: `Could not access the setup resource\n\nThe wizard could not access the setup resource. This may indicate a version mismatch or a temporary service issue.\n\nPlease try again, or set up ${config.metadata.name} manually by following our documentation:\n${config.metadata.docsUrl}`, - error: new WizardError('Agent could not access setup resource', { - integration: config.metadata.integration, - error_type: AgentErrorType.RESOURCE_MISSING, - signal: AgentSignals.ERROR_RESOURCE_MISSING, - }), - }); - } - - if (agentResult.error === AgentErrorType.YARA_VIOLATION) { - await wizardAbort({ - message: - 'Security violation detected\n\nThe YARA scanner terminated the session after detecting a security violation.\nThis may indicate prompt injection, poisoned skill files, or a policy breach.\n\nPlease report this to: wizard@posthog.com', - error: new WizardError('YARA scanner terminated session', { - integration: config.metadata.integration, - error_type: AgentErrorType.YARA_VIOLATION, - }), - }); - } - - if ( - agentResult.error === AgentErrorType.RATE_LIMIT || - agentResult.error === AgentErrorType.API_ERROR - ) { - analytics.wizardCapture('agent api error', { - integration: config.metadata.integration, - error_type: agentResult.error, - error_message: agentResult.message, - }); - - await wizardAbort({ - message: `API Error\n\n${ - agentResult.message || 'Unknown error' - }\n\nPlease report this error to: wizard@posthog.com`, - error: new WizardError(`API error: ${agentResult.message}`, { - integration: config.metadata.integration, - error_type: agentResult.error, - }), - }); - } - - // Build environment variables from OAuth credentials - const envVars = config.environment.getEnvVars(projectApiKey, host); - - // Upload environment variables to hosting providers (auto-accept) - let uploadedEnvVars: string[] = []; - if (config.environment.uploadToHosting) { - const { uploadEnvironmentVariablesStep } = await import( - '../steps/index.js' - ); - uploadedEnvVars = await uploadEnvironmentVariablesStep(envVars, { - integration: config.metadata.integration, - session, - }); - if (uploadedEnvVars.length > 0) { - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'wizard_env_vars_uploaded', - integration: config.metadata.integration, - variable_count: uploadedEnvVars.length, - variable_keys: uploadedEnvVars, - }); - } - } - - // MCP installation is handled by McpScreen — no prompt here - - // Build outro data and store it for OutroScreen - const continueUrl = session.signup - ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` - : undefined; - - const changes = [ - ...config.ui.getOutroChanges(frameworkContext), - Object.keys(envVars).length > 0 - ? `Added environment variables to .env file` - : '', - uploadedEnvVars.length > 0 - ? `Uploaded environment variables to your hosting provider` - : '', - ].filter(Boolean); - - session.outroData = { - kind: OutroKind.Success, - changes, - docsUrl: config.metadata.docsUrl, - continueUrl, - }; - - getUI().outro(`Successfully installed PostHog!`); - - await analytics.shutdown('success'); -} - -/** - * Build the integration prompt for the agent. - */ -function buildIntegrationPrompt( - config: FrameworkConfig, - context: { - frameworkVersion: string; - typescript: boolean; - projectApiKey: string; - host: string; - projectId: number; - }, - frameworkContext: Record, - cloudflareReference?: string | null, -): string { - const additionalLines = config.prompts.getAdditionalContextLines - ? config.prompts.getAdditionalContextLines(frameworkContext) - : []; - - const additionalContext = - additionalLines.length > 0 - ? '\n' + additionalLines.map((line) => `- ${line}`).join('\n') - : ''; - - const runtimeOverrides = cloudflareReference - ? `\n\n---\n\n${cloudflareReference}` - : ''; - - return `You have access to the PostHog MCP server which provides skills to integrate PostHog into this ${ - config.metadata.name - } project. - -Project context: -- PostHog Project ID: ${context.projectId} -- Framework: ${config.metadata.name} ${context.frameworkVersion} -- TypeScript: ${context.typescript ? 'Yes' : 'No'} -- PostHog public token: ${context.projectApiKey} -- PostHog Host: ${context.host} -- Project type: ${config.prompts.projectTypeDetection} -- Package installation: ${ - config.prompts.packageInstallation ?? DEFAULT_PACKAGE_INSTALLATION - }${additionalContext} - -Instructions (follow these steps IN ORDER - do not skip or reorder): - -STEP 1: Call load_skill_menu (from the wizard-tools MCP server) to see available skills. - If the tool fails, emit: ${ - AgentSignals.ERROR_MCP_MISSING - } Could not load skill menu and halt. - - Choose a skill from the \`integration\` category that matches this project's framework. Do NOT pick skills from other categories (llm-analytics, error-tracking, feature-flags, omnibus, etc.) — those are handled separately. - If no suitable integration skill is found, emit: ${ - AgentSignals.ERROR_RESOURCE_MISSING - } Could not find a suitable skill for this project. - -STEP 2: Call install_skill (from the wizard-tools MCP server) with the chosen skill ID (e.g., "integration-nextjs-app-router"). - Do NOT run any shell commands to install skills. - -STEP 3: Load the installed skill's SKILL.md file to understand what references are available. - -STEP 4: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. Never directly write PostHog tokens directly to code files; always use environment variables. - -STEP 5: Set up environment variables for PostHog using the wizard-tools MCP server (this runs locally — secret values never leave the machine): - - Use check_env_keys to see which keys already exist in the project's .env file (e.g. .env.local or .env). - - Use set_env_values to create or update the PostHog public token and host, using the appropriate environment variable naming convention for ${ - config.metadata.name - }, which you'll find in example code. The tool will also ensure .gitignore coverage. Don't assume the presence of keys means the value is up to date. Write the correct value each time. - - Reference these environment variables in the code files you create instead of hardcoding the public token and host. - -Important: Use the detect_package_manager tool (from the wizard-tools MCP server) to determine which package manager the project uses. Do not manually search for lockfiles or config files. Always install packages as a background task. Don't await completion; proceed with other work immediately after starting the installation. You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. -${runtimeOverrides} - -`; -} diff --git a/src/lib/agent/__tests__/agent-prompt.test.ts b/src/lib/agent/__tests__/agent-prompt.test.ts new file mode 100644 index 00000000..e13be7d0 --- /dev/null +++ b/src/lib/agent/__tests__/agent-prompt.test.ts @@ -0,0 +1,66 @@ +import { assemblePrompt, type PromptContext } from '../agent-prompt.js'; +import type { WorkflowRun } from '../agent-runner.js'; + +function makeRunDef(overrides: Partial = {}): WorkflowRun { + return { + integrationLabel: 'test', + spinnerMessage: 'Working...', + successMessage: 'Done!', + estimatedDurationMinutes: 5, + reportFile: 'test-report.md', + docsUrl: 'https://example.com/docs', + ...overrides, + }; +} + +const baseCtx: PromptContext = { + projectId: 42, + projectApiKey: 'phc_test123', + host: 'https://app.posthog.com', +}; + +describe('assemblePrompt', () => { + it('always includes project credentials in the default section', () => { + const prompt = assemblePrompt(makeRunDef(), baseCtx); + + expect(prompt).toContain('42'); + expect(prompt).toContain('phc_test123'); + expect(prompt).toContain('https://app.posthog.com'); + }); + + it('composes three sections in order: default → custom → skill', () => { + const customFn = jest.fn(() => 'CUSTOM_INSTRUCTIONS'); + const runDef = makeRunDef({ customPrompt: customFn }); + const ctx: PromptContext = { ...baseCtx, skillPath: '/skills/test' }; + + const prompt = assemblePrompt(runDef, ctx); + + // All three sections present + expect(prompt).toContain('PostHog MCP server'); + expect(prompt).toContain('CUSTOM_INSTRUCTIONS'); + expect(prompt).toContain('SKILL.md'); + + // Ordering is enforced + const order = [ + prompt.indexOf('PostHog MCP server'), + prompt.indexOf('CUSTOM_INSTRUCTIONS'), + prompt.indexOf('SKILL.md'), + ]; + expect(order).toEqual([...order].sort((a, b) => a - b)); + + // customPrompt receives the context + expect(customFn).toHaveBeenCalledWith(ctx); + }); + + it('omits custom and skill sections when not configured', () => { + const prompt = assemblePrompt(makeRunDef(), baseCtx); + + expect(prompt).not.toContain('SKILL.md'); + // Only the default section — should be shorter than a full 3-section prompt + const full = assemblePrompt(makeRunDef({ customPrompt: () => 'X' }), { + ...baseCtx, + skillPath: '/s', + }); + expect(prompt.length).toBeLessThan(full.length); + }); +}); diff --git a/src/lib/agent-interface.ts b/src/lib/agent/agent-interface.ts similarity index 92% rename from src/lib/agent-interface.ts rename to src/lib/agent/agent-interface.ts index 1dca0cce..127d8b52 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -5,36 +5,41 @@ import path from 'path'; import * as fs from 'fs'; -import { getUI, type SpinnerHandle } from '../ui'; -import { debug, logToFile, initLogFile, getLogFilePath } from '../utils/debug'; -import type { WizardOptions } from '../utils/types'; -import { analytics } from '../utils/analytics'; +import { getUI, type SpinnerHandle } from '../../ui'; +import { + debug, + logToFile, + initLogFile, + getLogFilePath, +} from '../../utils/debug'; +import type { WizardOptions } from '../../utils/types'; +import { analytics } from '../../utils/analytics'; import { WIZARD_REMARK_EVENT_NAME, POSTHOG_PROPERTY_HEADER_PREFIX, WIZARD_VARIANT_FLAG_KEY, WIZARD_VARIANTS, WIZARD_USER_AGENT, -} from './constants'; +} from '../constants'; import { type AdditionalFeature, ADDITIONAL_FEATURE_PROMPTS, -} from './wizard-session'; +} from '../wizard-session'; import { registerCleanup, wizardAbort, WizardError, -} from '../utils/wizard-abort'; -import { createCustomHeaders } from '../utils/custom-headers'; -import { getLlmGatewayUrlFromHost } from '../utils/urls'; -import { LINTING_TOOLS } from './safe-tools'; -import { createWizardToolsServer, WIZARD_TOOL_NAMES } from './wizard-tools'; +} from '../../utils/wizard-abort'; +import { createCustomHeaders } from '../../utils/custom-headers'; +import { getLlmGatewayUrlFromHost } from '../../utils/urls'; +import { LINTING_TOOLS } from '../safe-tools'; +import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '../wizard-tools'; import { createPreToolUseYaraHooks, createPostToolUseYaraHooks, -} from './yara-hooks'; +} from '../yara-hooks'; import { getWizardCommandments } from './commandments'; -import type { PackageManagerDetector } from './package-manager-detection'; +import type { PackageManagerDetector } from '../detection/package-manager'; // Dynamic import cache for ESM module let _sdkModule: any = null; @@ -59,6 +64,7 @@ function getClaudeCodeExecutablePath(): string { // syntax which prettier cannot parse. See PR discussion for details. type SDKMessage = any; type McpServersConfig = any; +type AbortCaseMatcher = { match: RegExp }; export const AgentSignals = { /** Signal emitted when the agent reports progress to the user */ @@ -67,6 +73,12 @@ export const AgentSignals = { ERROR_MCP_MISSING: '[ERROR-MCP-MISSING]', /** Signal emitted when the agent cannot access the setup resource */ ERROR_RESOURCE_MISSING: '[ERROR-RESOURCE-MISSING]', + /** + * Signal emitted when the agent cannot complete the workflow and is + * aborting intentionally (distinct from errors). Format: "[ABORT] ". + * Workflows can declare an onAbort handler to render a custom screen. + */ + ABORT: '[ABORT]', /** Signal emitted when the agent provides a remark about its run */ WIZARD_REMARK: '[WIZARD-REMARK]', /** Signal prefix for benchmark logging */ @@ -90,6 +102,8 @@ export enum AgentErrorType { API_ERROR = 'WIZARD_API_ERROR', /** YARA scanner detected a security violation */ YARA_VIOLATION = 'WIZARD_YARA_VIOLATION', + /** Agent intentionally aborted the workflow (emitted [ABORT] ) */ + ABORT = 'WIZARD_ABORT', } const BLOCKING_ENV_KEYS = [ @@ -438,7 +452,7 @@ const SAFE_SCRIPTS = [ const DANGEROUS_OPERATORS = /[;`$()]/; // Re-export for backwards compatibility — canonical source is skill-install.ts -export { isSkillInstallCommand } from './skill-install'; +export { isSkillInstallCommand } from '../skill-install'; /** * Check if command is an allowed package manager command. @@ -719,6 +733,7 @@ export async function runAgent( successMessage?: string; errorMessage?: string; additionalFeatureQueue?: readonly AdditionalFeature[]; + abortCases?: readonly AbortCaseMatcher[]; }, middleware?: { onMessage(message: any): void; @@ -729,6 +744,7 @@ export async function runAgent( spinnerMessage = 'Customizing your PostHog setup...', successMessage = 'PostHog integration complete', errorMessage = 'Integration failed', + abortCases = [], } = config ?? {}; const { query } = await getSDKModule(); @@ -817,6 +833,12 @@ export async function runAgent( let eventPlanWatcher: fs.FSWatcher | undefined; let eventPlanInterval: ReturnType | undefined; + // Abort controller — lets us force-kill the SDK query when we detect an + // [ABORT] signal in the agent's output. Also stashes the reason so the + // runner can surface it via outroData after we unwind. + const abortController = new AbortController(); + let abortReason: string | null = null; + try { // Tools needed for the wizard: // - File operations: Read, Write, Edit @@ -840,6 +862,7 @@ export async function runAgent( const response = query({ prompt: createPromptStream(), options: { + abortController, model: agentConfig.model, cwd: agentConfig.workingDirectory, permissionMode: 'acceptEdits', @@ -1017,6 +1040,33 @@ export async function runAgent( receivedSuccessResult, ); + // [ABORT] detection: the skill emits "[ABORT] " when it + // cannot complete the workflow. Kill the SDK query immediately — + // the prompt doesn't need to cooperate with "and exit" because the + // abort is enforced here. The reason is surfaced via the returned + // AgentErrorType.ABORT so the runner can render a custom screen. + if ( + abortCases.length > 0 && + !abortReason && + message.type === 'assistant' + ) { + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') { + const match = block.text.match(/\[ABORT\]\s*(.+?)(?:\n|$)/); + if (match) { + abortReason = match[1].trim(); + logToFile(`Agent emitted [ABORT]: ${abortReason}`); + abortController.abort(); + signalDone!(); + break; + } + } + } + } + } + // 401: show auth error screen and exit immediately if ( message.type === 'assistant' && @@ -1050,6 +1100,13 @@ export async function runAgent( } } + // If the middleware caught an [ABORT] and aborted the SDK query, surface + // it as a structured error before checking other signals. + if (abortReason) { + spinner.stop('Wizard aborted'); + return { error: AgentErrorType.ABORT, message: abortReason }; + } + const outputText = collectedText.join('\n'); // Check for YARA scanner violations @@ -1093,6 +1150,13 @@ export async function runAgent( // Signal done to unblock the async generator signalDone!(); + // If the middleware caught an [ABORT] and triggered abortController.abort(), + // the SDK will throw an AbortError — surface it as a clean abort result. + if (abortReason) { + spinner.stop('Wizard aborted'); + return { error: AgentErrorType.ABORT, message: abortReason }; + } + // If we already received a successful result, the error is from SDK cleanup // This happens due to a race condition: the SDK tries to send a cleanup command // after the prompt stream closes, but streaming mode is still active. diff --git a/src/lib/agent/agent-prompt.ts b/src/lib/agent/agent-prompt.ts new file mode 100644 index 00000000..ac591cf7 --- /dev/null +++ b/src/lib/agent/agent-prompt.ts @@ -0,0 +1,66 @@ +/** + * Agent prompt assembly. + * + * Three sections, always in this order: + * 1. Default project prompt — credentials and base context (always included) + * 2. Custom prompt — additional workflow-specific instructions (if set) + * 3. Skill prompt — "follow SKILL.md" instructions (if a skill was installed) + */ + +import type { WorkflowRun } from './agent-runner.js'; + +/** + * Values available to prompt builders after OAuth completes. + */ +export interface PromptContext { + projectId: number; + projectApiKey: string; + host: string; + /** Set when skillId was provided and the skill was installed successfully. */ + skillPath?: string; +} + +function defaultProjectPrompt(ctx: PromptContext): string { + return `You have access to the PostHog MCP server. + +Project context: +- PostHog Project ID: ${ctx.projectId} +- PostHog public token: ${ctx.projectApiKey} +- PostHog Host: ${ctx.host}`; +} + +function skillPrompt(skillPath: string, reportFile: string): string { + return `A PostHog skill has been installed at ${skillPath}/. Read ${skillPath}/SKILL.md and follow its instructions completely. + +After completing the skill workflow, write a brief markdown report to ./${reportFile} summarizing: +- What changes were made to the project +- Which files were modified or created +- Any manual steps the user should take next + +Important: You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure.`; +} + +/** + * Assemble the final agent prompt from the workflow's run config. + */ +export function assemblePrompt( + runDef: WorkflowRun, + ctx: PromptContext, +): string { + const parts: string[] = []; + + // Always include the default project prompt + parts.push(defaultProjectPrompt(ctx)); + + // Additional workflow-specific instructions + if (runDef.customPrompt) { + parts.push(runDef.customPrompt(ctx)); + } + + // Skill prompt (appended when a skill was pre-installed) + if (ctx.skillPath) { + parts.push(skillPrompt(ctx.skillPath, runDef.reportFile)); + } + + return parts.join('\n\n'); +} diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts new file mode 100644 index 00000000..21420db9 --- /dev/null +++ b/src/lib/agent/agent-runner.ts @@ -0,0 +1,481 @@ +/** + * Unified workflow runner. + * + * Single configurable pipeline for all workflows. Each workflow + * provides a WorkflowRun (via the `run` field on WorkflowConfig) + * that controls: + * - Whether a skill is pre-installed or discovered at runtime + * - How the agent prompt is built + * - What MCP servers and package manager detector to use + * - What happens after the agent completes + * + * The pipeline itself is fixed: + * init → health check → settings → OAuth → [skill install] → + * agent init → prompt → run → errors → [postRun] → outro + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { + type WizardSession, + type AdditionalFeature, + type Credentials, + OutroKind, +} from '../wizard-session'; +import { getOrAskForProjectData } from '../../utils/setup-utils'; +import { analytics } from '../../utils/analytics'; +import { getUI } from '../../ui'; +import { + initializeAgent, + runAgent as executeAgent, + AgentErrorType, + AgentSignals, + buildWizardMetadata, + checkAllSettingsConflicts, + backupAndFixClaudeSettings, + restoreClaudeSettings, +} from './agent-interface'; +import { getCloudUrlFromRegion } from '../../utils/urls'; +import { + evaluateWizardReadiness, + WizardReadiness, +} from '../health-checks/readiness'; +import { enableDebugLogs, initLogFile, logToFile } from '../../utils/debug'; +import { createBenchmarkPipeline } from '../middleware/benchmark'; +import { + wizardAbort, + WizardError, + registerCleanup, +} from '../../utils/wizard-abort'; +import { formatScanReport, writeScanReport } from '../yara-hooks'; +import { detectNodePackageManagers } from '../detection/package-manager'; +import type { PackageManagerDetector } from '../detection/package-manager'; +import { getSkillsBaseUrl } from '../constants'; +import { installSkillById, type InstallSkillResult } from '../wizard-tools'; +import type { WizardOptions } from '../../utils/types'; + +import type { WorkflowConfig } from '../workflows/workflow-step'; +import { assemblePrompt, type PromptContext } from './agent-prompt'; + +export type { PromptContext }; + +// ── Types ──────────────────────────────────────────────────────────── + +export type { Credentials }; + +/** + * A known `[ABORT] ` case. First matching entry is rendered on + * the error outro; unmatched aborts use a generic fallback. + */ +export interface AbortCase { + match: RegExp; + message: string; + body: string; + docsUrl?: string; +} + +/** + * Unified agent run configuration. + * + * Every workflow provides one of these — either as a static object + * or via a function that builds one from the session. The runner + * assembles the final prompt from `prompt` + `skillId`. + */ +export interface WorkflowRun { + /** Analytics label (e.g. 'revenue-analytics-setup', 'nextjs') */ + integrationLabel: string; + /** Skill ID to pre-install. Omit for agent-driven skill discovery. */ + skillId?: string; + /** Additional workflow-specific prompt instructions. Appended after the default project prompt. */ + customPrompt?: (ctx: PromptContext) => string; + /** Additional MCP servers (e.g. Svelte MCP) */ + additionalMcpServers?: Record; + /** Package manager detector. Defaults to detectNodePackageManagers. */ + detectPackageManager?: PackageManagerDetector; + spinnerMessage: string; + successMessage: string; + estimatedDurationMinutes: number; + reportFile: string; + docsUrl: string; + errorMessage?: string; + additionalFeatureQueue?: readonly AdditionalFeature[]; + /** Known `[ABORT] ` cases this workflow can render. */ + abortCases?: AbortCase[]; + /** Runs after agent completes, before outro (e.g. env var upload). */ + postRun?: (session: WizardSession, credentials: Credentials) => Promise; + /** Custom outro data. Omit for default built from successMessage/reportFile/docsUrl. */ + buildOutroData?: ( + session: WizardSession, + credentials: Credentials, + cloudRegion: import('../../utils/types').CloudRegion | undefined, + ) => WizardSession['outroData']; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function sessionToOptions(session: WizardSession): WizardOptions { + return { + installDir: session.installDir, + debug: session.debug, + forceInstall: session.forceInstall, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + menu: session.menu, + benchmark: session.benchmark, + projectId: session.projectId, + apiKey: session.apiKey, + yaraReport: session.yaraReport, + }; +} + +// ── Runner ─────────────────────────────────────────────────────────── + +/** + * Resolve a WorkflowConfig's agent run definition and execute the pipeline. + * Entry point for bin.ts — handles buildRunConfig, bootstrap, and (future) run field. + */ +export async function runAgent( + workflowConfig: WorkflowConfig, + session: WizardSession, +): Promise { + if (!workflowConfig.run) { + throw new Error( + `Workflow "${workflowConfig.flowKey}" has no run configuration.`, + ); + } + + const runDef = + typeof workflowConfig.run === 'function' + ? await workflowConfig.run(session) + : workflowConfig.run; + + await runWorkflow(session, runDef); +} + +/** + * Run a workflow's agent pipeline. + * + * This is the single execution path for all workflows — both skill-based + * (revenue analytics) and framework-based (core integration). The + * `WorkflowRun` controls what varies between them. + */ +export async function runWorkflow( + session: WizardSession, + config: WorkflowRun, +): Promise { + // 1. Init logging + debug + initLogFile(); + logToFile(`[agent-runner] START ${config.integrationLabel}`); + + if (session.debug) { + enableDebugLogs(); + } + + const skillsBaseUrl = getSkillsBaseUrl(session.localMcp); + + // 2. Health check (guarded — skip if TUI already ran it) + if (!session.readinessResult) { + logToFile('[agent-runner] evaluating wizard readiness'); + const readiness = await evaluateWizardReadiness(); + logToFile(`[agent-runner] readiness=${readiness.decision}`); + if (readiness.decision === WizardReadiness.No) { + await getUI().showBlockingOutage(readiness); + } else if (readiness.decision === WizardReadiness.YesWithWarnings) { + getUI().setReadinessWarnings(readiness); + } + } + + // 3. Settings conflicts + const settingsConflicts = checkAllSettingsConflicts(session.installDir); + logToFile( + `[agent-runner] settings conflicts: ${ + settingsConflicts.length > 0 + ? settingsConflicts + .map((c) => `${c.source}(${c.keys.join(',')})`) + .join('; ') + : 'none' + }`, + ); + + if (settingsConflicts.length > 0) { + for (const conflict of settingsConflicts) { + const level = conflict.source === 'managed' ? 'org' : conflict.source; + analytics.wizardCapture('settings conflict detected', { + level, + keys: conflict.keys, + }); + } + await getUI().showSettingsOverride(settingsConflicts, () => + backupAndFixClaudeSettings(session.installDir), + ); + logToFile('[agent-runner] settings override resolved'); + } + + analytics.wizardCapture('agent started', { + integration: config.integrationLabel, + }); + + // 4. OAuth + logToFile('[agent-runner] starting OAuth'); + const { projectApiKey, host, accessToken, projectId, cloudRegion } = + await getOrAskForProjectData({ + signup: session.signup, + ci: session.ci, + apiKey: session.apiKey, + projectId: session.projectId, + }); + + session.credentials = { accessToken, projectApiKey, host, projectId }; + getUI().setCredentials(session.credentials); + + // 5. Skill install (if skillId provided) + let skillPath: string | undefined; + if (config.skillId) { + logToFile(`[agent-runner] installing skill ${config.skillId}`); + const installResult = await installSkillById( + config.skillId, + session.installDir, + skillsBaseUrl, + ); + if (installResult.kind !== 'ok') { + await abortOnInstallFailure(config.integrationLabel, installResult); + return; + } + skillPath = installResult.path; + logToFile(`[agent-runner] skill installed at ${skillPath}`); + } + + // 6. Initialize agent + const spinner = getUI().spinner(); + const wizardFlags = await analytics.getAllFlagsForWizard(); + const wizardMetadata = buildWizardMetadata(wizardFlags); + + const mcpUrl = session.localMcp + ? 'http://localhost:8787/mcp' + : process.env.MCP_URL || + (cloudRegion === 'eu' + ? 'https://mcp-eu.posthog.com/mcp' + : 'https://mcp.posthog.com/mcp'); + + const restoreSettings = () => restoreClaudeSettings(session.installDir); + getUI().onEnterScreen('outro', restoreSettings); + + if (session.yaraReport) { + registerCleanup(() => { + const reportPath = writeScanReport(); + if (reportPath) { + const summary = formatScanReport(); + getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); + } + }); + } + + getUI().startRun(); + + const agent = await initializeAgent( + { + workingDirectory: session.installDir, + posthogMcpUrl: mcpUrl, + posthogApiKey: accessToken, + posthogApiHost: host, + additionalMcpServers: config.additionalMcpServers, + detectPackageManager: + config.detectPackageManager ?? detectNodePackageManagers, + skillsBaseUrl, + wizardFlags, + wizardMetadata, + }, + sessionToOptions(session), + ); + + const middleware = session.benchmark + ? createBenchmarkPipeline(spinner, sessionToOptions(session)) + : undefined; + + // 7. Build prompt + const prompt = assemblePrompt(config, { + projectId, + projectApiKey, + host, + skillPath, + }); + + // 8. Run agent + const agentResult = await executeAgent( + agent, + prompt, + sessionToOptions(session), + spinner, + { + estimatedDurationMinutes: config.estimatedDurationMinutes, + spinnerMessage: config.spinnerMessage, + successMessage: config.successMessage, + errorMessage: config.errorMessage ?? `${config.integrationLabel} failed`, + additionalFeatureQueue: config.additionalFeatureQueue ?? [], + abortCases: config.abortCases, + }, + middleware, + ); + + // 9. Error handling (full set from both runners) + if (agentResult.error === AgentErrorType.ABORT) { + const reason = agentResult.message ?? ''; + const matched = config.abortCases?.find((c) => c.match.test(reason)); + const outroData: WizardSession['outroData'] = matched + ? { + kind: OutroKind.Error, + message: matched.message, + body: matched.body, + docsUrl: matched.docsUrl, + } + : { + kind: OutroKind.Error, + message: `${config.integrationLabel} aborted`, + body: reason || 'The agent aborted the workflow.', + docsUrl: config.docsUrl, + }; + analytics.wizardCapture('agent aborted', { + integration: config.integrationLabel, + reason, + matched: matched?.message ?? null, + }); + await wizardAbort({ + outroData, + error: new WizardError(`Agent aborted: ${reason}`, { + integration: config.integrationLabel, + error_type: AgentErrorType.ABORT, + reason, + }), + }); + } + + if (agentResult.error === AgentErrorType.MCP_MISSING) { + await wizardAbort({ + message: + 'Could not access the PostHog MCP server\n\n' + + 'The wizard was unable to connect to the PostHog MCP server.\n' + + 'This could be due to a network issue or a configuration problem.\n\n' + + `Please try again, or check the documentation:\n${config.docsUrl}`, + error: new WizardError('Agent could not access PostHog MCP server', { + integration: config.integrationLabel, + error_type: AgentErrorType.MCP_MISSING, + signal: AgentSignals.ERROR_MCP_MISSING, + }), + }); + } + + if (agentResult.error === AgentErrorType.RESOURCE_MISSING) { + await wizardAbort({ + message: + 'Could not access the setup resource\n\n' + + 'This may indicate a version mismatch or a temporary service issue.\n\n' + + `Please try again, or check the documentation:\n${config.docsUrl}`, + error: new WizardError('Agent could not access setup resource', { + integration: config.integrationLabel, + error_type: AgentErrorType.RESOURCE_MISSING, + signal: AgentSignals.ERROR_RESOURCE_MISSING, + }), + }); + } + + if (agentResult.error === AgentErrorType.YARA_VIOLATION) { + await wizardAbort({ + message: + 'Security violation detected.\nPlease report this to: wizard@posthog.com', + error: new WizardError('YARA scanner terminated session', { + integration: config.integrationLabel, + error_type: AgentErrorType.YARA_VIOLATION, + }), + }); + } + + if ( + agentResult.error === AgentErrorType.RATE_LIMIT || + agentResult.error === AgentErrorType.API_ERROR + ) { + analytics.wizardCapture('agent api error', { + integration: config.integrationLabel, + error_type: agentResult.error, + error_message: agentResult.message, + }); + + await wizardAbort({ + message: `API Error\n\n${ + agentResult.message || 'Unknown error' + }\n\nPlease report this to: wizard@posthog.com`, + error: new WizardError(`API error: ${agentResult.message}`, { + integration: config.integrationLabel, + error_type: agentResult.error, + }), + }); + } + + // 10. Post-run hooks + if (config.postRun) { + await config.postRun(session, { + accessToken, + projectApiKey, + host, + projectId, + }); + } + + // 11. Outro + if (config.buildOutroData) { + session.outroData = config.buildOutroData( + session, + { accessToken, projectApiKey, host, projectId }, + cloudRegion, + ); + } else { + const continueUrl = session.signup + ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` + : undefined; + + const reportPath = join(session.installDir, config.reportFile); + const reportExists = existsSync(reportPath); + + session.outroData = { + kind: OutroKind.Success, + message: config.successMessage, + reportFile: reportExists ? config.reportFile : undefined, + docsUrl: config.docsUrl, + continueUrl, + }; + } + + getUI().outro(config.successMessage); + + // 12. Analytics shutdown + await analytics.shutdown('success'); +} + +// ── Shared error helpers ───────────────────────────────────────────── + +async function abortOnInstallFailure( + integrationLabel: string, + result: InstallSkillResult, +): Promise { + if (result.kind === 'ok') return; + + const message = (() => { + switch (result.kind) { + case 'menu-fetch-failed': + return 'Could not fetch the skill menu from context-mill.\nCheck your network connection and try again.'; + case 'skill-not-found': + return `Could not find the "${result.skillId}" skill in the context-mill menu.\nPlease try again later.`; + case 'download-failed': + return `Failed to install skill: ${result.message}\nPlease try again.`; + } + })(); + + await wizardAbort({ + message, + error: new WizardError(`Skill install failed: ${result.kind}`, { + integration: integrationLabel, + error_type: result.kind, + }), + }); +} diff --git a/src/lib/commandments.ts b/src/lib/agent/commandments.ts similarity index 100% rename from src/lib/commandments.ts rename to src/lib/agent/commandments.ts diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c20ad305..c09b2e40 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -59,6 +59,20 @@ export const DEFAULT_HOST_URL = IS_DEV export const ISSUES_URL = 'https://github.com/posthog/wizard/issues'; export const CONTEXT_MILL_URL = 'https://github.com/PostHog/context-mill'; +/** Remote base URL for fetching the skill menu + downloading skills. */ +export const REMOTE_SKILLS_BASE_URL = + 'https://github.com/PostHog/context-mill/releases/latest/download'; +/** Local base URL when `--local-mcp` is set (served by context-mill dev server). */ +export const LOCAL_SKILLS_BASE_URL = 'http://localhost:8765'; + +/** + * Pick the skills base URL based on the session's localMcp flag. + * Single source of truth — do not inline this ternary anywhere. + */ +export function getSkillsBaseUrl(localMcp: boolean): string { + return localMcp ? LOCAL_SKILLS_BASE_URL : REMOTE_SKILLS_BASE_URL; +} + // ── Analytics (internal) ────────────────────────────────────────────── export const ANALYTICS_POSTHOG_PUBLIC_PROJECT_WRITE_KEY = 'sTMFPsFhdP1Ssg'; diff --git a/src/lib/detection/__tests__/context.test.ts b/src/lib/detection/__tests__/context.test.ts new file mode 100644 index 00000000..a7865323 --- /dev/null +++ b/src/lib/detection/__tests__/context.test.ts @@ -0,0 +1,84 @@ +import { gatherFrameworkContext, checkFrameworkVersion } from '../context.js'; +import type { FrameworkConfig } from '../../framework-config.js'; +import type { WizardOptions } from '../../../utils/types.js'; + +const baseOptions: WizardOptions = { + installDir: '/test/dir', + debug: false, + forceInstall: false, + default: false, + signup: false, + localMcp: false, + ci: false, + menu: false, + benchmark: false, + yaraReport: false, +}; + +describe('gatherFrameworkContext', () => { + it('calls gatherContext and returns the result', async () => { + const config = { + metadata: { + gatherContext: jest + .fn() + .mockResolvedValue({ routerType: 'app', srcDir: 'src' }), + }, + } as unknown as FrameworkConfig; + + const result = await gatherFrameworkContext(config, baseOptions); + + expect(result).toEqual({ routerType: 'app', srcDir: 'src' }); + expect(config.metadata.gatherContext).toHaveBeenCalledWith(baseOptions); + }); + + it('returns {} when gatherContext is missing or throws', async () => { + const noGather = { metadata: {} } as FrameworkConfig; + expect(await gatherFrameworkContext(noGather, baseOptions)).toEqual({}); + + const throws = { + metadata: { + gatherContext: jest.fn().mockRejectedValue(new Error('fail')), + }, + } as unknown as FrameworkConfig; + expect(await gatherFrameworkContext(throws, baseOptions)).toEqual({}); + }); +}); + +describe('checkFrameworkVersion', () => { + it('returns supported when no minimum is configured', async () => { + const config = { detection: {} } as unknown as FrameworkConfig; + const result = await checkFrameworkVersion(config, baseOptions); + expect(result.supported).toBe(true); + }); + + it('returns supported when installed version meets minimum', async () => { + const config = { + detection: { + minimumVersion: '14.0.0', + getInstalledVersion: jest.fn().mockResolvedValue('15.2.3'), + }, + metadata: { docsUrl: 'https://example.com/docs' }, + } as unknown as FrameworkConfig; + + expect((await checkFrameworkVersion(config, baseOptions)).supported).toBe( + true, + ); + }); + + it('returns version details when installed version is below minimum', async () => { + const config = { + detection: { + minimumVersion: '14.0.0', + getInstalledVersion: jest.fn().mockResolvedValue('13.5.0'), + }, + metadata: { docsUrl: 'https://example.com/docs' }, + } as unknown as FrameworkConfig; + + const result = await checkFrameworkVersion(config, baseOptions); + expect(result.supported).toEqual({ + current: '13.5.0', + minimum: '14.0.0', + docsUrl: 'https://example.com/docs', + }); + }); +}); diff --git a/src/lib/detection/__tests__/features.test.ts b/src/lib/detection/__tests__/features.test.ts new file mode 100644 index 00000000..e7981cad --- /dev/null +++ b/src/lib/detection/__tests__/features.test.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { discoverFeatures } from '../features.js'; +import { DiscoveredFeature } from '../../wizard-session.js'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'features-detect-')); +} + +function cleanup(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function writePackageJson( + dir: string, + deps: Record = {}, +): void { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: deps }), + ); +} + +describe('discoverFeatures', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => cleanup(tmpDir)); + + it('returns empty when no package.json exists', () => { + expect(discoverFeatures(tmpDir)).toEqual([]); + }); + + it('detects Stripe and LLM features from known packages', () => { + writePackageJson(tmpDir, { stripe: '13.0.0', openai: '4.0.0' }); + const features = discoverFeatures(tmpDir); + + expect(features).toContain(DiscoveredFeature.Stripe); + expect(features).toContain(DiscoveredFeature.LLM); + expect(features).toHaveLength(2); + }); + + it('returns empty for unrelated dependencies', () => { + writePackageJson(tmpDir, { react: '18.0.0', express: '4.0.0' }); + expect(discoverFeatures(tmpDir)).toEqual([]); + }); + + it('handles malformed package.json gracefully', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), 'not valid json'); + expect(discoverFeatures(tmpDir)).toEqual([]); + }); +}); diff --git a/src/lib/__tests__/package-manager-detection.test.ts b/src/lib/detection/__tests__/package-manager.test.ts similarity index 97% rename from src/lib/__tests__/package-manager-detection.test.ts rename to src/lib/detection/__tests__/package-manager.test.ts index bb607c16..0e97a680 100644 --- a/src/lib/__tests__/package-manager-detection.test.ts +++ b/src/lib/detection/__tests__/package-manager.test.ts @@ -7,13 +7,13 @@ import { composerPackageManager, swiftPackageManager, gradlePackageManager, -} from '../package-manager-detection'; +} from '../package-manager'; -jest.mock('../../utils/debug'); -jest.mock('../../telemetry', () => ({ +jest.mock('../../../utils/debug'); +jest.mock('../../../telemetry', () => ({ traceStep: (_name: string, fn: () => unknown) => fn(), })); -jest.mock('../../utils/analytics', () => ({ +jest.mock('../../../utils/analytics', () => ({ analytics: { setTag: jest.fn() }, })); diff --git a/src/lib/detection/context.ts b/src/lib/detection/context.ts new file mode 100644 index 00000000..e8a207a8 --- /dev/null +++ b/src/lib/detection/context.ts @@ -0,0 +1,82 @@ +/** + * Framework context gathering — run gatherContext and version checks + * for a detected framework. + * + * Pure functions: take a framework config and options, return results. + * No store mutations, no UI calls. + */ + +import * as semver from 'semver'; +import { DETECTION_TIMEOUT_MS } from '../constants.js'; +import type { FrameworkConfig } from '../framework-config.js'; +import type { WizardOptions } from '../../utils/types.js'; + +/** + * Run a framework's `gatherContext()` to collect variant-specific + * metadata (e.g., router type for Next.js, Expo vs bare for React Native). + * + * Returns the gathered context, or an empty object on failure/timeout. + */ +export async function gatherFrameworkContext( + config: FrameworkConfig, + options: WizardOptions, +): Promise> { + if (!config.metadata.gatherContext) return {}; + + try { + return await Promise.race([ + config.metadata.gatherContext(options), + new Promise>((resolve) => + setTimeout(() => resolve({}), DETECTION_TIMEOUT_MS), + ), + ]); + } catch { + return {}; + } +} + +export interface VersionCheckResult { + /** Whether the installed version is supported */ + supported: + | true + | { + current: string; + minimum: string; + docsUrl: string; + }; +} + +/** + * Check whether the installed framework version meets the minimum requirement. + * + * Returns `{ supported: true }` if the version is fine (or no check is needed). + * Returns the version details if unsupported. + */ +export async function checkFrameworkVersion( + config: FrameworkConfig, + options: WizardOptions, +): Promise { + if ( + !config.detection.minimumVersion || + !config.detection.getInstalledVersion + ) { + return { supported: true }; + } + + const version = await config.detection.getInstalledVersion(options); + if (!version) return { supported: true }; + + const coerced = semver.coerce(version); + if (coerced && semver.lt(coerced, config.detection.minimumVersion)) { + return { + supported: { + current: version, + minimum: config.detection.minimumVersion, + docsUrl: + config.metadata.unsupportedVersionDocsUrl ?? config.metadata.docsUrl, + }, + }; + } + + return { supported: true }; +} diff --git a/src/lib/detection/features.ts b/src/lib/detection/features.ts new file mode 100644 index 00000000..8d2b1c88 --- /dev/null +++ b/src/lib/detection/features.ts @@ -0,0 +1,64 @@ +/** + * Feature discovery — scan project dependencies for known SDK patterns + * that indicate additional PostHog workflows are relevant. + * + * Pure function: takes an install dir, returns a set of discovered features. + * No store mutations, no UI calls. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { DiscoveredFeature } from '../wizard-session.js'; + +const STRIPE_PACKAGES = ['stripe', '@stripe/stripe-js']; + +const LLM_PACKAGES = [ + 'openai', + '@anthropic-ai/sdk', + 'ai', + '@ai-sdk/openai', + 'langchain', + '@langchain/openai', + '@langchain/langgraph', + '@google/generative-ai', + '@google/genai', + '@instructor-ai/instructor', + '@mastra/core', + 'portkey-ai', +]; + +/** + * Scan `package.json` at `installDir` for dependencies that indicate + * additional PostHog features (Stripe revenue analytics, LLM observability, etc.) + * + * Returns an array of discovered features, or empty if nothing found + * or no package.json exists. + */ +export function discoverFeatures(installDir: string): DiscoveredFeature[] { + const features: DiscoveredFeature[] = []; + + try { + const pkg = JSON.parse( + readFileSync(join(installDir, 'package.json'), 'utf-8'), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + const depNames = Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + }); + + if (depNames.some((d) => STRIPE_PACKAGES.includes(d))) { + features.push(DiscoveredFeature.Stripe); + } + + if (depNames.some((d) => LLM_PACKAGES.includes(d))) { + features.push(DiscoveredFeature.LLM); + } + } catch { + // No package.json or parse error — skip feature discovery + } + + return features; +} diff --git a/src/lib/detection/framework.ts b/src/lib/detection/framework.ts new file mode 100644 index 00000000..61587f93 --- /dev/null +++ b/src/lib/detection/framework.ts @@ -0,0 +1,36 @@ +/** + * Framework detection — identify which PostHog-supported framework + * is present in the project directory. + * + * Pure function: takes an install dir, returns the detected integration + * (or undefined). No store mutations, no UI calls. + */ + +import { Integration, DETECTION_TIMEOUT_MS } from '../constants.js'; +import { FRAMEWORK_REGISTRY } from '../registry.js'; + +/** + * Loop through all registered frameworks and return the first one + * whose `detect()` predicate matches the given directory. + * Returns undefined if no framework is detected or detection times out. + */ +export async function detectFramework( + installDir: string, +): Promise { + for (const integration of Object.values(Integration)) { + const config = FRAMEWORK_REGISTRY[integration]; + try { + const detected = await Promise.race([ + config.detection.detect({ installDir }), + new Promise((resolve) => + setTimeout(() => resolve(false), DETECTION_TIMEOUT_MS), + ), + ]); + if (detected) { + return integration; + } + } catch { + // Skip frameworks whose detection throws + } + } +} diff --git a/src/lib/detection/index.ts b/src/lib/detection/index.ts new file mode 100644 index 00000000..ecc446cc --- /dev/null +++ b/src/lib/detection/index.ts @@ -0,0 +1,7 @@ +export { detectFramework } from './framework.js'; +export { discoverFeatures } from './features.js'; +export { + gatherFrameworkContext, + checkFrameworkVersion, + type VersionCheckResult, +} from './context.js'; diff --git a/src/lib/package-manager-detection.ts b/src/lib/detection/package-manager.ts similarity index 98% rename from src/lib/package-manager-detection.ts rename to src/lib/detection/package-manager.ts index 8bdce4f7..0a8427d0 100644 --- a/src/lib/package-manager-detection.ts +++ b/src/lib/detection/package-manager.ts @@ -10,11 +10,11 @@ import { detectAllPackageManagers, type PackageManager, -} from '../utils/package-manager'; +} from '../../utils/package-manager'; import { detectPackageManager as detectPythonPM, PythonPackageManager, -} from '../frameworks/python/utils'; +} from '../../frameworks/python/utils'; // --------------------------------------------------------------------------- // Common types diff --git a/src/lib/framework-config.ts b/src/lib/framework-config.ts index 39d07999..bb502007 100644 --- a/src/lib/framework-config.ts +++ b/src/lib/framework-config.ts @@ -1,6 +1,6 @@ import type { Integration } from './constants'; import type { WizardOptions } from '../utils/types'; -import type { PackageManagerDetector } from './package-manager-detection'; +import type { PackageManagerDetector } from './detection/package-manager'; /** * A setup question that the SetupScreen renders for framework disambiguation. diff --git a/src/lib/health-checks/endpoints.ts b/src/lib/health-checks/endpoints.ts index 99c2941b..f740d97e 100644 --- a/src/lib/health-checks/endpoints.ts +++ b/src/lib/health-checks/endpoints.ts @@ -1,3 +1,4 @@ +import { REMOTE_SKILLS_BASE_URL } from '../constants'; import { ServiceHealthStatus, type BaseHealthResult } from './types'; // --------------------------------------------------------------------------- @@ -51,6 +52,4 @@ export const checkMcpHealth = (): Promise => fetchEndpointHealth('https://mcp.posthog.com/'); export const checkGithubReleasesHealth = (): Promise => - fetchEndpointHealth( - 'https://github.com/PostHog/context-mill/releases/latest/download/skill-menu.json', - ); + fetchEndpointHealth(`${REMOTE_SKILLS_BASE_URL}/skill-menu.json`); diff --git a/src/lib/middleware/benchmark.ts b/src/lib/middleware/benchmark.ts index 099219e1..01c624a8 100644 --- a/src/lib/middleware/benchmark.ts +++ b/src/lib/middleware/benchmark.ts @@ -15,7 +15,7 @@ import { loadBenchmarkConfig } from './config'; import { createPluginsFromConfig } from './benchmarks'; import type { BenchmarkConfig } from './config'; import type { WizardOptions } from '../../utils/types'; -import { AgentSignals } from '../agent-interface'; +import { AgentSignals } from '../agent/agent-interface'; // ── Types ────────────────────────────────────────────────────────────── diff --git a/src/lib/middleware/benchmarks/compaction-tracker.ts b/src/lib/middleware/benchmarks/compaction-tracker.ts index 844f1b6d..8e3dd410 100644 --- a/src/lib/middleware/benchmarks/compaction-tracker.ts +++ b/src/lib/middleware/benchmarks/compaction-tracker.ts @@ -7,7 +7,7 @@ import type { Middleware, MiddlewareContext, MiddlewareStore } from '../types'; import { logToFile } from '../../../utils/debug'; -import { AgentSignals } from '../../agent-interface'; +import { AgentSignals } from '../../agent/agent-interface'; export interface CompactionData { phaseCompactions: number; diff --git a/src/lib/middleware/benchmarks/json-writer.ts b/src/lib/middleware/benchmarks/json-writer.ts index fe5fb016..4ea5f49d 100644 --- a/src/lib/middleware/benchmarks/json-writer.ts +++ b/src/lib/middleware/benchmarks/json-writer.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import { getUI } from '../../../ui'; import { logToFile } from '../../../utils/debug'; -import { AgentSignals } from '../../agent-interface'; +import { AgentSignals } from '../../agent/agent-interface'; import type { Middleware, MiddlewareContext, MiddlewareStore } from '../types'; import type { TokenData } from './token-tracker'; import type { CacheData } from './cache-tracker'; diff --git a/src/lib/middleware/benchmarks/summary.ts b/src/lib/middleware/benchmarks/summary.ts index ac01eebe..f8255368 100644 --- a/src/lib/middleware/benchmarks/summary.ts +++ b/src/lib/middleware/benchmarks/summary.ts @@ -1,5 +1,5 @@ import { getUI, type SpinnerHandle } from '../../../ui'; -import { AgentSignals } from '../../agent-interface'; +import { AgentSignals } from '../../agent/agent-interface'; import type { Middleware, MiddlewareContext, MiddlewareStore } from '../types'; import type { TokenData } from './token-tracker'; import type { TurnData } from './turn-counter'; diff --git a/src/lib/middleware/config.ts b/src/lib/middleware/config.ts index 40294568..9306e5ac 100644 --- a/src/lib/middleware/config.ts +++ b/src/lib/middleware/config.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import path from 'path'; import { logToFile } from '../../utils/debug'; -import { AgentSignals } from '../agent-interface'; +import { AgentSignals } from '../agent/agent-interface'; export interface BenchmarkConfig { /** Enable/disable individual metric plugins */ diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index c665a623..241e3999 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -13,7 +13,14 @@ import type { Integration } from './constants'; import type { FrameworkConfig } from './framework-config'; import type { WizardReadinessResult } from './health-checks/readiness'; -import type { SettingsConflict } from './agent-interface'; +import type { SettingsConflict } from './agent/agent-interface'; + +export interface Credentials { + accessToken: string; + projectApiKey: string; + host: string; + projectId: number; +} function parseProjectIdArg(value: string | undefined): number | undefined { if (value === undefined || value === '') return undefined; @@ -73,10 +80,16 @@ export enum OutroKind { export interface OutroData { kind: OutroKind; + /** Main headline (green check for Success, red X for Error, etc.) */ message?: string; + /** Free-form body text shown under the headline. Use \n for paragraph breaks. */ + body?: string; + /** Success-only: bulleted list of "what the agent did" */ changes?: string[]; docsUrl?: string; continueUrl?: string; + /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ + reportFile?: string; } export interface WizardSession { @@ -116,12 +129,7 @@ export interface WizardSession { } | null; // From OAuth - credentials: { - accessToken: string; - projectApiKey: string; - host: string; - projectId: number; - } | null; + credentials: Credentials | null; // Lifecycle runPhase: RunPhase; @@ -149,6 +157,10 @@ export interface WizardSession { // Additional features queue (drained via stop hook after main integration) additionalFeatureQueue: AdditionalFeature[]; + // Workflow metadata (set by runWizard in bin.ts) + workflowLabel: string | null; + skillId: string | null; + // Resolved framework config (set after integration is known) frameworkConfig: FrameworkConfig | null; } @@ -214,6 +226,8 @@ export function buildSession(args: { portConflictProcess: null, outroData: null, additionalFeatureQueue: [], + workflowLabel: null, + skillId: null, frameworkConfig: null, }; } diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index 206c0183..6b3a54c5 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -12,7 +12,7 @@ import fs from 'fs'; import { execFileSync } from 'child_process'; import { z } from 'zod'; import { logToFile } from '../utils/debug'; -import type { PackageManagerDetector } from './package-manager-detection'; +import type { PackageManagerDetector } from './detection/package-manager'; // --------------------------------------------------------------------------- // SDK dynamic import (ESM module loaded once, cached) @@ -107,6 +107,51 @@ export function downloadSkill( } } +/** + * Structured result for installSkillById. + * - `ok`: the skill was fetched and extracted; `path` is where it lives + * relative to installDir. + * - `menu-fetch-failed`: couldn't fetch or parse the skill menu. + * - `skill-not-found`: the menu didn't contain a skill with this id. + * - `download-failed`: found the skill but download/extract failed; + * `message` has the underlying error. + */ +export type InstallSkillResult = + | { kind: 'ok'; path: string } + | { kind: 'menu-fetch-failed' } + | { kind: 'skill-not-found'; skillId: string } + | { kind: 'download-failed'; message: string }; + +/** + * High-level "install a skill by ID" helper. Fetches the skill menu, + * finds the skill, downloads and extracts it. Workflows should use this + * instead of composing fetchSkillMenu + downloadSkill themselves. + */ +export async function installSkillById( + skillId: string, + installDir: string, + skillsBaseUrl: string, + skillsRoot?: string, +): Promise { + const menu = await fetchSkillMenu(skillsBaseUrl); + if (!menu) return { kind: 'menu-fetch-failed' }; + + const skill = Object.values(menu.categories) + .flat() + .find((s) => s.id === skillId); + if (!skill) return { kind: 'skill-not-found', skillId }; + + const result = downloadSkill(skill, installDir, skillsRoot); + if (!result.success) { + return { kind: 'download-failed', message: result.error ?? 'unknown' }; + } + + const relPath = skillsRoot + ? `${skillsRoot}/${skillId}` + : `.claude/skills/${skillId}`; + return { kind: 'ok', path: relPath }; +} + // --------------------------------------------------------------------------- // Options for creating the wizard tools server // --------------------------------------------------------------------------- diff --git a/src/lib/workflows/__tests__/agent-skill.test.ts b/src/lib/workflows/__tests__/agent-skill.test.ts new file mode 100644 index 00000000..cd749997 --- /dev/null +++ b/src/lib/workflows/__tests__/agent-skill.test.ts @@ -0,0 +1,82 @@ +import { + createSkillWorkflow, + AGENT_SKILL_STEPS, + type SkillWorkflowOptions, +} from '../agent-skill/index.js'; +import type { WorkflowRun } from '../../agent/agent-runner.js'; +import { buildSession, RunPhase } from '../../wizard-session.js'; + +const baseOpts: SkillWorkflowOptions = { + skillId: 'error-tracking-setup', + command: 'errors', + flowKey: 'error-tracking', + description: 'Set up PostHog error tracking', + integrationLabel: 'error-tracking', + successMessage: 'Error tracking configured!', + reportFile: 'posthog-error-tracking-report.md', + docsUrl: 'https://posthog.com/docs/error-tracking', + spinnerMessage: 'Setting up error tracking...', + estimatedDurationMinutes: 5, +}; + +describe('createSkillWorkflow', () => { + it('produces a WorkflowConfig with static run (not a function)', () => { + const config = createSkillWorkflow(baseOpts); + + expect(config.command).toBe('errors'); + expect(config.flowKey).toBe('error-tracking'); + expect(config.steps).toBe(AGENT_SKILL_STEPS); + + // run must be a static object — skill workflows don't need dynamic resolution + const run = config.run as WorkflowRun; + expect(typeof config.run).toBe('object'); + expect(run.skillId).toBe('error-tracking-setup'); + expect(run.integrationLabel).toBe('error-tracking'); + }); + + it('wraps customPrompt string into a function, omits when absent', () => { + const withPrompt = createSkillWorkflow({ + ...baseOpts, + customPrompt: 'Do the thing.', + }); + const without = createSkillWorkflow(baseOpts); + + expect((withPrompt.run as WorkflowRun).customPrompt!(null as never)).toBe( + 'Do the thing.', + ); + expect((without.run as WorkflowRun).customPrompt).toBeUndefined(); + }); +}); + +describe('AGENT_SKILL_STEPS', () => { + it('is auth → run → outro, all with screens and working predicates', () => { + expect(AGENT_SKILL_STEPS.map((s) => s.id)).toEqual([ + 'auth', + 'run', + 'outro', + ]); + + const session = buildSession({}); + const [auth, run, outro] = AGENT_SKILL_STEPS; + + // All incomplete initially + expect(auth.isComplete!(session)).toBe(false); + expect(run.isComplete!(session)).toBe(false); + expect(outro.isComplete!(session)).toBe(false); + + // Completing each + session.credentials = { + accessToken: 't', + projectApiKey: 'k', + host: 'h', + projectId: 1, + }; + expect(auth.isComplete!(session)).toBe(true); + + session.runPhase = RunPhase.Completed; + expect(run.isComplete!(session)).toBe(true); + + session.outroDismissed = true; + expect(outro.isComplete!(session)).toBe(true); + }); +}); diff --git a/src/lib/workflows/__tests__/revenue-analytics-detect.test.ts b/src/lib/workflows/__tests__/revenue-analytics-detect.test.ts new file mode 100644 index 00000000..023ce66e --- /dev/null +++ b/src/lib/workflows/__tests__/revenue-analytics-detect.test.ts @@ -0,0 +1,94 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { detectRevenuePrerequisites } from '../revenue-analytics/index.js'; +import { buildSession } from '../../wizard-session.js'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'rev-detect-')); +} + +function cleanup(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function writePackageJson( + dir: string, + deps: Record = {}, +): void { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: deps }), + ); +} + +describe('detectRevenuePrerequisites', () => { + let tmpDir: string; + let ctx: Record; + let setCtx: jest.Mock; + + beforeEach(() => { + tmpDir = makeTmpDir(); + ctx = {}; + setCtx = jest.fn((key: string, value: unknown) => { + ctx[key] = value; + }); + }); + afterEach(() => cleanup(tmpDir)); + + it('errors when install directory is invalid', () => { + const session = buildSession({ installDir: '/nonexistent/path' }); + detectRevenuePrerequisites(session, setCtx); + + expect(ctx.detectError).toEqual( + expect.objectContaining({ kind: 'bad-directory' }), + ); + }); + + it('errors when no package.json exists', () => { + const session = buildSession({ installDir: tmpDir }); + detectRevenuePrerequisites(session, setCtx); + + expect(ctx.detectError).toEqual({ kind: 'no-package-json' }); + }); + + it('errors when only one of PostHog/Stripe is found', () => { + writePackageJson(tmpDir, { stripe: '13.0.0' }); + + const session = buildSession({ installDir: tmpDir }); + detectRevenuePrerequisites(session, setCtx); + + expect(ctx.detectError).toEqual( + expect.objectContaining({ + kind: 'missing-posthog', + foundStripe: ['stripe'], + }), + ); + }); + + it('succeeds when both PostHog and Stripe SDKs are present', () => { + writePackageJson(tmpDir, { 'posthog-js': '1.0.0', stripe: '13.0.0' }); + + const session = buildSession({ installDir: tmpDir }); + detectRevenuePrerequisites(session, setCtx); + + expect(ctx.detectError).toBeUndefined(); + expect(ctx.detectedPosthogSdks).toEqual(['posthog-js']); + expect(ctx.detectedStripeSdks).toEqual(['stripe']); + }); + + it('aggregates SDKs across monorepo packages', () => { + writePackageJson(tmpDir, { 'posthog-js': '1.0.0' }); + + const subDir = path.join(tmpDir, 'packages', 'api'); + fs.mkdirSync(subDir, { recursive: true }); + writePackageJson(subDir, { stripe: '13.0.0' }); + + const session = buildSession({ installDir: tmpDir }); + detectRevenuePrerequisites(session, setCtx); + + expect(ctx.detectError).toBeUndefined(); + expect(ctx.detectedPosthogSdks).toContain('posthog-js'); + expect(ctx.detectedStripeSdks).toContain('stripe'); + }); +}); diff --git a/src/lib/workflows/__tests__/workflow-registry.test.ts b/src/lib/workflows/__tests__/workflow-registry.test.ts new file mode 100644 index 00000000..57979eed --- /dev/null +++ b/src/lib/workflows/__tests__/workflow-registry.test.ts @@ -0,0 +1,42 @@ +import { + WORKFLOW_REGISTRY, + getWorkflowConfig, + getSubcommandWorkflows, +} from '../workflow-registry.js'; + +describe('WORKFLOW_REGISTRY', () => { + it('every entry has unique flowKey, description, and non-empty steps', () => { + const flowKeys = WORKFLOW_REGISTRY.map((c) => c.flowKey); + expect(new Set(flowKeys).size).toBe(flowKeys.length); + + for (const config of WORKFLOW_REGISTRY) { + expect(config.description).toBeTruthy(); + expect(config.steps.length).toBeGreaterThan(0); + } + }); +}); + +describe('getWorkflowConfig', () => { + it('finds known configs by flowKey and returns undefined for unknown', () => { + expect(getWorkflowConfig('posthog-integration')?.flowKey).toBe( + 'posthog-integration', + ); + expect(getWorkflowConfig('revenue-analytics-setup')?.command).toBe( + 'revenue', + ); + expect(getWorkflowConfig('nonexistent')).toBeUndefined(); + }); +}); + +describe('getSubcommandWorkflows', () => { + it('returns only workflows that have a CLI command', () => { + const subcommands = getSubcommandWorkflows(); + const commands = subcommands.map((c) => c.command); + + expect(commands).toContain('integrate'); + expect(commands).toContain('revenue'); + for (const config of subcommands) { + expect(config.command).toBeTruthy(); + } + }); +}); diff --git a/src/lib/workflows/__tests__/workflow-step.test.ts b/src/lib/workflows/__tests__/workflow-step.test.ts new file mode 100644 index 00000000..7c51f64b --- /dev/null +++ b/src/lib/workflows/__tests__/workflow-step.test.ts @@ -0,0 +1,59 @@ +import { workflowToFlowEntries, type WorkflowStep } from '../workflow-step.js'; + +describe('workflowToFlowEntries', () => { + it('filters out headless steps and keeps only screen-bearing ones', () => { + const workflow: WorkflowStep[] = [ + { id: 'detect', label: 'Detecting' }, // headless + { id: 'intro', label: 'Welcome', screen: 'intro' }, + { id: 'check', label: 'Checking' }, // headless + { id: 'run', label: 'Running', screen: 'run' }, + { id: 'outro', label: 'Outro', screen: 'outro' }, + ]; + + const entries = workflowToFlowEntries(workflow); + expect(entries.map((e) => e.screen)).toEqual(['intro', 'run', 'outro']); + }); + + it('falls back isComplete to gate, preferring explicit isComplete', () => { + const gateFn = jest.fn(); + const isCompleteFn = jest.fn(); + + const workflow: WorkflowStep[] = [ + { id: 'a', label: 'A', screen: 'a', gate: gateFn }, + { + id: 'b', + label: 'B', + screen: 'b', + isComplete: isCompleteFn, + gate: gateFn, + }, + { id: 'c', label: 'C', screen: 'c' }, + ]; + + const entries = workflowToFlowEntries(workflow); + + expect(entries[0].isComplete).toBe(gateFn); // fallback + expect(entries[1].isComplete).toBe(isCompleteFn); // explicit wins + expect(entries[2].isComplete).toBeUndefined(); // neither set + }); + + it('strips internal step fields — router only sees screen/show/isComplete', () => { + const workflow: WorkflowStep[] = [ + { + id: 'intro', + label: 'Welcome', + screen: 'intro', + gate: () => true, + onInit: jest.fn(), + onReady: jest.fn(), + }, + ]; + + const entry = workflowToFlowEntries(workflow)[0]; + expect(entry).not.toHaveProperty('id'); + expect(entry).not.toHaveProperty('label'); + expect(entry).not.toHaveProperty('gate'); + expect(entry).not.toHaveProperty('onInit'); + expect(entry).not.toHaveProperty('onReady'); + }); +}); diff --git a/src/lib/workflows/agent-skill/index.ts b/src/lib/workflows/agent-skill/index.ts new file mode 100644 index 00000000..fe0f34a3 --- /dev/null +++ b/src/lib/workflows/agent-skill/index.ts @@ -0,0 +1,69 @@ +/** + * Generic agent skill workflow factory. + * + * Creates a WorkflowConfig for any context-mill skill. Provide a + * skill ID and basic UI config — the factory handles the rest. + * + * Usage: + * createSkillWorkflow({ + * skillId: 'error-tracking-setup', + * command: 'errors', + * flowKey: 'error-tracking', + * description: 'Set up PostHog error tracking', + * integrationLabel: 'error-tracking', + * successMessage: 'Error tracking configured!', + * reportFile: 'posthog-error-tracking-report.md', + * docsUrl: 'https://posthog.com/docs/error-tracking', + * spinnerMessage: 'Setting up error tracking...', + * estimatedDurationMinutes: 5, + * }) + */ + +import type { WorkflowConfig } from '../workflow-step.js'; +import { AGENT_SKILL_STEPS } from './steps.js'; + +export interface SkillWorkflowOptions { + /** Context-mill skill ID to install */ + skillId: string; + /** CLI subcommand name */ + command: string; + /** Unique flow key — must match a Flow enum entry */ + flowKey: string; + /** CLI description shown in --help */ + description: string; + /** Analytics integration label */ + integrationLabel: string; + /** Custom prompt instruction. Appended after default project prompt. */ + customPrompt?: string; + successMessage: string; + reportFile: string; + docsUrl: string; + spinnerMessage: string; + estimatedDurationMinutes: number; + /** Other workflow flowKeys that must be satisfied first */ + requires?: string[]; +} + +export function createSkillWorkflow( + opts: SkillWorkflowOptions, +): WorkflowConfig { + return { + command: opts.command, + description: opts.description, + flowKey: opts.flowKey, + steps: AGENT_SKILL_STEPS, + run: { + skillId: opts.skillId, + integrationLabel: opts.integrationLabel, + customPrompt: opts.customPrompt ? () => opts.customPrompt! : undefined, + successMessage: opts.successMessage, + reportFile: opts.reportFile, + docsUrl: opts.docsUrl, + spinnerMessage: opts.spinnerMessage, + estimatedDurationMinutes: opts.estimatedDurationMinutes, + }, + requires: opts.requires, + }; +} + +export { AGENT_SKILL_STEPS } from './steps.js'; diff --git a/src/lib/workflows/agent-skill/steps.ts b/src/lib/workflows/agent-skill/steps.ts new file mode 100644 index 00000000..39003852 --- /dev/null +++ b/src/lib/workflows/agent-skill/steps.ts @@ -0,0 +1,32 @@ +/** + * Generic agent skill step list. + * + * Minimal flow: intro → auth → run → outro. + * No detection, no setup, no MCP, no skills screen. + */ + +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../../wizard-session.js'; + +export const AGENT_SKILL_STEPS: Workflow = [ + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Running', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, +]; diff --git a/src/lib/workflows/posthog-integration/detect.ts b/src/lib/workflows/posthog-integration/detect.ts new file mode 100644 index 00000000..ac66859c --- /dev/null +++ b/src/lib/workflows/posthog-integration/detect.ts @@ -0,0 +1,72 @@ +/** + * Core integration detection step. + * + * Runs framework detection, context gathering, version checking, + * and feature discovery. Writes results to the store via the + * WorkflowReadyContext so the IntroScreen can display them. + * + * This is the same work that bin.ts $0 handler does inline — + * extracted here so the `integrate` subcommand can reuse it. + */ + +import type { WorkflowReadyContext } from '../workflow-step.js'; +import { FRAMEWORK_REGISTRY } from '../../registry.js'; +import { + detectFramework, + discoverFeatures, + gatherFrameworkContext, + checkFrameworkVersion, +} from '../../detection/index.js'; + +export async function detectPostHogIntegration( + ctx: WorkflowReadyContext, +): Promise { + const session = ctx.session; + const installDir = session.installDir; + + const detectedIntegration = await detectFramework(installDir); + + if (detectedIntegration) { + const config = FRAMEWORK_REGISTRY[detectedIntegration]; + + const sessionOptions = { + installDir, + debug: session.debug, + forceInstall: session.forceInstall, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + menu: session.menu, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }; + + // Gather framework-specific context (e.g., router type) + const context = await gatherFrameworkContext(config, sessionOptions); + for (const [key, value] of Object.entries(context)) { + if (!(key in session.frameworkContext)) { + ctx.setFrameworkContext(key, value); + } + } + + ctx.setFrameworkConfig(detectedIntegration, config); + + if (!session.detectedFrameworkLabel) { + ctx.setDetectedFramework(config.metadata.name); + } + + // Version check + const versionResult = await checkFrameworkVersion(config, sessionOptions); + if (versionResult.supported !== true) { + ctx.setUnsupportedVersion(versionResult.supported); + } + } + + // Feature discovery + for (const feature of discoverFeatures(installDir)) { + ctx.addDiscoveredFeature(feature); + } + + ctx.setDetectionComplete(); +} diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts new file mode 100644 index 00000000..2a0cb6eb --- /dev/null +++ b/src/lib/workflows/posthog-integration/index.ts @@ -0,0 +1,197 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import type { WorkflowRun } from '../../agent/agent-runner.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { OutroKind } from '../../wizard-session.js'; +import { AgentSignals } from '../../agent/agent-interface.js'; +import { + DEFAULT_PACKAGE_INSTALLATION, + SPINNER_MESSAGE, +} from '../../framework-config.js'; +import { + tryGetPackageJson, + isUsingTypeScript, +} from '../../../utils/setup-utils.js'; +import { analytics } from '../../../utils/analytics.js'; +import { WIZARD_INTERACTION_EVENT_NAME } from '../../constants.js'; +import { getUI } from '../../../ui/index.js'; +import { getCloudUrlFromRegion } from '../../../utils/urls.js'; +import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; + +export const posthogIntegrationConfig: WorkflowConfig = { + command: 'integrate', + description: 'Set up PostHog SDK integration', + flowKey: 'posthog-integration', + steps: POSTHOG_INTEGRATION_WORKFLOW, + + run: async (session: WizardSession): Promise => { + const config = session.frameworkConfig!; + + const typeScriptDetected = isUsingTypeScript({ + installDir: session.installDir, + }); + session.typescript = typeScriptDetected; + + // Read package.json and resolve framework version + const usesPackageJson = config.detection.usesPackageJson !== false; + let frameworkVersion: string | undefined; + + if (usesPackageJson) { + const packageJson = await tryGetPackageJson({ + installDir: session.installDir, + }); + if (packageJson) { + const { hasPackageInstalled } = await import( + '../../../utils/package-json.js' + ); + if (!hasPackageInstalled(config.detection.packageName, packageJson)) { + getUI().log.warn( + `${config.detection.packageDisplayName} does not seem to be installed. Continuing anyway — the agent will handle it.`, + ); + } + frameworkVersion = config.detection.getVersion(packageJson); + } else { + getUI().log.warn( + 'Could not find package.json. Continuing anyway — the agent will handle it.', + ); + } + } else { + frameworkVersion = config.detection.getVersion(null); + } + + // Analytics tags + if (frameworkVersion && config.detection.getVersionBucket) { + const versionBucket = config.detection.getVersionBucket(frameworkVersion); + analytics.setTag(`${config.metadata.integration}-version`, versionBucket); + } + const frameworkContext = session.frameworkContext; + const contextTags = config.analytics.getTags(frameworkContext); + Object.entries(contextTags).forEach(([key, value]) => { + analytics.setTag(key, value); + }); + + return { + integrationLabel: config.metadata.integration, + additionalMcpServers: config.metadata.additionalMcpServers, + detectPackageManager: config.detection.detectPackageManager, + spinnerMessage: SPINNER_MESSAGE, + successMessage: config.ui.successMessage, + estimatedDurationMinutes: config.ui.estimatedDurationMinutes, + reportFile: 'posthog-setup-report.md', + docsUrl: config.metadata.docsUrl, + errorMessage: 'Integration failed', + additionalFeatureQueue: session.additionalFeatureQueue, + + customPrompt: (ctx) => { + const additionalLines = config.prompts.getAdditionalContextLines + ? config.prompts.getAdditionalContextLines(frameworkContext) + : []; + const additionalContext = + additionalLines.length > 0 + ? '\n' + additionalLines.map((line) => `- ${line}`).join('\n') + : ''; + + return `You have access to the PostHog MCP server which provides skills to integrate PostHog into this ${ + config.metadata.name + } project. + +Project context: +- PostHog Project ID: ${ctx.projectId} +- Framework: ${config.metadata.name} ${frameworkVersion || 'latest'} +- TypeScript: ${typeScriptDetected ? 'Yes' : 'No'} +- PostHog public token: ${ctx.projectApiKey} +- PostHog Host: ${ctx.host} +- Project type: ${config.prompts.projectTypeDetection} +- Package installation: ${ + config.prompts.packageInstallation ?? DEFAULT_PACKAGE_INSTALLATION + }${additionalContext} + +Instructions (follow these steps IN ORDER - do not skip or reorder): + +STEP 1: Call load_skill_menu (from the wizard-tools MCP server) to see available skills. + If the tool fails, emit: ${ + AgentSignals.ERROR_MCP_MISSING + } Could not load skill menu and halt. + + Choose a skill from the \`integration\` category that matches this project's framework. Do NOT pick skills from other categories (llm-analytics, error-tracking, feature-flags, omnibus, etc.) — those are handled separately. + If no suitable integration skill is found, emit: ${ + AgentSignals.ERROR_RESOURCE_MISSING + } Could not find a suitable skill for this project. + +STEP 2: Call install_skill (from the wizard-tools MCP server) with the chosen skill ID (e.g., "integration-nextjs-app-router"). + Do NOT run any shell commands to install skills. + +STEP 3: Load the installed skill's SKILL.md file to understand what references are available. + +STEP 4: Follow the skill's workflow files in sequence. Look for numbered workflow files in the references (e.g., files with patterns like "1.0-", "1.1-", "1.2-"). Start with the first one and proceed through each step until completion. Each workflow file will tell you what to do and which file comes next. Never directly write PostHog tokens directly to code files; always use environment variables. + +STEP 5: Set up environment variables for PostHog using the wizard-tools MCP server (this runs locally — secret values never leave the machine): + - Use check_env_keys to see which keys already exist in the project's .env file (e.g. .env.local or .env). + - Use set_env_values to create or update the PostHog public token and host, using the appropriate environment variable naming convention for ${ + config.metadata.name + }, which you'll find in example code. The tool will also ensure .gitignore coverage. Don't assume the presence of keys means the value is up to date. Write the correct value each time. + - Reference these environment variables in the code files you create instead of hardcoding the public token and host. + +Important: Use the detect_package_manager tool (from the wizard-tools MCP server) to determine which package manager the project uses. Do not manually search for lockfiles or config files. Always install packages as a background task. Don't await completion; proceed with other work immediately after starting the installation. You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure. + + +`; + }, + + postRun: async (sess, credentials) => { + const envVars = config.environment.getEnvVars( + credentials.projectApiKey, + credentials.host, + ); + if (config.environment.uploadToHosting) { + const { uploadEnvironmentVariablesStep } = await import( + '../../../steps/index.js' + ); + const uploadedEnvVars = await uploadEnvironmentVariablesStep( + envVars, + { + integration: config.metadata.integration, + session: sess, + }, + ); + if (uploadedEnvVars.length > 0) { + analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { + action: 'wizard_env_vars_uploaded', + integration: config.metadata.integration, + variable_count: uploadedEnvVars.length, + variable_keys: uploadedEnvVars, + }); + } + } + }, + + buildOutroData: (sess, credentials, cloudRegion) => { + const envVars = config.environment.getEnvVars( + credentials.projectApiKey, + credentials.host, + ); + const continueUrl = + sess.signup && cloudRegion + ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` + : undefined; + + const changes = [ + ...config.ui.getOutroChanges(frameworkContext), + Object.keys(envVars).length > 0 + ? 'Added environment variables to .env file' + : '', + ].filter(Boolean); + + return { + kind: OutroKind.Success as const, + message: 'Successfully installed PostHog!', + reportFile: 'posthog-setup-report.md', + changes, + docsUrl: config.metadata.docsUrl, + continueUrl, + }; + }, + }; + }, +}; + +export { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; diff --git a/src/lib/workflows/posthog-integration/steps.ts b/src/lib/workflows/posthog-integration/steps.ts new file mode 100644 index 00000000..54277178 --- /dev/null +++ b/src/lib/workflows/posthog-integration/steps.ts @@ -0,0 +1,107 @@ +/** + * PostHog integration workflow — the default wizard flow. + * + * Steps define their own gate predicates and onInit callbacks. + * The store derives gate promises and fires init work from these + * definitions — no hardcoded per-flow logic in the store. + */ + +import type { Workflow } from '../workflow-step.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { RunPhase } from '../../wizard-session.js'; +import { + evaluateWizardReadiness, + WizardReadiness, +} from '../../health-checks/readiness.js'; +import { detectPostHogIntegration } from './detect.js'; + +function needsSetup(session: WizardSession): boolean { + const config = session.frameworkConfig; + if (!config?.metadata.setup?.questions) return false; + + return config.metadata.setup.questions.some( + (q: { key: string }) => !(q.key in session.frameworkContext), + ); +} + +function healthCheckReady(session: WizardSession): boolean { + if (!session.readinessResult) return false; + if (session.readinessResult.decision === WizardReadiness.No) + return session.outageDismissed; + return true; +} + +export const POSTHOG_INTEGRATION_WORKFLOW: Workflow = [ + { + id: 'detect', + label: 'Detecting framework', + // Headless step: no screen. onReady fires after bin.ts assigns the + // session — runs framework detection, context gathering, version + // check, and feature discovery. Results are written to the store + // for the IntroScreen to render. + onReady: (ctx) => detectPostHogIntegration(ctx), + }, + { + id: 'intro', + label: 'Welcome', + screen: 'intro', + gate: (session) => session.setupConfirmed, + }, + { + id: 'health-check', + label: 'Health check', + screen: 'health-check', + gate: healthCheckReady, + onInit: (ctx) => { + evaluateWizardReadiness() + .then((readiness) => { + ctx.setReadinessResult(readiness); + }) + .catch(() => { + ctx.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + }); + }, + }, + { + id: 'setup', + label: 'Setup', + screen: 'setup', + show: needsSetup, + isComplete: (session) => !needsSetup(session), + }, + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Integration', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'mcp', + label: 'MCP servers', + screen: 'mcp', + isComplete: (session) => session.mcpComplete, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, + { + id: 'skills', + label: 'Skills', + screen: 'skills', + }, +]; diff --git a/src/lib/workflows/revenue-analytics/detect.ts b/src/lib/workflows/revenue-analytics/detect.ts new file mode 100644 index 00000000..b914f419 --- /dev/null +++ b/src/lib/workflows/revenue-analytics/detect.ts @@ -0,0 +1,201 @@ +/** + * Revenue analytics prerequisite detection. + * + * Scans the project for PostHog + Stripe SDKs and writes results + * into frameworkContext for the intro screen to render. + */ + +import type { Dirent } from 'fs'; +import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { IGNORED_DIRS } from '../../../utils/file-utils.js'; +import type { WizardSession } from '../../wizard-session.js'; +import type { AbortCase } from '../../agent/agent-runner.js'; + +export const POSTHOG_SDKS = [ + 'posthog-js', + 'posthog-node', + 'posthog-react-native', + 'posthog-android', + 'posthog-ios', +]; + +export const STRIPE_SDKS = [ + 'stripe', + '@stripe/stripe-js', + '@stripe/react-stripe-js', +]; + +interface PackageMatch { + /** Path to the package.json relative to installDir */ + path: string; + posthogSdks: string[]; + stripeSdks: string[]; +} + +/** + * Structured detection errors. The screen renders each kind into JSX + * with proper formatting — keeps error data separate from presentation. + */ +export type RevenueDetectError = + | { + kind: 'bad-directory'; + path: string; + reason: 'missing' | 'not-dir' | 'unreadable'; + } + | { kind: 'no-package-json' } + | { kind: 'no-sdks'; scannedCount: number } + | { kind: 'missing-posthog'; foundStripe: string[] } + | { kind: 'missing-stripe'; foundPosthog: string[] }; + +/** `[ABORT] ` cases the revenue analytics skill can emit. */ +export const REVENUE_ABORT_CASES: AbortCase[] = [ + { + // Skill emits: [ABORT] Could not find a PostHog distinct_id + match: /^could not find a posthog distinct_id$/i, + message: 'Could not find a PostHog distinct_id', + body: + 'The agent could not find PostHog distinct_id usage in your codebase. ' + + 'Your users must be identified in PostHog before they can be tagged in Stripe. ' + + 'Please identify your users and try again.', + docsUrl: 'https://posthog.com/docs/product-analytics/identify', + }, + { + // Skill emits: [ABORT] Could not find a Stripe integration + match: /^could not find a stripe integration$/i, + message: 'Could not find a Stripe integration', + body: + 'The Wizard could not find an existing Stripe customer, charge, ' + + 'subscription, or other Stripe operations. Please run the Revenue ' + + 'Analytics Wizard on a project with an existing Stripe integration.', + docsUrl: 'https://posthog.com/docs/revenue-analytics', + }, +]; + +/** + * Recursively find all package.json files under installDir (max depth 3), + * skipping common ignored directories. Returns matches with detected SDKs. + */ +function findPackageJsons(installDir: string, maxDepth = 3): PackageMatch[] { + const matches: PackageMatch[] = []; + + function scan(dir: string, depth: number): void { + if (depth > maxDepth) return; + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.') continue; + if (IGNORED_DIRS.has(entry.name)) continue; + + const fullPath = join(dir, entry.name); + + if (entry.isFile() && entry.name === 'package.json') { + try { + const pkg = JSON.parse(readFileSync(fullPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + const depNames = [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]; + const posthogSdks = depNames.filter((d) => POSTHOG_SDKS.includes(d)); + const stripeSdks = depNames.filter((d) => STRIPE_SDKS.includes(d)); + matches.push({ + path: relative(installDir, fullPath) || 'package.json', + posthogSdks, + stripeSdks, + }); + } catch { + // Skip malformed package.json + } + } else if (entry.isDirectory()) { + scan(fullPath, depth + 1); + } + } + } + + scan(installDir, 0); + return matches; +} + +/** + * Scan `session.installDir` for PostHog + Stripe SDKs. Writes detection + * results into frameworkContext via the callback — either the detected + * SDK lists (for the intro screen) or a `RevenueDetectError` on failure. + * + * The skill install happens later in the bootstrap runner, not here. + */ +export function detectRevenuePrerequisites( + session: WizardSession, + setFrameworkContext: (key: string, value: unknown) => void, +): void { + const fail = (error: RevenueDetectError) => + setFrameworkContext('detectError', error); + + const installDir = session.installDir; + + // Verify the install directory exists and is readable + if (!existsSync(installDir)) { + fail({ kind: 'bad-directory', path: installDir, reason: 'missing' }); + return; + } + try { + if (!statSync(installDir).isDirectory()) { + fail({ kind: 'bad-directory', path: installDir, reason: 'not-dir' }); + return; + } + } catch { + fail({ kind: 'bad-directory', path: installDir, reason: 'unreadable' }); + return; + } + + // Find all package.json files (root + monorepo subpackages) + const matches = findPackageJsons(installDir); + + if (matches.length === 0) { + fail({ kind: 'no-package-json' }); + return; + } + + // Aggregate detected SDKs across all package.json files + const allPosthogSdks = new Set(); + const allStripeSdks = new Set(); + for (const match of matches) { + for (const sdk of match.posthogSdks) allPosthogSdks.add(sdk); + for (const sdk of match.stripeSdks) allStripeSdks.add(sdk); + } + + const detectedPosthogSdks = [...allPosthogSdks]; + const detectedStripeSdks = [...allStripeSdks]; + + if (detectedPosthogSdks.length === 0 && detectedStripeSdks.length === 0) { + fail({ kind: 'no-sdks', scannedCount: matches.length }); + return; + } + + if (detectedPosthogSdks.length === 0) { + fail({ kind: 'missing-posthog', foundStripe: detectedStripeSdks }); + return; + } + + if (detectedStripeSdks.length === 0) { + fail({ kind: 'missing-stripe', foundPosthog: detectedPosthogSdks }); + return; + } + + setFrameworkContext('detectedPosthogSdks', detectedPosthogSdks); + setFrameworkContext('detectedStripeSdks', detectedStripeSdks); + setFrameworkContext( + 'detectedPackagePaths', + matches + .filter((m) => m.posthogSdks.length > 0 || m.stripeSdks.length > 0) + .map((m) => m.path), + ); +} diff --git a/src/lib/workflows/revenue-analytics/index.ts b/src/lib/workflows/revenue-analytics/index.ts new file mode 100644 index 00000000..3cffdf3e --- /dev/null +++ b/src/lib/workflows/revenue-analytics/index.ts @@ -0,0 +1,30 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import { REVENUE_ANALYTICS_WORKFLOW } from './steps.js'; +import { REVENUE_ABORT_CASES } from './detect.js'; + +export const revenueAnalyticsConfig: WorkflowConfig = { + command: 'revenue', + description: 'Set up PostHog revenue analytics (e.g. Stripe integration)', + flowKey: 'revenue-analytics-setup', + steps: REVENUE_ANALYTICS_WORKFLOW, + run: { + skillId: 'revenue-analytics-setup', + integrationLabel: 'revenue-analytics-setup', + customPrompt: () => 'Set up revenue analytics for this project.', + successMessage: 'Revenue analytics configured!', + reportFile: 'posthog-revenue-report.md', + docsUrl: 'https://posthog.com/docs/revenue-analytics', + spinnerMessage: 'Setting up revenue analytics...', + estimatedDurationMinutes: 5, + abortCases: REVENUE_ABORT_CASES, + }, + requires: ['posthog-integration'], +}; + +export { REVENUE_ANALYTICS_WORKFLOW } from './steps.js'; +export { + detectRevenuePrerequisites, + POSTHOG_SDKS, + STRIPE_SDKS, + type RevenueDetectError, +} from './detect.js'; diff --git a/src/lib/workflows/revenue-analytics/steps.ts b/src/lib/workflows/revenue-analytics/steps.ts new file mode 100644 index 00000000..2f840910 --- /dev/null +++ b/src/lib/workflows/revenue-analytics/steps.ts @@ -0,0 +1,49 @@ +/** + * Revenue analytics workflow step list. + * + * The detect step checks for PostHog + Stripe SDKs. The skill install + * and agent run live in the workflow runner (see agent-runner.ts). + */ + +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../../wizard-session.js'; +import { detectRevenuePrerequisites } from './detect.js'; + +export const REVENUE_ANALYTICS_WORKFLOW: Workflow = [ + { + id: 'detect', + label: 'Detecting prerequisites', + // Headless step: no screen, no gate. onReady fires after bin.ts + // assigns the session — the hook scans for PostHog + Stripe SDKs + // and writes the results (or a detectError) to frameworkContext + // for the intro screen to render. + onReady: (ctx) => + detectRevenuePrerequisites(ctx.session, ctx.setFrameworkContext), + }, + { + id: 'intro', + label: 'Welcome', + screen: 'revenue-intro', + gate: (session) => session.setupConfirmed, + }, + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Revenue analytics', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, +]; diff --git a/src/lib/workflows/workflow-registry.ts b/src/lib/workflows/workflow-registry.ts new file mode 100644 index 00000000..63297ccf --- /dev/null +++ b/src/lib/workflows/workflow-registry.ts @@ -0,0 +1,31 @@ +/** + * Central registry of all wizard workflows. + * + * Adding a new workflow: + * 1. Create src/lib/workflows// with index.ts exporting a WorkflowConfig + * 2. Import and add it to WORKFLOW_REGISTRY below + * 3. Add a matching Flow enum entry in src/ui/tui/flows.ts + * 4. (If custom intro screen) add to src/ui/tui/screen-registry.tsx + * + * flows.ts, store.ts, and bin.ts all derive their wiring from this array — + * no need to touch those files when adding a workflow. + */ + +import type { WorkflowConfig } from './workflow-step.js'; +import { posthogIntegrationConfig } from './posthog-integration/index.js'; +import { revenueAnalyticsConfig } from './revenue-analytics/index.js'; + +export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ + posthogIntegrationConfig, + revenueAnalyticsConfig, +]; + +/** Look up a workflow config by its flowKey. */ +export function getWorkflowConfig(flowKey: string): WorkflowConfig | undefined { + return WORKFLOW_REGISTRY.find((c) => c.flowKey === flowKey); +} + +/** All workflow configs that are exposed as CLI subcommands. */ +export function getSubcommandWorkflows(): WorkflowConfig[] { + return WORKFLOW_REGISTRY.filter((c) => c.command != null); +} diff --git a/src/lib/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts new file mode 100644 index 00000000..f31ebdda --- /dev/null +++ b/src/lib/workflows/workflow-step.ts @@ -0,0 +1,155 @@ +import type { WizardSession, DiscoveredFeature } from '../wizard-session'; +import type { WizardReadinessResult } from '../health-checks/readiness.js'; +import type { WorkflowRun } from '../agent/agent-runner.js'; +import type { Integration } from '../constants.js'; +import type { FrameworkConfig } from '../framework-config.js'; + +/** + * A workflow step is the primary unit of the wizard's execution model. + * + * It can own: + * - a screen in the TUI (optional — some steps are headless) + * - agent work via a workflow reference (optional — some steps are UI-only) + * - completion and visibility predicates + * + * The current PostHog integration flow is one ordered list of steps. + * Future flows (e.g. revenue analytics) register a different step list. + */ +/** + * Context passed to onInit callbacks — fires during store construction, + * before bin.ts has assigned the real session. + */ +export interface StoreInitContext { + readonly session: WizardSession; + readonly setReadinessResult: (result: WizardReadinessResult | null) => void; + readonly setFrameworkContext: (key: string, value: unknown) => void; + readonly emitChange: () => void; +} + +/** + * Context passed to onReady callbacks — fires after bin.ts has assigned + * the real session, so reading `session.installDir` returns the target + * project. Use for async pre-flow work like prerequisite detection. + */ +export interface WorkflowReadyContext { + readonly session: WizardSession; + readonly setFrameworkContext: (key: string, value: unknown) => void; + + // Detection-specific methods — used by core-integration's detect step + readonly setFrameworkConfig: ( + integration: Integration, + config: FrameworkConfig, + ) => void; + readonly setDetectedFramework: (label: string) => void; + readonly setUnsupportedVersion: (info: { + current: string; + minimum: string; + docsUrl: string; + }) => void; + readonly addDiscoveredFeature: (feature: DiscoveredFeature) => void; + readonly setDetectionComplete: () => void; +} + +export interface WorkflowStep { + /** Unique identifier for this step */ + id: string; + + /** Human-readable label for progress display */ + label: string; + + /** + * TUI screen this step owns, if any. + * Matches the Screen enum values (e.g. 'intro', 'run', 'outro'). + */ + screen?: string; + + /** + * Whether this step should be visible in the current flow. + * If omitted, the step is always visible. + */ + show?: (session: WizardSession) => boolean; + + /** + * Exit condition for the screen. Router advances when true. + * Defaults to `gate` if unset. + */ + isComplete?: (session: WizardSession) => boolean; + + /** + * Define a gate if your screen needs to await user interactions. + * bin.ts can `await store.getGate(stepId)` to pause until the + * predicate becomes true. + */ + gate?: (session: WizardSession) => boolean; + + /** + * Called once during store construction, with the default session. + * Use for session-independent fire-and-forget work that should start + * as early as possible (e.g. health check kicked off while the user + * is still reading the intro screen). + */ + onInit?: (ctx: StoreInitContext) => void; + + /** + * Called once after bin.ts has assigned the real session to the store, + * before any gate is awaited. Awaited in sequence with other steps' + * onReady callbacks. Use for session-dependent pre-flow work like + * scanning the installDir for prerequisites. May be sync or async. + */ + onReady?: (ctx: WorkflowReadyContext) => void | Promise; +} + +/** + * An ordered list of workflow steps that defines a wizard flow. + */ +export type Workflow = WorkflowStep[]; + +/** + * Uniform configuration for a wizard workflow. + * + * Each workflow directory exports one of these. The system uses it + * for CLI registration, flow/step wiring, and skill bootstrap. + */ +export interface WorkflowConfig { + /** CLI command name (e.g. 'revenue'). Omit for the default flow. */ + command?: string; + /** CLI description shown in --help */ + description: string; + /** Unique flow key — matches the Flow enum value */ + flowKey: string; + /** The ordered step list */ + steps: Workflow; + /** Agent run config. Static object or async function for dynamic config. */ + run?: WorkflowRun | ((session: WizardSession) => Promise); + /** Prerequisites: other workflow flowKeys that must have run first */ + requires?: string[]; +} + +/** + * Project a Workflow into the narrower FlowEntry shape the router consumes. + * + * Two things happen here: + * 1. Headless steps (no `screen`) are filtered out. The router walks + * visible screens; gate-only steps like `detect` are store concerns. + * 2. The step is narrowed to just { screen, show, isComplete } — the + * router has no business touching gate, onInit, id, or label. + * + * This intentional separation keeps the router focused on one question: + * "Which screen should be rendered right now?" + */ +export function workflowToFlowEntries(workflow: Workflow): Array<{ + screen: string; + show?: (session: WizardSession) => boolean; + isComplete?: (session: WizardSession) => boolean; +}> { + return workflow + .filter((step) => step.screen != null) + .map((step) => ({ + screen: step.screen!, + show: step.show, + // `isComplete` defaults to `gate` — for most steps they're the same + // predicate (e.g. intro: setupConfirmed unblocks bin.ts AND finishes + // the screen). Only override when the two conditions diverge. + isComplete: step.isComplete ?? step.gate, + })); +} diff --git a/src/run.ts b/src/run.ts deleted file mode 100644 index c486286c..00000000 --- a/src/run.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { type WizardSession, buildSession } from './lib/wizard-session'; - -import type { CloudRegion } from './utils/types'; - -import { - Integration, - DETECTION_TIMEOUT_MS, - WIZARD_INTERACTION_EVENT_NAME, -} from './lib/constants'; -import { readEnvironment } from './utils/environment'; -import { getUI } from './ui'; -import path from 'path'; -import { FRAMEWORK_REGISTRY } from './lib/registry'; -import { analytics } from './utils/analytics'; -import { runAgentWizard } from './lib/agent-runner'; -import { EventEmitter } from 'events'; -import { logToFile, configureLogFileFromEnvironment } from './utils/debug'; -import { wizardAbort } from './utils/wizard-abort'; -import { readApiKeyFromEnv } from './utils/env-api-key'; - -EventEmitter.defaultMaxListeners = 50; - -type Args = { - integration?: Integration; - debug?: boolean; - forceInstall?: boolean; - installDir?: string; - region?: CloudRegion; - default?: boolean; - signup?: boolean; - localMcp?: boolean; - ci?: boolean; - apiKey?: string; - email?: string; - projectId?: string; - menu?: boolean; - benchmark?: boolean; - yaraReport?: boolean; -}; - -export async function runWizard(argv: Args, session?: WizardSession) { - // Apply log file env overrides for all modes (CI, benchmark, and interactive). - configureLogFileFromEnvironment(); - - const finalArgs = { - ...argv, - ...readEnvironment(), - apiKey: argv.apiKey ?? readApiKeyFromEnv(), - }; - - let resolvedInstallDir: string; - if (finalArgs.installDir) { - if (path.isAbsolute(finalArgs.installDir)) { - resolvedInstallDir = finalArgs.installDir; - } else { - resolvedInstallDir = path.join(process.cwd(), finalArgs.installDir); - } - } else { - resolvedInstallDir = process.cwd(); - } - - // Build session if not provided (CI mode passes one pre-built) - if (!session) { - session = buildSession({ - debug: finalArgs.debug, - forceInstall: finalArgs.forceInstall, - installDir: resolvedInstallDir, - ci: finalArgs.ci, - signup: finalArgs.signup, - localMcp: finalArgs.localMcp, - apiKey: finalArgs.apiKey, - email: finalArgs.email, - region: finalArgs.region, - menu: finalArgs.menu, - integration: finalArgs.integration, - benchmark: finalArgs.benchmark, - yaraReport: finalArgs.yaraReport, - projectId: finalArgs.projectId, - }); - } - - session.installDir = resolvedInstallDir; - - getUI().intro(`Welcome to the PostHog setup wizard`); - - if (session.ci) { - getUI().log.info('Running in CI mode'); - } - - const integration = - session.integration ?? (await detectAndResolveIntegration(session)); - - session.integration = integration; - analytics.setTag('integration', integration); - - const config = FRAMEWORK_REGISTRY[integration]; - session.frameworkConfig = config; - - // Run gatherContext if the framework has it and it hasn't already run - // (bin.ts runs it early so IntroScreen can show the friendly label) - const contextAlreadyGathered = - Object.keys(session.frameworkContext).length > 0; - if (config.metadata.gatherContext && !contextAlreadyGathered) { - try { - const context = await config.metadata.gatherContext({ - installDir: session.installDir, - debug: session.debug, - forceInstall: session.forceInstall, - default: false, - signup: session.signup, - localMcp: session.localMcp, - ci: session.ci, - menu: session.menu, - benchmark: session.benchmark, - yaraReport: session.yaraReport, - }); - for (const [key, value] of Object.entries(context)) { - if (!(key in session.frameworkContext)) { - session.frameworkContext[key] = value; - } - } - } catch { - // Detection failed — SetupScreen or agent will handle it - } - } - - try { - await runAgentWizard(config, session); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = - error instanceof Error && error.stack ? error.stack : undefined; - - logToFile(`[Wizard run.ts] ERROR MESSAGE: ${errorMessage} `); - if (errorStack) { - logToFile(`[Wizard run.ts] ERROR STACK: ${errorStack}`); - } - - const debugInfo = session.debug && errorStack ? `\n\n${errorStack}` : ''; - - await wizardAbort({ - message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${config.metadata.docsUrl} to set up PostHog manually.${debugInfo}`, - error: error as Error, - }); - } -} - -export async function detectIntegration( - installDir: string, -): Promise { - for (const integration of Object.values(Integration)) { - const config = FRAMEWORK_REGISTRY[integration]; - try { - const detected = await Promise.race([ - config.detection.detect({ installDir }), - new Promise((resolve) => - setTimeout(() => resolve(false), DETECTION_TIMEOUT_MS), - ), - ]); - if (detected) { - return integration; - } - } catch { - // Skip frameworks whose detection throws - } - } -} - -async function detectAndResolveIntegration( - session: WizardSession, -): Promise { - if (!session.menu) { - const detectedIntegration = await detectIntegration(session.installDir); - - if (detectedIntegration) { - getUI().setDetectedFramework( - FRAMEWORK_REGISTRY[detectedIntegration].metadata.name, - ); - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'wizard_framework_detected', - integration: detectedIntegration, - framework_name: FRAMEWORK_REGISTRY[detectedIntegration].metadata.name, - }); - return detectedIntegration; - } - - analytics.capture(WIZARD_INTERACTION_EVENT_NAME, { - action: 'wizard_framework_detection_failed', - }); - getUI().log.info( - "I couldn't detect your framework. Please choose one to get started.", - ); - } - - // Fallback: in TUI mode the IntroScreen would handle this, - // but for CI mode or when detection fails, abort with guidance. - return wizardAbort({ - message: - 'Could not auto-detect your framework. Please specify --integration on the command line.', - }); -} diff --git a/src/steps/add-mcp-server-to-clients/index.ts b/src/steps/add-mcp-server-to-clients/index.ts index a46ccff1..fea011dd 100644 --- a/src/steps/add-mcp-server-to-clients/index.ts +++ b/src/steps/add-mcp-server-to-clients/index.ts @@ -1,4 +1,5 @@ import type { Integration } from '../../lib/constants'; +import type { CloudRegion } from '../../utils/types'; import { traceStep } from '../../telemetry'; import { analytics } from '../../utils/analytics'; import { getUI } from '../../ui'; @@ -55,7 +56,7 @@ export const addMCPServerToClientsStep = async ({ integration?: Integration; local?: boolean; ci?: boolean; - cloudRegion?: import('../../utils/types').CloudRegion; + cloudRegion?: CloudRegion; features?: string[]; apiKey?: string; }): Promise => { diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index b5a816f0..61718c03 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -5,7 +5,9 @@ */ import { TaskStatus, type WizardUI, type SpinnerHandle } from './wizard-ui'; -import type { SettingsConflict } from '../lib/agent-interface'; +import type { SettingsConflict } from '../lib/agent/agent-interface'; +import type { WizardReadinessResult } from '../lib/health-checks/readiness.js'; +import type { OutroData } from '../lib/wizard-session'; export class LoggingUI implements WizardUI { intro(message: string): void { @@ -16,6 +18,12 @@ export class LoggingUI implements WizardUI { console.log(`└ ${message}`); } + outroError(data: OutroData): void { + console.log(`✖ ${data.message ?? 'Wizard aborted'}`); + if (data.body) console.log(`│ ${data.body}`); + if (data.docsUrl) console.log(`│ Docs: ${data.docsUrl}`); + } + cancel(message: string): void { console.log(`■ ${message}`); } @@ -77,9 +85,7 @@ export class LoggingUI implements WizardUI { } } - showBlockingOutage( - result: import('../lib/health-checks/readiness.js').WizardReadinessResult, - ): Promise { + showBlockingOutage(result: WizardReadinessResult): Promise { console.log(`▲ Service health issues detected — blocking outage.`); for (const reason of result.reasons) { console.log(`│ ${reason}`); @@ -90,9 +96,7 @@ export class LoggingUI implements WizardUI { return Promise.resolve(); } - setReadinessWarnings( - result: import('../lib/health-checks/readiness.js').WizardReadinessResult, - ): void { + setReadinessWarnings(result: WizardReadinessResult): void { console.log(`▲ Service health warnings detected.`); for (const reason of result.reasons) { console.log(`│ ${reason}`); diff --git a/src/ui/tui/__tests__/flows.test.ts b/src/ui/tui/__tests__/flows.test.ts index 009a2402..e96d81e5 100644 --- a/src/ui/tui/__tests__/flows.test.ts +++ b/src/ui/tui/__tests__/flows.test.ts @@ -14,7 +14,7 @@ describe('FLOWS', () => { describe('Wizard setup predicate', () => { it('hides setup when there are no setup questions', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Setup); + const entry = getEntry(Flow.PostHogIntegration, Screen.Setup); expect(entry.show?.(session)).toBe(false); expect(entry.isComplete?.(session)).toBe(true); @@ -22,7 +22,7 @@ describe('FLOWS', () => { it('shows setup when framework questions are missing answers', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Setup); + const entry = getEntry(Flow.PostHogIntegration, Screen.Setup); session.frameworkConfig = { metadata: { @@ -39,7 +39,7 @@ describe('FLOWS', () => { it('marks setup complete once all required answers are present', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Setup); + const entry = getEntry(Flow.PostHogIntegration, Screen.Setup); session.frameworkConfig = { metadata: { @@ -61,14 +61,14 @@ describe('FLOWS', () => { describe('Wizard health-check predicate', () => { it('stays incomplete before readiness exists', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + const entry = getEntry(Flow.PostHogIntegration, Screen.HealthCheck); expect(entry.isComplete?.(session)).toBe(false); }); it('stays incomplete for blocking readiness until outage is dismissed', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + const entry = getEntry(Flow.PostHogIntegration, Screen.HealthCheck); session.readinessResult = { decision: WizardReadiness.No, @@ -85,7 +85,7 @@ describe('FLOWS', () => { it('completes immediately for non-blocking readiness', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.HealthCheck); + const entry = getEntry(Flow.PostHogIntegration, Screen.HealthCheck); session.readinessResult = { decision: WizardReadiness.YesWithWarnings, @@ -100,7 +100,7 @@ describe('FLOWS', () => { describe('Wizard run predicate', () => { it('stays incomplete while run is idle or running', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Run); + const entry = getEntry(Flow.PostHogIntegration, Screen.Run); session.runPhase = RunPhase.Idle; expect(entry.isComplete?.(session)).toBe(false); @@ -111,7 +111,7 @@ describe('FLOWS', () => { it('completes when run finishes or errors', () => { const session = buildSession({}); - const entry = getEntry(Flow.Wizard, Screen.Run); + const entry = getEntry(Flow.PostHogIntegration, Screen.Run); session.runPhase = RunPhase.Completed; expect(entry.isComplete?.(session)).toBe(true); diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index 0c3b0b58..a4a17c37 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -9,7 +9,7 @@ function baseWizardSession() { describe('WizardRouter', () => { describe('resolve', () => { it('returns the first incomplete visible screen for the wizard flow', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.PostHogIntegration); const session = baseWizardSession(); expect(router.resolve(session)).toBe(Screen.Intro); @@ -31,7 +31,7 @@ describe('WizardRouter', () => { }); it('skips the setup screen when there are no unanswered framework questions', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.PostHogIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -53,7 +53,7 @@ describe('WizardRouter', () => { }); it('returns the last flow screen when every entry is complete', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.PostHogIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -75,7 +75,7 @@ describe('WizardRouter', () => { }); it('gives the topmost overlay precedence over the flow screen', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.PostHogIntegration); const session = baseWizardSession(); router.pushOverlay(Overlay.SettingsOverride); @@ -96,7 +96,7 @@ describe('WizardRouter', () => { }); it('returns the top overlay when overlays are active', () => { - const router = new WizardRouter(Flow.Wizard); + const router = new WizardRouter(Flow.PostHogIntegration); router.pushOverlay(Overlay.ManagedSettings); diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index 17da4a5d..3f22c062 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -77,7 +77,7 @@ describe('WizardStore', () => { it('defaults to Wizard flow', () => { const store = createStore(); - expect(store.router.activeFlow).toBe(Flow.Wizard); + expect(store.router.activeFlow).toBe(Flow.PostHogIntegration); }); it('accepts a custom flow', () => { @@ -162,7 +162,7 @@ describe('WizardStore', () => { // ── Session setters ────────────────────────────────────────────── describe('session setters', () => { - it('completeSetup sets setupConfirmed and resolves setupComplete promise', async () => { + it('completeSetup sets setupConfirmed and resolves intro gate', async () => { const store = createStore(); const cb = jest.fn(); store.subscribe(cb); @@ -170,7 +170,7 @@ describe('WizardStore', () => { store.completeSetup(); expect(store.session.setupConfirmed).toBe(true); - await store.setupComplete; + await store.getGate('intro'); expect(cb).toHaveBeenCalled(); }); @@ -900,7 +900,7 @@ describe('WizardStore', () => { store.completeSetup(); store.completeSetup(); // second call — promise already resolved - await store.setupComplete; + await store.getGate('intro'); expect(store.session.setupConfirmed).toBe(true); }); @@ -967,13 +967,13 @@ describe('WizardStore', () => { }); }); - // ── healthGateComplete promise ──────────────────────────────────── + // ── health-check gate ──────────────────────────────────────────── - describe('healthGateComplete', () => { + describe('health-check gate', () => { it('resolves immediately for non-Wizard flows', async () => { const store = createStore(Flow.McpAdd); - await expect(store.healthGateComplete).resolves.toBeUndefined(); + await expect(store.getGate('health-check')).resolves.toBeUndefined(); }); it('resolves automatically when readiness is non-blocking', async () => { @@ -986,7 +986,7 @@ describe('WizardStore', () => { const store = createStore(); let resolved = false; - void store.healthGateComplete.then(() => { + void store.getGate('health-check').then(() => { resolved = true; }); @@ -1010,7 +1010,7 @@ describe('WizardStore', () => { const store = createStore(); let resolved = false; - void store.healthGateComplete.then(() => { + void store.getGate('health-check').then(() => { resolved = true; }); @@ -1020,7 +1020,7 @@ describe('WizardStore', () => { expect(store.currentScreen).toBe(Screen.Intro); store.dismissOutage(); - await store.healthGateComplete; + await store.getGate('health-check'); expect(resolved).toBe(true); expect(store.session.outageDismissed).toBe(true); @@ -1077,13 +1077,13 @@ describe('WizardStore', () => { }); }); - // ── setupComplete promise ──────────────────────────────────────── + // ── intro gate ────────────────────────────────────────────────── - describe('setupComplete', () => { + describe('intro gate', () => { it('resolves when completeSetup is called', async () => { const store = createStore(); store.completeSetup(); - await store.setupComplete; + await store.getGate('intro'); expect(store.session.setupConfirmed).toBe(true); }); @@ -1091,7 +1091,7 @@ describe('WizardStore', () => { const store = createStore(); let resolved = false; - void store.setupComplete.then(() => { + void store.getGate('intro').then(() => { resolved = true; }); @@ -1100,7 +1100,7 @@ describe('WizardStore', () => { expect(resolved).toBe(false); store.completeSetup(); - await store.setupComplete; + await store.getGate('intro'); expect(resolved).toBe(true); }); }); diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index 14a848c6..d8c14939 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -4,19 +4,25 @@ * Owns the Screen and Flow enums (re-exported by router.ts) to avoid * circular imports between router ↔ flows. * - * Each entry defines a screen, optional visibility predicate, and - * optional completion predicate. The router walks the active flow - * to resolve which screen to show. + * Workflow-based flows are derived from WORKFLOW_REGISTRY via + * workflowToFlowEntries(). MCP add/remove flows are standalone since + * they don't go through the agent runner. */ -import { type WizardSession, RunPhase } from '../../lib/wizard-session.js'; -import { WizardReadiness } from '../../lib/health-checks/readiness.js'; +import type { WizardSession } from '../../lib/wizard-session.js'; +import { + workflowToFlowEntries, + type Workflow, +} from '../../lib/workflows/workflow-step.js'; +import { WORKFLOW_REGISTRY } from '../../lib/workflows/workflow-registry.js'; +import { AGENT_SKILL_STEPS } from '../../lib/workflows/agent-skill/index.js'; // ── Screen + Flow enums ────────────────────────────────────────────── /** Screens that participate in linear flows */ export enum Screen { Intro = 'intro', + RevenueIntro = 'revenue-intro', HealthCheck = 'health-check', Setup = 'setup', Auth = 'auth', @@ -30,7 +36,9 @@ export enum Screen { /** Named flows the router can run */ export enum Flow { - Wizard = 'wizard', + PostHogIntegration = 'posthog-integration', + RevenueAnalyticsSetup = 'revenue-analytics-setup', + AgentSkill = 'agent-skill', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', } @@ -46,59 +54,35 @@ export interface FlowEntry { isComplete?: (session: WizardSession) => boolean; } -/** - * Check if the SetupScreen is needed (unresolved framework questions). - */ -function needsSetup(session: WizardSession): boolean { - const config = session.frameworkConfig; - if (!config?.metadata.setup?.questions) return false; +// ── Derived from WORKFLOW_REGISTRY ─────────────────────────────────── - return config.metadata.setup.questions.some( - (q: { key: string }) => !(q.key in session.frameworkContext), - ); -} +/** Raw workflow step arrays — used by the store for gate/onInit definitions. */ +export const WORKFLOW_STEPS: Partial> = { + ...(Object.fromEntries( + WORKFLOW_REGISTRY.map((c) => [c.flowKey, c.steps]), + ) as Partial>), + [Flow.AgentSkill]: AGENT_SKILL_STEPS, +}; -/** All flow pipelines. Add new screens by appending entries. */ +/** + * All flow pipelines. + * + * Workflow-based flows are derived from the registry. + * MCP add/remove flows are standalone. + */ export const FLOWS: Record = { - [Flow.Wizard]: [ - { - screen: Screen.Intro, - isComplete: (s) => s.setupConfirmed, - }, - { - screen: Screen.HealthCheck, - isComplete: (s) => { - if (!s.readinessResult) return false; - if (s.readinessResult.decision === WizardReadiness.No) - return s.outageDismissed; - return true; - }, - }, - { - screen: Screen.Setup, - show: needsSetup, - isComplete: (s) => !needsSetup(s), - }, - { - screen: Screen.Auth, - isComplete: (s) => s.credentials !== null, - }, - { - screen: Screen.Run, - isComplete: (s) => - s.runPhase === RunPhase.Completed || s.runPhase === RunPhase.Error, - }, - { - screen: Screen.Mcp, - isComplete: (s) => s.mcpComplete, - }, - { - screen: Screen.Outro, - isComplete: (s) => s.outroDismissed, - }, - { screen: Screen.Skills }, - ], + // Derive workflow flows from registry + ...(Object.fromEntries( + WORKFLOW_REGISTRY.map((c) => [ + c.flowKey, + workflowToFlowEntries(c.steps) as FlowEntry[], + ]), + ) as Record), + + // Generic agent skill flow + [Flow.AgentSkill]: workflowToFlowEntries(AGENT_SKILL_STEPS) as FlowEntry[], + // Standalone MCP flows [Flow.McpAdd]: [ { screen: Screen.McpAdd, diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 305e5b33..05e2620d 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -8,7 +8,9 @@ import type { WizardUI, SpinnerHandle } from '../wizard-ui.js'; import type { WizardStore } from './store.js'; -import type { SettingsConflict } from '../../lib/agent-interface.js'; +import type { SettingsConflict } from '../../lib/agent/agent-interface.js'; +import type { WizardReadinessResult } from '../../lib/health-checks/readiness.js'; +import type { OutroData } from '../../lib/wizard-session.js'; import { RunPhase, OutroKind } from '../../lib/wizard-session.js'; // Strip ANSI escape codes (chalk formatting) from strings @@ -41,6 +43,14 @@ export class InkUI implements WizardUI { } } + outroError(data: OutroData): void { + this.store.setOutroData(data); + // Advance router past the run step so the outro screen renders + if (this.store.session.runPhase !== RunPhase.Error) { + this.store.setRunPhase(RunPhase.Error); + } + } + setCredentials(credentials: { accessToken: string; projectApiKey: string; @@ -65,18 +75,14 @@ export class InkUI implements WizardUI { this.store.setLoginUrl(url); } - showBlockingOutage( - result: import('../../lib/health-checks/readiness.js').WizardReadinessResult, - ): Promise { + showBlockingOutage(result: WizardReadinessResult): Promise { // In the TUI, the HealthCheckScreen handles outage display. // This is only called from agent-runner for the CI fallback path. this.store.setReadinessResult(result); return Promise.resolve(); } - setReadinessWarnings( - result: import('../../lib/health-checks/readiness.js').WizardReadinessResult, - ): void { + setReadinessWarnings(result: WizardReadinessResult): void { this.store.setReadinessResult(result); } diff --git a/src/ui/tui/primitives/ProgressList.tsx b/src/ui/tui/primitives/ProgressList.tsx index f7d28d46..42d2baf2 100644 --- a/src/ui/tui/primitives/ProgressList.tsx +++ b/src/ui/tui/primitives/ProgressList.tsx @@ -17,9 +17,16 @@ export interface ProgressItem { interface ProgressListProps { items: ProgressItem[]; title?: string; + workflowLabel?: string | null; + skillId?: string | null; } -export const ProgressList = ({ items, title }: ProgressListProps) => { +export const ProgressList = ({ + items, + title, + workflowLabel, + skillId, +}: ProgressListProps) => { const completed = items.filter((t) => t.status === 'completed').length; const total = items.length; @@ -67,6 +74,12 @@ export const ProgressList = ({ items, title }: ProgressListProps) => { )} + {(workflowLabel || skillId) && ( + + {workflowLabel && workflow: {workflowLabel}} + {skillId && skill: {skillId}} + + )} ); }; diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index 39c4a306..6cc054cc 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -39,7 +39,7 @@ export class WizardRouter { private flowName: Flow; private overlays: Overlay[] = []; - constructor(flowName: Flow = Flow.Wizard) { + constructor(flowName: Flow = Flow.PostHogIntegration) { this.flowName = flowName; this.flow = FLOWS[flowName]; } diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index d1b25467..f0e4e82b 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -18,6 +18,7 @@ import { SettingsOverrideScreen } from './screens/SettingsOverrideScreen.js'; import { ManagedSettingsScreen } from './screens/ManagedSettingsScreen.js'; import { PortConflictScreen } from './screens/PortConflictScreen.js'; import { IntroScreen } from './screens/IntroScreen.js'; +import { RevenueIntroScreen } from './screens/RevenueIntroScreen.js'; import { SetupScreen } from './screens/SetupScreen.js'; import { AuthScreen } from './screens/AuthScreen.js'; import { RunScreen } from './screens/RunScreen.js'; @@ -51,6 +52,7 @@ export function createScreens( // Wizard flow [Screen.Intro]: , + [Screen.RevenueIntro]: , [Screen.HealthCheck]: , [Screen.Setup]: , [Screen.Auth]: , diff --git a/src/ui/tui/screens/ManagedSettingsScreen.tsx b/src/ui/tui/screens/ManagedSettingsScreen.tsx index 0f5278c5..4aa39b0d 100644 --- a/src/ui/tui/screens/ManagedSettingsScreen.tsx +++ b/src/ui/tui/screens/ManagedSettingsScreen.tsx @@ -11,7 +11,7 @@ import { useSyncExternalStore } from 'react'; import type { WizardStore } from '../store.js'; import { ConfirmationInput, ModalOverlay } from '../primitives/index.js'; import { Icons } from '../styles.js'; -import type { SettingsConflict } from '../../../lib/agent-interface.js'; +import type { SettingsConflict } from '../../../lib/agent/agent-interface.js'; function sourceLabel(source: SettingsConflict['source']): string { switch (source) { diff --git a/src/ui/tui/screens/OutroScreen.tsx b/src/ui/tui/screens/OutroScreen.tsx index 82c51c48..19dbb5b6 100644 --- a/src/ui/tui/screens/OutroScreen.tsx +++ b/src/ui/tui/screens/OutroScreen.tsx @@ -39,15 +39,16 @@ export const OutroScreen = ({ store }: OutroScreenProps) => { {outroData.kind === OutroKind.Success && ( - {'\u2714'} Successfully installed PostHog! + {'\u2714'} {outroData.message || 'Done!'} - - - Check ./posthog-setup-report.md for details - about your integration - - + {outroData.reportFile && ( + + + Check ./{outroData.reportFile} for details + + + )} {outroData.changes && outroData.changes.length > 0 && ( @@ -112,6 +113,20 @@ export const OutroScreen = ({ store }: OutroScreenProps) => { {'\u2718'} {outroData.message || 'An error occurred'} + + {outroData.body && ( + + {outroData.body} + + )} + + {outroData.docsUrl && ( + + + Docs: {outroData.docsUrl} + + + )} )} diff --git a/src/ui/tui/screens/RevenueIntroScreen.tsx b/src/ui/tui/screens/RevenueIntroScreen.tsx new file mode 100644 index 00000000..1c2f870a --- /dev/null +++ b/src/ui/tui/screens/RevenueIntroScreen.tsx @@ -0,0 +1,271 @@ +/** + * RevenueIntroScreen — Welcome screen for the revenue analytics flow. + * + * Renders one of two states: + * - Detection succeeded: shows detected SDKs + continue/cancel + * - Detection failed: shows the error + exit prompt + * + * Reads `frameworkContext.detectError` and `frameworkContext.detectedPosthogSdks` + * / `detectedStripeSdks` set by detectRevenuePrerequisites(). + */ + +import path from 'path'; +import { Box, Text } from 'ink'; +import { useSyncExternalStore } from 'react'; +import type { WizardStore } from '../store.js'; +import { PickerMenu } from '../primitives/index.js'; +import { + POSTHOG_SDKS, + STRIPE_SDKS, + type RevenueDetectError, +} from '../../../lib/workflows/revenue-analytics/index.js'; + +interface RevenueIntroScreenProps { + store: WizardStore; +} + +const WizardTitle = () => ( + + {'\u2588'} + {'\u2588'} + {'\u2588'} + {' Revenue Analytics Wizard 💸'} + +); + +const DetectErrorView = ({ error }: { error: RevenueDetectError }) => ( + + + + + + + + {'\u2718'} Cannot set up revenue analytics + + + + + + + process.exit(1)} + /> + +); + +const DetectErrorBody = ({ error }: { error: RevenueDetectError }) => { + switch (error.kind) { + case 'bad-directory': { + const reasonText = { + missing: 'does not exist', + 'not-dir': 'is not a directory', + unreadable: 'could not be accessed', + }[error.reason]; + return ( + <> + This path {reasonText}: + + {' '} + {error.path} + + + ); + } + + case 'no-package-json': + return ( + <> + No package.json found in this directory. + + Revenue analytics currently supports Node.js / TypeScript projects. + + Run this command from your project root. + + ); + + case 'no-sdks': + return ( + <> + + Neither PostHog nor Stripe SDKs detected (scanned{' '} + {error.scannedCount} package.json file + {error.scannedCount === 1 ? '' : 's'}). + + + Revenue analytics requires: + + {' \u2022'} A PostHog SDK ({POSTHOG_SDKS.slice(0, 3).join(', ')}, + …) + + + {' \u2022'} A Stripe SDK ({STRIPE_SDKS.join(', ')}) + + + + + Install Stripe and run npx @posthog/wizard to + set up PostHog. + + + + ); + + case 'missing-posthog': + return ( + <> + + Found Stripe ({error.foundStripe.join(', ')}) but no PostHog SDK. + + + + Run npx @posthog/wizard first to set up the base + PostHog integration. + + + + ); + + case 'missing-stripe': + return ( + <> + + Found PostHog ({error.foundPosthog.join(', ')}) but no Stripe SDK. + + + Revenue analytics currently supports Stripe only. + + + Install one of: + {STRIPE_SDKS.map((sdk) => ( + + {' \u2022'} {sdk} + + ))} + + + ); + } +}; + +export const RevenueIntroScreen = ({ store }: RevenueIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const { session } = store; + const detectError = session.frameworkContext.detectError as + | RevenueDetectError + | undefined; + const detectedPosthogSdks = + (session.frameworkContext.detectedPosthogSdks as string[] | undefined) ?? + []; + const detectedStripeSdks = + (session.frameworkContext.detectedStripeSdks as string[] | undefined) ?? []; + const detectedPackagePaths = + (session.frameworkContext.detectedPackagePaths as string[] | undefined) ?? + []; + + if (detectError) { + return ; + } + + return ( + + + + + + + Let's wire up your revenue dashboards with Stripe! + + + + + + + + Directory {'\u2714'}{' '} + + + {'/'} + {path.basename(session.installDir)} + + + + {detectedPosthogSdks.length > 0 && ( + + + PostHog {'\u2714'}{' '} + + {detectedPosthogSdks.join(', ')} (detected) + + )} + + {detectedStripeSdks.length > 0 && ( + + + Stripe {'\u2714'}{' '} + + {detectedStripeSdks.join(', ')} (detected) + + )} + + {detectedPackagePaths.length > 1 && ( + + + Found in {detectedPackagePaths.length} packages: + + {detectedPackagePaths.map((p) => ( + + {' '} + {'\u2022'} {p} + + ))} + + )} + + + What the wizard will do next: + + {'\u2022'} Links Stripe customers and their purchases to PostHog + persons + + + {'\u2022'} Unlocks analytics like revenue per user, top customers, + and lifetime value + + + + + { + const choice = Array.isArray(value) ? value[0] : value; + if (choice === 'cancel') { + process.exit(0); + } else { + store.completeSetup(); + } + }} + /> + + + + ); +}; diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx index 7ba6ff5c..24a6fee5 100644 --- a/src/ui/tui/screens/RunScreen.tsx +++ b/src/ui/tui/screens/RunScreen.tsx @@ -68,7 +68,14 @@ export const RunScreen = ({ store }: RunScreenProps) => { ) : ( store.setLearnCardComplete()} /> ); - const progressList = ; + const progressList = ( + + ); // On narrow terminals, drop the learn pane and show only progress const statusComponent = diff --git a/src/ui/tui/screens/SettingsOverrideScreen.tsx b/src/ui/tui/screens/SettingsOverrideScreen.tsx index faf1ed0b..ab0ba2c1 100644 --- a/src/ui/tui/screens/SettingsOverrideScreen.tsx +++ b/src/ui/tui/screens/SettingsOverrideScreen.tsx @@ -3,7 +3,7 @@ import { useState, useSyncExternalStore } from 'react'; import type { WizardStore } from '../store.js'; import { ConfirmationInput, ModalOverlay } from '../primitives/index.js'; import { Icons } from '../styles.js'; -import type { SettingsConflictSource } from '../../../lib/agent-interface.js'; +import type { SettingsConflictSource } from '../../../lib/agent/agent-interface.js'; function sourcePath(source: SettingsConflictSource): string { switch (source) { diff --git a/src/ui/tui/screens/health/HealthCheckScreen.tsx b/src/ui/tui/screens/health/HealthCheckScreen.tsx index 4d63e588..42d05a4b 100644 --- a/src/ui/tui/screens/health/HealthCheckScreen.tsx +++ b/src/ui/tui/screens/health/HealthCheckScreen.tsx @@ -21,6 +21,7 @@ import { getBlockingServiceKeys } from '../../../../lib/health-checks/readiness. import { ServiceHealthStatus } from '../../../../lib/health-checks/types.js'; import { wizardAbort } from '../../../../utils/wizard-abort.js'; import { fetchSkillMenu, downloadSkill } from '../../../../lib/wizard-tools.js'; +import { REMOTE_SKILLS_BASE_URL } from '../../../../lib/constants.js'; interface HealthCheckScreenProps { store: WizardStore; @@ -105,9 +106,7 @@ export const HealthCheckScreen = ({ store }: HealthCheckScreenProps) => { const handleDownloadAndExit = async () => { if (downloading) return; setDownloading(true); - const skillsBaseUrl = - 'https://github.com/PostHog/context-mill/releases/latest/download'; - const menu = await fetchSkillMenu(skillsBaseUrl); + const menu = await fetchSkillMenu(REMOTE_SKILLS_BASE_URL); if (menu) { const prefix = `integration-${integration}`; const skills = (menu.categories['integration'] ?? []).filter((s) => diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index 975317fa..119b44b3 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -20,7 +20,7 @@ const FORCE_DARK = BG_BLACK + CLEAR_SCREEN + CURSOR_HOME; export function startTUI( version: string, - flow: Flow = Flow.Wizard, + flow: Flow = Flow.PostHogIntegration, ): { unmount: () => void; store: WizardStore; @@ -51,6 +51,6 @@ export function startTUI( cleanup(); }, store, - waitForSetup: () => store.setupComplete, + waitForSetup: () => store.getGate('intro'), }; } diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 26c9fd75..a55d42ac 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -2,9 +2,12 @@ * WizardStore — Nanostore-backed reactive store for the TUI. * React components subscribe via useSyncExternalStore. * - * Navigation is delegated to WizardRouter. - * The active screen is derived from session state — not imperatively set. - * Overlays (settings-override, port-conflict) are the only imperative navigation. + * The active screen is derived from session state — WizardRouter walks + * the flow and shows the first step whose `isComplete` is still false. + * + * Define a step `gate` if your screen needs to await user interactions. + * bin.ts calls `await store.getGate(stepId)` to pause until the gate + * predicate becomes true. * * All session mutations that affect screen resolution go through * explicit setters so emitChange() is always called. @@ -21,7 +24,8 @@ import { RunPhase, buildSession, } from '../../lib/wizard-session.js'; -import type { SettingsConflict } from '../../lib/agent-interface.js'; +import type { SettingsConflict } from '../../lib/agent/agent-interface.js'; +import type { WizardReadinessResult } from '../../lib/health-checks/readiness.js'; import { WizardRouter, type ScreenName, @@ -30,10 +34,11 @@ import { Flow, } from './router.js'; import { analytics, sessionProperties } from '../../utils/analytics.js'; -import { - evaluateWizardReadiness, - WizardReadiness, -} from '../../lib/health-checks/readiness.js'; +import type { + StoreInitContext, + WorkflowReadyContext, +} from '../../lib/workflows/workflow-step.js'; +import { WORKFLOW_STEPS } from './flows.js'; export { TaskStatus, Screen, Overlay, Flow, RunPhase, McpOutcome }; export type { ScreenName, OutroData, WizardSession }; @@ -51,6 +56,13 @@ export interface PlannedEvent { description: string; } +interface GateEntry { + predicate: (session: WizardSession) => boolean; + promise: Promise; + resolve: () => void; + resolved: boolean; +} + export class WizardStore { // ── Internal nanostore atoms ───────────────────────────────────── private $session = map(buildSession({})); @@ -68,20 +80,14 @@ export class WizardStore { /** Hooks run when transitioning onto a screen. */ private _enterScreenHooks = new Map void)[]>(); + /** Gate promises derived from workflow step definitions. */ + private _gates = new Map(); + version = ''; /** Navigation router — resolves active screen from session state. */ readonly router: WizardRouter; - /** - * Setup promise — IntroScreen resolves this when the user confirms. - * bin.ts awaits it before calling runWizard. - */ - private _resolveSetup!: () => void; - readonly setupComplete: Promise = new Promise((resolve) => { - this._resolveSetup = resolve; - }); - /** Blocks agent execution until the settings-override overlay is dismissed. */ private _resolveSettingsOverride: (() => void) | null = null; private _backupAndFixSettings: (() => boolean) | null = null; @@ -89,47 +95,111 @@ export class WizardStore { /** Blocks OAuth flow until the port-conflict overlay is dismissed. */ private _resolvePortConflict: (() => void) | null = null; + constructor(flow: Flow = Flow.PostHogIntegration) { + this.router = new WizardRouter(flow); + this._initFromWorkflow(flow); + } + /** - * Resolves when the health-check screen is done — either auto-advanced - * (healthy) or user-dismissed (outage). bin.ts awaits this before runWizard(). + * Scan workflow steps for gate predicates and onInit callbacks. + * Creates gate promises and fires init work. */ - private _resolveHealthGate!: () => void; - readonly healthGateComplete: Promise = new Promise((resolve) => { - this._resolveHealthGate = resolve; - }); + private _initFromWorkflow(flow: Flow): void { + const steps = WORKFLOW_STEPS[flow]; + if (!steps) return; + + // Create gate promises from steps that define them + for (const step of steps) { + if (step.gate) { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + this._gates.set(step.id, { + predicate: step.gate, + promise, + resolve, + resolved: false, + }); + } + } - constructor(flow: Flow = Flow.Wizard) { - this.router = new WizardRouter(flow); + // Run onInit callbacks with a minimal context interface. + // Arrow functions capture `this` from _initFromWorkflow so we don't + // need to alias it. + const getSession = (): WizardSession => this.session; + const ctx: StoreInitContext = { + get session() { + return getSession(); + }, + setReadinessResult: (r) => this.setReadinessResult(r), + setFrameworkContext: (k, v) => this.setFrameworkContext(k, v), + emitChange: () => this.emitChange(), + }; + for (const step of steps) { + step.onInit?.(ctx); + } + } - // Fire health check immediately for Wizard flow so results arrive - // while the user is still on IntroScreen. - if (flow === Flow.Wizard) { - this._initHealthCheck(); - } else { - this._resolveHealthGate(); + /** + * Run all `onReady` hooks declared by the current flow's steps, in + * order. Must be called after `store.session = session` so hooks see + * the real installDir. bin.ts calls this generically — it doesn't + * need to know which workflow has which pre-flow work. + */ + async runReadyHooks(): Promise { + const steps = WORKFLOW_STEPS[this.router.activeFlow]; + if (!steps) return; + const ctx: WorkflowReadyContext = { + session: this.session, + setFrameworkContext: (k, v) => this.setFrameworkContext(k, v), + setFrameworkConfig: (i, c) => this.setFrameworkConfig(i, c), + setDetectedFramework: (l) => this.setDetectedFramework(l), + setUnsupportedVersion: (info) => this.setUnsupportedVersion(info), + addDiscoveredFeature: (f) => this.addDiscoveredFeature(f), + setDetectionComplete: () => this.setDetectionComplete(), + }; + for (const step of steps) { + if (step.onReady) { + await step.onReady(ctx); + } } } + // ── Gate API ──────────────────────────────────────────────────── + /** - * Kick off the health check. Stores the result and resolves the - * health gate if non-blocking. + * Get a gate promise by step ID — the primary blocking checkpoint API + * for bin.ts. `await store.getGate('...')` parks the caller until the + * corresponding workflow step's gate predicate flips to true (if the + * predicate stays false, the caller stays parked indefinitely — the + * TUI keeps rendering so the user can resolve whatever is blocking). + * + * If the workflow doesn't define a step with this ID, or the step + * has no `gate` predicate, this returns an already-resolved promise + * so bin.ts flows straight through. This lets workflows opt in to + * gates on a per-step basis without bin.ts needing to know which + * gates exist in which flow. */ - private _initHealthCheck(): void { - evaluateWizardReadiness() - .then((readiness) => { - this.setReadinessResult(readiness); - if (readiness.decision !== WizardReadiness.No) { - this._resolveHealthGate(); - } - }) - .catch(() => { - this.setReadinessResult({ - decision: WizardReadiness.Yes, - health: {} as never, - reasons: [], - }); - this._resolveHealthGate(); - }); + getGate(stepId: string): Promise { + return this._gates.get(stepId)?.promise ?? Promise.resolve(); + } + + /** + * Re-evaluate every gate predicate against the current session and + * resolve any whose predicate now returns true. Called after every + * emitChange(), so gates unblock as soon as the session mutation + * that satisfies them lands. Gates only resolve once — a predicate + * that goes true → false → true will NOT re-block a caller that + * already awaited through. + */ + private _checkGates(): void { + for (const [, gate] of this._gates) { + if (!gate.resolved && gate.predicate(this.session)) { + gate.resolved = true; + gate.resolve(); + } + } } // ── State accessors (read from atoms) ──────────────────────────── @@ -174,11 +244,10 @@ export class WizardStore { // Every setter that affects screen resolution calls emitChange(). // Business logic calls these instead of mutating session directly. - /** Unblocks bin.ts via the setupComplete promise. */ + /** Sets setupConfirmed. Gate resolves via _checkGates(). */ completeSetup(): void { this.$session.setKey('setupConfirmed', true); analytics.wizardCapture('setup confirmed', sessionProperties(this.session)); - this._resolveSetup(); this.emitChange(); } @@ -229,19 +298,14 @@ export class WizardStore { this.emitChange(); } - setReadinessResult( - result: - | import('../../lib/health-checks/readiness.js').WizardReadinessResult - | null, - ): void { + setReadinessResult(result: WizardReadinessResult | null): void { this.$session.setKey('readinessResult', result); this.emitChange(); } - /** User dismissed the blocking outage screen. Unblocks bin.ts. */ + /** User dismissed the blocking outage screen. Gate resolves via _checkGates(). */ dismissOutage(): void { this.$session.setKey('outageDismissed', true); - this._resolveHealthGate(); this.emitChange(); } @@ -402,10 +466,12 @@ export class WizardStore { /** * Notify React that state has changed. * The router re-resolves the active screen on next render. + * Gate predicates are checked and resolved if ready. */ emitChange(): void { this.router._setDirection('push'); this.$version.set(this.$version.get() + 1); + this._checkGates(); this._detectTransition(); } diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 09bfb564..986ca2db 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -8,7 +8,9 @@ * Session-mutating methods trigger reactive screen resolution in the TUI. */ -import type { SettingsConflict } from '../lib/agent-interface'; +import type { SettingsConflict } from '../lib/agent/agent-interface'; +import type { WizardReadinessResult } from '../lib/health-checks/readiness.js'; +import type { OutroData } from '../lib/wizard-session'; export enum TaskStatus { Pending = 'pending', @@ -25,7 +27,15 @@ export interface SpinnerHandle { export interface WizardUI { // ── Lifecycle messages ──────────────────────────────────────────── intro(message: string): void; + /** Success outro with a plain text message. */ outro(message: string): void; + /** + * Error outro. Sets structured outroData and transitions run phase so + * the router advances to the outro screen. Use for abort/failure paths + * that need a custom error render — do NOT build the outroData by + * mutating session directly (nanostore holds a shallow copy). + */ + outroError(data: OutroData): void; cancel(message: string): void; // ── Logging ─────────────────────────────────────────────────────── @@ -56,14 +66,10 @@ export interface WizardUI { }): void; /** Show blocking service outage (pushes outage overlay in TUI). Blocks until dismissed. */ - showBlockingOutage( - result: import('../lib/health-checks/readiness.js').WizardReadinessResult, - ): Promise; + showBlockingOutage(result: WizardReadinessResult): Promise; /** Store non-blocking readiness warnings (shown as Health tab in RunScreen). */ - setReadinessWarnings( - result: import('../lib/health-checks/readiness.js').WizardReadinessResult, - ): void; + setReadinessWarnings(result: WizardReadinessResult): void; /** Warn that another process is blocking the OAuth port (pushes overlay in TUI). */ showPortConflict(processInfo: { diff --git a/src/utils/file-utils.ts b/src/utils/file-utils.ts index 8c35550e..02e38692 100644 --- a/src/utils/file-utils.ts +++ b/src/utils/file-utils.ts @@ -14,3 +14,35 @@ export function getDotGitignore({ return undefined; } + +/** + * Directory names to skip when recursively scanning a project tree. + * Used by detection logic (e.g. finding all package.json files) to avoid + * dependency directories, build output, virtual environments, etc. + * + * For fast-glob `ignore` patterns, map this to `**\//**`. + */ +export const IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + '.next', + '.nuxt', + '.svelte-kit', + '.turbo', + '.cache', + '.parcel-cache', + 'dist', + 'build', + 'out', + 'coverage', + '.coverage', + 'venv', + '.venv', + '__pycache__', + '.pytest_cache', + 'vendor', + 'target', + '.gradle', + '.idea', + '.vscode', +]); diff --git a/src/utils/wizard-abort.ts b/src/utils/wizard-abort.ts index 2caf4c7c..8acd28d2 100644 --- a/src/utils/wizard-abort.ts +++ b/src/utils/wizard-abort.ts @@ -8,6 +8,7 @@ */ import { analytics } from './analytics'; import { getUI } from '../ui'; +import { OutroKind, type OutroData } from '../lib/wizard-session'; export class WizardError extends Error { constructor( @@ -21,6 +22,8 @@ export class WizardError extends Error { interface WizardAbortOptions { message?: string; + /** Structured error data. Renders via `outroError` instead of `outro`. */ + outroData?: OutroData; error?: Error | WizardError; exitCode?: number; } @@ -40,6 +43,7 @@ export async function wizardAbort( ): Promise { const { message = 'Wizard setup cancelled.', + outroData, error, exitCode = 1, } = options ?? {}; @@ -63,8 +67,9 @@ export async function wizardAbort( // 3. Shutdown analytics await analytics.shutdown(error ? 'error' : 'cancelled'); - // 4. Display message to user - getUI().outro(message); + // 4. Render the error outro. Synthesize OutroData from `message` + // when the caller didn't provide structured data. + getUI().outroError(outroData ?? { kind: OutroKind.Error, message }); // 5. Exit (fires 'exit' event so TUI cleanup runs) return process.exit(exitCode);