diff --git a/bin.ts b/bin.ts index fa328cc3..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); + } + }, ); } @@ -561,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-interface.ts b/src/lib/agent/agent-interface.ts index f9c9a57d..5b4948f1 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -72,6 +72,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 */ @@ -95,6 +101,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 = [ @@ -822,6 +830,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 @@ -845,6 +859,7 @@ export async function runAgent( const response = query({ prompt: createPromptStream(), options: { + abortController, model: agentConfig.model, cwd: agentConfig.workingDirectory, permissionMode: 'acceptEdits', @@ -1022,6 +1037,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' && @@ -1055,6 +1093,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 @@ -1098,6 +1143,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-runner.ts b/src/lib/agent/agent-runner.ts index 2f7be6f7..1e2172ee 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -63,6 +63,17 @@ export type { PromptContext }; 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. * @@ -88,6 +99,8 @@ export interface WorkflowRun { 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. */ @@ -306,6 +319,37 @@ export async function runWorkflow( ); // 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: diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 81bed22c..2a049439 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -80,7 +80,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/detect.ts b/src/lib/workflows/revenue-analytics/detect.ts index e1a975f6..b914f419 100644 --- a/src/lib/workflows/revenue-analytics/detect.ts +++ b/src/lib/workflows/revenue-analytics/detect.ts @@ -10,6 +10,7 @@ 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', @@ -47,6 +48,30 @@ export type RevenueDetectError = | { 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. diff --git a/src/lib/workflows/revenue-analytics/index.ts b/src/lib/workflows/revenue-analytics/index.ts index 64e0380b..f5a7ea07 100644 --- a/src/lib/workflows/revenue-analytics/index.ts +++ b/src/lib/workflows/revenue-analytics/index.ts @@ -1,5 +1,6 @@ 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', @@ -15,6 +16,7 @@ export const revenueAnalyticsConfig: WorkflowConfig = { docsUrl: 'https://posthog.com/docs/revenue-analytics', spinnerMessage: 'Setting up revenue analytics...', estimatedDurationMinutes: 5, + abortCases: REVENUE_ABORT_CASES, }, requires: ['posthog-integration'], }; diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index 1fc10aa8..61718c03 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/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 85627ef1..05e2620d 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/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 aabbb3cd..986ca2db 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -10,6 +10,7 @@ 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', @@ -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 ─────────────────────────────────────────────────────── 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);