From c046e360d0f38e1e875391b0ddd868589aebf3a2 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Tue, 14 Apr 2026 11:22:47 -0400 Subject: [PATCH 1/3] Allow abort by wizard --- bin.ts | 6 ++- src/lib/agent-interface.ts | 52 ++++++++++++++++++++++++++ src/lib/revenue-runner.ts | 6 ++- src/lib/skill-runner.ts | 37 +++++++++++++++++- src/lib/wizard-session.ts | 4 ++ src/lib/workflows/revenue-analytics.ts | 45 +++++++++++++++++++++- src/ui/logging-ui.ts | 7 ++++ src/ui/tui/ink-ui.ts | 9 +++++ src/ui/tui/screens/OutroScreen.tsx | 14 +++++++ src/ui/wizard-ui.ts | 9 +++++ 10 files changed, 183 insertions(+), 6 deletions(-) diff --git a/bin.ts b/bin.ts index fa2846f8..7ebd8655 100644 --- a/bin.ts +++ b/bin.ts @@ -654,7 +654,11 @@ yargs(hideBin(process.argv)) resolve(); } }); - process.exit(0); + // Exit 1 if the outro ended up in an error state (e.g. agent abort) + const { OutroKind } = await import('./src/lib/wizard-session.js'); + const exitCode = + tui.store.session.outroData?.kind === OutroKind.Error ? 1 : 0; + process.exit(exitCode); } catch (err) { if (process.env.DEBUG || process.env.POSTHOG_WIZARD_DEBUG) { console.error('TUI init failed:', err); // eslint-disable-line no-console diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 1dca0cce..1652a8dc 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -67,6 +67,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 +96,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 = [ @@ -817,6 +825,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 +854,7 @@ export async function runAgent( const response = query({ prompt: createPromptStream(), options: { + abortController, model: agentConfig.model, cwd: agentConfig.workingDirectory, permissionMode: 'acceptEdits', @@ -1017,6 +1032,29 @@ 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 (!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 +1088,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 +1138,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/revenue-runner.ts b/src/lib/revenue-runner.ts index 38e9f9d6..323b2390 100644 --- a/src/lib/revenue-runner.ts +++ b/src/lib/revenue-runner.ts @@ -2,11 +2,12 @@ * Revenue analytics wizard runner. * * Thin config wrapper around the generic skill bootstrap runner. - * The revenue workflow's detect step has already verified prerequisites - * (PostHog + Stripe); the bootstrap runner handles skill install + agent run. + * All revenue-specific logic (prerequisite detection, abort handling, + * SDK lists, error types) lives in `workflows/revenue-analytics.ts`. */ import { runSkillBootstrap } from './skill-runner'; +import { revenueAbortToOutro } from './workflows/revenue-analytics'; import type { WizardSession } from './wizard-session'; export async function runRevenueWizard(session: WizardSession): Promise { @@ -19,5 +20,6 @@ export async function runRevenueWizard(session: WizardSession): Promise { docsUrl: 'https://posthog.com/docs/revenue-analytics', spinnerMessage: 'Setting up revenue analytics...', estimatedDurationMinutes: 5, + onAbort: revenueAbortToOutro, }); } diff --git a/src/lib/skill-runner.ts b/src/lib/skill-runner.ts index c8cb3889..8bae3c9b 100644 --- a/src/lib/skill-runner.ts +++ b/src/lib/skill-runner.ts @@ -8,7 +8,11 @@ import { existsSync } from 'fs'; import { join } from 'path'; -import { type WizardSession, OutroKind } from './wizard-session'; +import { + type WizardSession, + type OutroData, + OutroKind, +} from './wizard-session'; import { getOrAskForProjectData } from '../utils/setup-utils'; import { analytics } from '../utils/analytics'; import { getUI } from '../ui'; @@ -59,6 +63,16 @@ export interface SkillBootstrapConfig { spinnerMessage: string; /** Estimated duration in minutes */ estimatedDurationMinutes: number; + /** + * Optional abort handler. When the agent emits `[ABORT] `, the + * middleware force-kills the SDK query and this callback is invoked to + * produce the full OutroData the error screen will render. The workflow + * owns the entire rendering — no merging, no defaults from the runner. + * + * Must return `kind: OutroKind.Error` (the TypeScript type enforces it). + * If not specified, aborts fall through to a generic error outro. + */ + onAbort?: (reason: string) => OutroData; } function sessionToOptions(session: WizardSession): WizardOptions { @@ -240,6 +254,27 @@ export async function runSkillBootstrap( }); } + // Agent emitted [ABORT] — the workflow's onAbort owns the full error + // outro rendering. bin.ts waits for outroDismissed then exits with + // code 1 based on outroData.kind. + if (agentResult.error === AgentErrorType.ABORT) { + const reason = agentResult.message ?? 'Unknown reason'; + logToFile(`[skill-runner] abort: ${reason}`); + analytics.wizardCapture('agent aborted', { + integration: config.integrationLabel, + reason, + }); + const outroData: OutroData = config.onAbort?.(reason) ?? { + kind: OutroKind.Error, + message: `${config.integrationLabel} setup aborted`, + body: reason, + docsUrl: config.docsUrl, + }; + getUI().outroError(outroData); + await analytics.shutdown('error'); + return; + } + // Outro — check if agent wrote the report const continueUrl = session.signup ? `${getCloudUrlFromRegion(cloudRegion)}/products?source=wizard` diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 195fc2fc..5c99583c 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -73,7 +73,11 @@ 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; diff --git a/src/lib/workflows/revenue-analytics.ts b/src/lib/workflows/revenue-analytics.ts index d0191e7f..173a200a 100644 --- a/src/lib/workflows/revenue-analytics.ts +++ b/src/lib/workflows/revenue-analytics.ts @@ -6,8 +6,8 @@ */ import type { Workflow } from '../workflow-step.js'; -import type { WizardSession } from '../wizard-session.js'; -import { RunPhase } from '../wizard-session.js'; +import type { WizardSession, OutroData } from '../wizard-session.js'; +import { RunPhase, OutroKind } from '../wizard-session.js'; import type { Dirent } from 'fs'; import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; import { join, relative } from 'path'; @@ -49,6 +49,47 @@ export type RevenueDetectError = | { kind: 'missing-posthog'; foundStripe: string[] } | { kind: 'missing-stripe'; foundPosthog: string[] }; +/** + * Map an `[ABORT] ` message from the agent into the full OutroData + * the error screen renders. The skill runner uses this as-is — no merging, + * no runner defaults. The workflow owns the rendering. + */ +export function revenueAbortToOutro(reason: string): OutroData { + const r = reason.toLowerCase(); + + if (r.includes('distinct_id') || r.includes('distinct id')) { + return { + kind: OutroKind.Error, + 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', + }; + } + + if (r.includes('stripe')) { + return { + kind: OutroKind.Error, + 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', + }; + } + + // Fallback for unknown revenue abort reasons + return { + kind: OutroKind.Error, + message: 'Revenue analytics setup aborted', + body: reason, + 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. diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index de27ecc6..69200e63 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -7,6 +7,7 @@ import { TaskStatus, type WizardUI, type SpinnerHandle } from './wizard-ui'; import type { SettingsConflict } from '../lib/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 { @@ -17,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}`); } diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 8b55f7ed..3f6170e4 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -10,6 +10,7 @@ import type { WizardUI, SpinnerHandle } from '../wizard-ui.js'; import type { WizardStore } from './store.js'; import type { SettingsConflict } from '../../lib/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 @@ -42,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; diff --git a/src/ui/tui/screens/OutroScreen.tsx b/src/ui/tui/screens/OutroScreen.tsx index b16a6e9b..19dbb5b6 100644 --- a/src/ui/tui/screens/OutroScreen.tsx +++ b/src/ui/tui/screens/OutroScreen.tsx @@ -113,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/wizard-ui.ts b/src/ui/wizard-ui.ts index 0853790f..736f4e4b 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -10,6 +10,7 @@ import type { SettingsConflict } from '../lib/agent-interface'; import type { WizardReadinessResult } from '../lib/health-checks/readiness.js'; +import type { OutroData } from '../lib/wizard-session'; export enum TaskStatus { Pending = 'pending', @@ -26,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 ─────────────────────────────────────────────────────── From 00436c20ac84b578620718b4fc3431cf0b8f588a Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Tue, 14 Apr 2026 11:43:07 -0400 Subject: [PATCH 2/3] Tweak for cleaner interface --- src/lib/revenue-runner.ts | 6 +-- src/lib/skill-runner.ts | 74 ++++++++++++++++++++------ src/lib/workflows/revenue-analytics.ts | 67 ++++++++++------------- 3 files changed, 88 insertions(+), 59 deletions(-) diff --git a/src/lib/revenue-runner.ts b/src/lib/revenue-runner.ts index 323b2390..e57fa6f8 100644 --- a/src/lib/revenue-runner.ts +++ b/src/lib/revenue-runner.ts @@ -2,12 +2,12 @@ * Revenue analytics wizard runner. * * Thin config wrapper around the generic skill bootstrap runner. - * All revenue-specific logic (prerequisite detection, abort handling, + * All revenue-specific logic (prerequisite detection, abort cases, * SDK lists, error types) lives in `workflows/revenue-analytics.ts`. */ import { runSkillBootstrap } from './skill-runner'; -import { revenueAbortToOutro } from './workflows/revenue-analytics'; +import { REVENUE_ABORT_CASES } from './workflows/revenue-analytics'; import type { WizardSession } from './wizard-session'; export async function runRevenueWizard(session: WizardSession): Promise { @@ -20,6 +20,6 @@ export async function runRevenueWizard(session: WizardSession): Promise { docsUrl: 'https://posthog.com/docs/revenue-analytics', spinnerMessage: 'Setting up revenue analytics...', estimatedDurationMinutes: 5, - onAbort: revenueAbortToOutro, + abortCases: REVENUE_ABORT_CASES, }); } diff --git a/src/lib/skill-runner.ts b/src/lib/skill-runner.ts index 8bae3c9b..74ef50ae 100644 --- a/src/lib/skill-runner.ts +++ b/src/lib/skill-runner.ts @@ -43,6 +43,25 @@ import { getSkillsBaseUrl } from './constants'; import { installSkillById, type InstallSkillResult } from './wizard-tools'; import type { WizardOptions } from '../utils/types'; +/** + * A single abort case. Workflows declare a list of these; when the agent + * emits `[ABORT] `, the resolver picks the first case whose `match` + * regex hits the reason, and renders its contents on the error outro. + * + * Flat, declarative, data-only — no callbacks. Keeps workflow-specific + * error catalogs readable (just a list of {match, message, body, docs}). + */ +export interface AbortCase { + /** Regex tested against the raw abort reason. First match wins. */ + match: RegExp; + /** Red headline on the error outro. */ + message: string; + /** Prose body under the headline. */ + body: string; + /** Optional "Docs: " link. */ + docsUrl?: string; +} + /** * Configuration for a skill-based workflow. */ @@ -57,22 +76,45 @@ export interface SkillBootstrapConfig { successMessage: string; /** Report file the agent should write */ reportFile: string; - /** Docs URL for the outro */ + /** Docs URL for the success outro */ docsUrl: string; /** Spinner message during agent run */ spinnerMessage: string; /** Estimated duration in minutes */ estimatedDurationMinutes: number; /** - * Optional abort handler. When the agent emits `[ABORT] `, the - * middleware force-kills the SDK query and this callback is invoked to - * produce the full OutroData the error screen will render. The workflow - * owns the entire rendering — no merging, no defaults from the runner. - * - * Must return `kind: OutroKind.Error` (the TypeScript type enforces it). - * If not specified, aborts fall through to a generic error outro. + * Ordered list of abort cases. When the agent emits `[ABORT] `, + * the first case whose `match` regex hits renders on the error outro. + * If no case matches, a generic fallback is shown with the raw reason. */ - onAbort?: (reason: string) => OutroData; + abortCases?: AbortCase[]; +} + +/** + * Resolve an abort reason against a workflow's declared abort cases. + * Returns full OutroData for the error screen. + */ +function resolveAbort( + reason: string, + cases: AbortCase[] | undefined, + fallback: { integrationLabel: string; docsUrl: string }, +): OutroData { + for (const c of cases ?? []) { + if (c.match.test(reason)) { + return { + kind: OutroKind.Error, + message: c.message, + body: c.body, + docsUrl: c.docsUrl, + }; + } + } + return { + kind: OutroKind.Error, + message: `${fallback.integrationLabel} setup aborted`, + body: reason, + docsUrl: fallback.docsUrl, + }; } function sessionToOptions(session: WizardSession): WizardOptions { @@ -254,9 +296,9 @@ export async function runSkillBootstrap( }); } - // Agent emitted [ABORT] — the workflow's onAbort owns the full error - // outro rendering. bin.ts waits for outroDismissed then exits with - // code 1 based on outroData.kind. + // Agent emitted [ABORT] — resolve against the workflow's declared + // abort cases. bin.ts waits for outroDismissed then exits with code 1 + // based on outroData.kind. if (agentResult.error === AgentErrorType.ABORT) { const reason = agentResult.message ?? 'Unknown reason'; logToFile(`[skill-runner] abort: ${reason}`); @@ -264,12 +306,10 @@ export async function runSkillBootstrap( integration: config.integrationLabel, reason, }); - const outroData: OutroData = config.onAbort?.(reason) ?? { - kind: OutroKind.Error, - message: `${config.integrationLabel} setup aborted`, - body: reason, + const outroData = resolveAbort(reason, config.abortCases, { + integrationLabel: config.integrationLabel, docsUrl: config.docsUrl, - }; + }); getUI().outroError(outroData); await analytics.shutdown('error'); return; diff --git a/src/lib/workflows/revenue-analytics.ts b/src/lib/workflows/revenue-analytics.ts index 173a200a..fb77e6a0 100644 --- a/src/lib/workflows/revenue-analytics.ts +++ b/src/lib/workflows/revenue-analytics.ts @@ -6,8 +6,9 @@ */ import type { Workflow } from '../workflow-step.js'; -import type { WizardSession, OutroData } from '../wizard-session.js'; -import { RunPhase, OutroKind } from '../wizard-session.js'; +import type { AbortCase } from '../skill-runner.js'; +import type { WizardSession } from '../wizard-session.js'; +import { RunPhase } from '../wizard-session.js'; import type { Dirent } from 'fs'; import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; import { join, relative } from 'path'; @@ -50,45 +51,33 @@ export type RevenueDetectError = | { kind: 'missing-stripe'; foundPosthog: string[] }; /** - * Map an `[ABORT] ` message from the agent into the full OutroData - * the error screen renders. The skill runner uses this as-is — no merging, - * no runner defaults. The workflow owns the rendering. + * Abort cases the revenue analytics workflow knows how to render. The + * skill runner matches the agent's `[ABORT] ` against these in + * order and renders the first hit on the error outro. Unmatched aborts + * fall through to the skill runner's generic fallback. */ -export function revenueAbortToOutro(reason: string): OutroData { - const r = reason.toLowerCase(); - - if (r.includes('distinct_id') || r.includes('distinct id')) { - return { - kind: OutroKind.Error, - 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', - }; - } - - if (r.includes('stripe')) { - return { - kind: OutroKind.Error, - 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', - }; - } - - // Fallback for unknown revenue abort reasons - return { - kind: OutroKind.Error, - message: 'Revenue analytics setup aborted', - body: reason, +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), From aa640479445f19812b894204afb2e932b311cee2 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Tue, 14 Apr 2026 13:38:33 -0400 Subject: [PATCH 3/3] handle ci mode --- bin.ts | 301 +++++++++++------- src/__tests__/wizard-abort.test.ts | 59 +++- src/lib/agent/agent-runner.ts | 17 +- src/lib/workflows/revenue-analytics/detect.ts | 7 +- src/utils/wizard-abort.ts | 9 +- 5 files changed, 244 insertions(+), 149 deletions(-) diff --git a/bin.ts b/bin.ts index 39d382b3..28612723 100644 --- a/bin.ts +++ b/bin.ts @@ -24,6 +24,7 @@ import { getUI, setUI } from './src/ui'; import { LoggingUI } from './src/ui/logging-ui'; 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 () => { @@ -181,133 +182,54 @@ const cli = 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.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 PostHog 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 } = await import( - './src/utils/debug.js' + 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 { posthogIntegrationConfig } = await import( - './src/lib/workflows/posthog-integration/index.js' - ); const { wizardAbort } = await import('./src/utils/wizard-abort.js'); - const { logToFile } = await import('./src/utils/debug.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, - benchmark: options.benchmark as boolean | undefined, - yaraReport: options.yaraReport as boolean | undefined, - projectId: options.projectId as string | undefined, - ...env, - }); - getUI().intro('Welcome to the PostHog setup wizard'); - getUI().log.info('Running in CI mode'); - - // Detect framework - const integration = - session.integration ?? (await detectFramework(installDir)); - if (!integration) { - return wizardAbort({ - message: - 'Could not auto-detect your framework. Please specify --integration on the command line.', - }); - } - session.integration = integration; - analytics.setTag('integration', integration); - - const frameworkConfig = FRAMEWORK_REGISTRY[integration]; - session.frameworkConfig = frameworkConfig; - - // Gather context - const context = await gatherFrameworkContext(frameworkConfig, { - 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; + // 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); - try { - const { runAgent } = await import( - './src/lib/agent/agent-runner.js' - ); - await runAgent(posthogIntegrationConfig, 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}` : ''; - await wizardAbort({ - message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${frameworkConfig.metadata.docsUrl} to set up PostHog manually.${debugInfo}`, - error: error as Error, + 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 @@ -481,7 +403,14 @@ for (const wfConfig of getSubcommandWorkflows()) { wfConfig.command!, wfConfig.description, (y) => y.options(skillSubcommandOptions), - (argv) => runWizard(wfConfig, { ...argv }), + (argv) => { + const options = { ...argv }; + if (options.ci) { + runWizardCI(wfConfig, options); + } else { + runWizard(wfConfig, options); + } + }, ); } @@ -553,11 +482,7 @@ function runWizard( resolve(); } }); - // Exit 1 if the outro ended up in an error state (e.g. agent abort) - const { OutroKind } = await import('./src/lib/wizard-session.js'); - const exitCode = - tui.store.session.outroData?.kind === OutroKind.Error ? 1 : 0; - process.exit(exitCode); + 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 @@ -565,3 +490,137 @@ function runWizard( } })(); } + +/** + * 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__/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/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index beb834db..1e2172ee 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -64,10 +64,8 @@ export type { PromptContext }; export type { Credentials }; /** - * An abort case the workflow knows how to render. The runner matches the - * agent's `[ABORT] ` against `match` in order and renders the - * first hit on the error outro. Unmatched aborts fall through to a - * generic fallback. + * A known `[ABORT] ` case. First matching entry is rendered on + * the error outro; unmatched aborts use a generic fallback. */ export interface AbortCase { match: RegExp; @@ -342,9 +340,14 @@ export async function runWorkflow( reason, matched: matched?.message ?? null, }); - getUI().outroError(outroData); - await analytics.shutdown('success'); - return; + await wizardAbort({ + outroData, + error: new WizardError(`Agent aborted: ${reason}`, { + integration: config.integrationLabel, + error_type: AgentErrorType.ABORT, + reason, + }), + }); } if (agentResult.error === AgentErrorType.MCP_MISSING) { diff --git a/src/lib/workflows/revenue-analytics/detect.ts b/src/lib/workflows/revenue-analytics/detect.ts index 26f2365d..b914f419 100644 --- a/src/lib/workflows/revenue-analytics/detect.ts +++ b/src/lib/workflows/revenue-analytics/detect.ts @@ -48,12 +48,7 @@ export type RevenueDetectError = | { kind: 'missing-posthog'; foundStripe: string[] } | { kind: 'missing-stripe'; foundPosthog: string[] }; -/** - * Abort cases the revenue analytics workflow knows how to render. The - * skill runner matches the agent's `[ABORT] ` against these in - * order and renders the first hit on the error outro. Unmatched aborts - * fall through to the skill runner's generic fallback. - */ +/** `[ABORT] ` cases the revenue analytics skill can emit. */ export const REVENUE_ABORT_CASES: AbortCase[] = [ { // Skill emits: [ABORT] Could not find a PostHog distinct_id 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);