From 6b6345e71302ddf8041dc48039e636e94e73c23b Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 3 Oct 2026 16:39:58 -0400 Subject: [PATCH] refactor: route the cli, e2e harness and examples through public entries Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- bin.ts | 1 + docs/examples/run-agent-quack.ts | 49 ++++------ docs/examples/run-program-quack.ts | 95 ++++++++++--------- e2e-harness/action-registry.ts | 93 ++++++++++-------- e2e-harness/e2e-profile.ts | 92 ++++++++++-------- e2e-harness/e2e-result.ts | 10 +- e2e-harness/profiles.ts | 81 ++++++++-------- e2e-harness/tui-capture.ts | 1 + e2e-harness/wizard-ci-driver.ts | 22 ++--- e2e-tests/mocks/preload.ts | 8 ++ e2e-tests/utils/index.ts | 24 ++++- main.ts | 54 +---------- scripts/mcp-install-smoke-test.ts | 4 +- scripts/smoke-test.sh | 2 +- scripts/warlock-smoke-test.ts | 3 +- src/cli/commands/ai-observability.ts | 2 +- src/cli/commands/audit.ts | 8 +- .../commands/basic-integration/ci-install.ts | 36 ++++--- src/cli/commands/basic-integration/index.ts | 2 +- .../commands/basic-integration/interactive.ts | 2 +- .../basic-integration/non-interactive.ts | 6 +- .../commands/basic-integration/playground.ts | 5 +- src/cli/commands/basic-integration/skill.ts | 2 +- src/cli/commands/dispatch-family.ts | 14 +-- src/cli/commands/error-tracking.ts | 2 +- .../factories/family-command-factory.ts | 2 +- src/cli/commands/factories/family-picker.ts | 75 +++------------ src/cli/commands/mcp-analytics.ts | 2 +- src/cli/commands/metrics.ts | 2 +- src/cli/commands/migrate.ts | 2 +- src/cli/commands/replay-vision.ts | 2 +- src/cli/commands/revenue.ts | 2 +- src/cli/commands/self-driving.ts | 2 +- src/cli/commands/upload-sourcemaps.ts | 2 +- src/cli/commands/warehouse.ts | 2 +- src/cli/index.ts | 41 ++++++++ src/cli/wizard.ts | 13 ++- vitest.config.ts | 44 ++++++--- 38 files changed, 413 insertions(+), 396 deletions(-) create mode 100644 e2e-tests/mocks/preload.ts create mode 100644 src/cli/index.ts diff --git a/bin.ts b/bin.ts index de087491b..7fe676e3f 100644 --- a/bin.ts +++ b/bin.ts @@ -34,4 +34,5 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { } // Loaded dynamically so no dependency is evaluated before the check above. +// eslint-disable-next-line no-restricted-syntax -- main.ts is bin.ts's own layer. await import('./main'); diff --git a/docs/examples/run-agent-quack.ts b/docs/examples/run-agent-quack.ts index 1d92a179a..b3e3c0447 100644 --- a/docs/examples/run-agent-quack.ts +++ b/docs/examples/run-agent-quack.ts @@ -6,11 +6,7 @@ // Needs local PostHog on :8010 (with its ai-gateway) and context-mill on :8765. // POSTHOG_PERSONAL_API_KEY logs in. WIZARD_CI_GATEWAY_TOKEN_FILE holds the gateway token. // QUACK_INSTALL_DIR sets the project the agent runs in (default: the current directory). -import { - configureGatewayFromCIEnvironment, - runAgent, - RunOutcome, -} from '@agent'; +import { runAgent, RunOutcome } from '@agent'; import type { RunConfig, RunInput } from '@agent/types'; import { Harness, @@ -18,8 +14,9 @@ import { Sequence, getSkillsBaseUrl, } from '@shared/constants'; +import { resolveApiKeyProject } from '@shared/api-key-login'; +import { readCiGatewayCredential } from '@shared/ci-gateway'; import { initLocalDev, POSTHOG_LOCAL_URL } from '@shared/local-dev'; -import { getOrAskForProjectData } from '@tui/auth/project-data'; // Point PostHog, skills and MCP at the local stack, like --local-posthog --local-context-mill --local-mcp. initLocalDev({ localPosthog: true, localContextMill: true, localMcp: true }); @@ -28,16 +25,11 @@ initLocalDev({ localPosthog: true, localContextMill: true, localMcp: true }); const apiKey = process.env.POSTHOG_PERSONAL_API_KEY; if (!apiKey) throw new Error('Set POSTHOG_PERSONAL_API_KEY'); const programId = 'posthog-integration'; // a program the local gateway admits -const login = await getOrAskForProjectData({ - signup: false, - ci: true, // with apiKey, this skips OAuth - apiKey, +const { host, project, apiUser } = await resolveApiKeyProject(apiKey, { baseUrl: POSTHOG_LOCAL_URL, localMcp: true, - programId, + onWarning: (message) => console.warn(message), }); -// Use the token in WIZARD_CI_GATEWAY_TOKEN_FILE at WIZARD_CI_GATEWAY_URL instead of minting one. -configureGatewayFromCIEnvironment(login.projectId, 'us'); // What the agent runs: one prompt, a small model, no Write, Edit or Bash. const config: RunConfig = { @@ -54,34 +46,33 @@ const config: RunConfig = { docsUrl: 'https://posthog.com/docs', }, composed: true, // a sub-run: no terminal outro - // runAgent doesn't resolve a route. Linear on the Anthropic harness keeps the transcript. - binding: { - sequence: Sequence.linear, - harness: Harness.anthropic, - model: HAIKU_MODEL, + // Linear on the Anthropic harness keeps the transcript. With no flags, the agent runs this binding as is. + routing: { + binding: { + sequence: Sequence.linear, + harness: Harness.anthropic, + model: HAIKU_MODEL, + }, }, - switchboard: { program: programId, composed: true, flags: {} }, skillsBaseUrl: getSkillsBaseUrl(), wizardFlags: {}, wizardFlagPayloads: {}, - wizardMetadata: {}, disallowedTools: ['Write', 'Edit', 'Bash'], }; // Where and as whom: the project, the login and the flags. const input: RunInput = { installDir: process.env.QUACK_INSTALL_DIR ?? process.cwd(), + // Use the token in WIZARD_CI_GATEWAY_TOKEN_FILE at WIZARD_CI_GATEWAY_URL instead of minting one. credentials: { - accessToken: login.accessToken, - refreshToken: login.refreshToken, - expiresAt: login.expiresAt, - projectApiKey: login.projectApiKey, - host: login.host, - projectId: login.projectId, - missingScopes: login.missingScopes, + accessToken: apiKey, + projectApiKey: project.api_token, + host, + projectId: project.id, + gateway: readCiGatewayCredential('us'), }, - project: login.project, - apiUser: login.user, + project, + apiUser, flags: { ci: false, signup: false, diff --git a/docs/examples/run-program-quack.ts b/docs/examples/run-program-quack.ts index 5cc5766e1..adac79c6b 100644 --- a/docs/examples/run-program-quack.ts +++ b/docs/examples/run-program-quack.ts @@ -6,12 +6,17 @@ // Needs local PostHog on :8010 (with its ai-gateway) and context-mill on :8765. // POSTHOG_PERSONAL_API_KEY logs in. WIZARD_CI_GATEWAY_TOKEN_FILE holds the gateway token. // QUACK_INSTALL_DIR sets the project the agent runs in (default: the current directory). -import { configureGatewayFromCIEnvironment, RunOutcome } from '@agent'; -import { runProgram } from '@programs'; +import { + buildSession, + resolveApiKeyLogin, + RunOutcome, + runProgram, + SessionStore, +} from '@programs'; import type { ProgramProgress } from '@programs/types'; import { Harness, HAIKU_MODEL, Sequence } from '@shared/constants'; +import { readCiGatewayCredential } from '@shared/ci-gateway'; import { initLocalDev, POSTHOG_LOCAL_URL } from '@shared/local-dev'; -import { getOrAskForProjectData } from '@tui/auth/project-data'; // Point PostHog, skills and MCP at the local stack, like --local-posthog --local-context-mill --local-mcp. initLocalDev({ localPosthog: true, localContextMill: true, localMcp: true }); @@ -20,76 +25,72 @@ initLocalDev({ localPosthog: true, localContextMill: true, localMcp: true }); const apiKey = process.env.POSTHOG_PERSONAL_API_KEY; if (!apiKey) throw new Error('Set POSTHOG_PERSONAL_API_KEY'); const programId = 'posthog-integration'; // a program the local gateway admits -const login = await getOrAskForProjectData({ - signup: false, - ci: true, // with apiKey, this skips OAuth - apiKey, +const login = await resolveApiKeyLogin(apiKey, { baseUrl: POSTHOG_LOCAL_URL, localMcp: true, - programId, + onWarning: (message) => console.warn(message), }); // Use the token in WIZARD_CI_GATEWAY_TOKEN_FILE at WIZARD_CI_GATEWAY_URL instead of minting one. -configureGatewayFromCIEnvironment(login.projectId, 'us'); +login.posthog.gateway = readCiGatewayCredential('us'); // Log status lines as the program reports them. runProgram never waits for this. function logProgress(progress: ProgramProgress): void { - if (progress.kind === 'program') return; // a data snapshot; it holds tokens, don't log it const { event } = progress; if (event.kind === 'status') console.log(`status: ${event.message}`); if (event.kind === 'lifecycle') console.log(`lifecycle: ${event.phase}`); } +// You own the session store: runProgram reads the launch values from it and writes the run into it. +const store = new SessionStore( + buildSession({ + installDir: process.env.QUACK_INSTALL_DIR ?? process.cwd(), + baseUrl: POSTHOG_LOCAL_URL, + localMcp: true, + // A small model on the linear Anthropic route, where the transcript is kept. + sequence: Sequence.linear, + harness: Harness.anthropic, + model: HAIKU_MODEL, + }), +); +// The quack prompt reads nothing from the project, so skip the program's detection. +store.setDetectionComplete(); + const result = await runProgram( programId, { - installDir: process.env.QUACK_INSTALL_DIR ?? process.cwd(), - // A caller-built run in place of the program's own: one prompt, and keep the reply. - run: { - integrationLabel: 'quack', - prompt: () => 'Reply with the single word quack. Use no tools.', - collectTranscript: true, // keep the agent's output for the reply below - requestRemark: false, // no closing remark - spinnerMessage: 'Quacking...', - successMessage: 'Quacked', - estimatedDurationMinutes: 1, - reportFile: '', - docsUrl: 'https://posthog.com/docs', - }, - program: { disallowedTools: ['Write', 'Edit', 'Bash'] }, - // The login from above, so runProgram skips its own login step. - credentials: { - posthog: { - accessToken: login.accessToken, - refreshToken: login.refreshToken, - expiresAt: login.expiresAt, - projectApiKey: login.projectApiKey, - host: login.host, - projectId: login.projectId, - missingScopes: login.missingScopes, + store, + // Laid over the program's own config: one prompt, keep the reply, no health check. + config: { + run: { + integrationLabel: 'quack', + prompt: () => 'Reply with the single word quack. Use no tools.', + collectTranscript: true, // keep the agent's output for the reply below + requestRemark: false, // no closing remark + spinnerMessage: 'Quacking...', + successMessage: 'Quacked', + estimatedDurationMinutes: 1, + reportFile: '', + docsUrl: 'https://posthog.com/docs', }, - project: login.project, - apiUser: login.user, + disallowedTools: ['Write', 'Edit', 'Bash'], + healthCheck: false, }, + credentials: login, // the login from above, so runProgram skips its own login step composed: true, // a sub-run: no terminal outro - // A small model on the linear Anthropic route, where the transcript is kept. - overrides: { - sequence: Sequence.linear, - harness: Harness.anthropic, - model: HAIKU_MODEL, - }, - flags: { localMcp: true }, - host: { baseUrl: POSTHOG_LOCAL_URL }, wizardFlags: {}, // no flag snapshot to load }, { // You approved AI data processing for this local test user. - awaitAiApproval: () => Promise.resolve(true), + workflow: { + confirmStep: (step) => + Promise.resolve(step.kind === 'ai-approval' || step.kind === 'run'), + }, onProgress: logProgress, }, ); -// Endings resolve to an outcome. The agent's reply is in its settled run's transcript. -const reply = result.settledRuns[0]?.result.snapshot.transcriptTail ?? ''; +// Endings resolve to an outcome. The agent's reply is in its run's transcript. +const reply = result.runResults[0]?.snapshot.transcriptTail ?? ''; console.log(`reply: ${reply}`); console.log(`outcome: ${result.outcome}`); if (result.failure) console.log(`failure: ${result.failure.message}`); diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 8ea61a397..1b8842bac 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -8,22 +8,39 @@ * same store method the Ink screen's keyboard handler would. * * Discipline mirrors screen-registry.tsx: one entry per screen, kept exhaustive - * by a test over the ScreenId/Overlay enums. No product knowledge leaks in — + * by a test over every screen the TUI mounts. No product knowledge leaks in — * actions speak only in store setters and generic params. */ -import type { WizardStore } from '@tui/store'; -import { ScreenId, Overlay, type ScreenName } from '@tui/router'; -import { McpOutcome, OutroKind } from '@programs/session/wizard-session'; -import type { AskAnswers } from '@programs/session/wizard-session'; +import { + AiObservabilityScreenId, + AuditScreenId, + ErrorTrackingScreenId, + McpScreenId, + MetricsScreenId, + MigrationScreenId, + Overlay, + PostHogIntegrationScreenId, + PosthogDoctorScreenId, + RevenueAnalyticsScreenId, + ScreenId, + SelfDrivingScreenId, + SkillScreenId, + SourceMapsScreenId, + WarehouseSourceScreenId, + type WizardStore, +} from '@tui'; +import { McpOutcome } from '@shared/run-state'; +import { OutroKind } from '@shared/outro'; +import type { AskAnswers } from '@agent/types'; import { SOURCE_MAPS_CONTEXT_KEYS, VARIANT_DISPLAY_NAME, -} from '@programs/error-tracking-upload-source-maps/index'; +} from '@programs/error-tracking-upload-source-maps'; import { GITHUB_REQUIRED_BODY, GITHUB_REQUIRED_MESSAGE, -} from '@programs/self-driving/detect'; +} from '@programs/self-driving'; /** One commit action legal on a given screen. */ export interface DriverAction { @@ -69,21 +86,21 @@ function requireString( * and the no-dismiss terminal overlays. * - screens of programs the integration e2e profile never enters (doctor). */ -export const NO_ACTION_SCREENS: ReadonlySet = new Set([ +export const NO_ACTION_SCREENS: ReadonlySet = new Set([ ScreenId.Auth, ScreenId.Run, ScreenId.AiOptIn, ScreenId.Exit, // The agent advances the audit run, the same way it advances `run`. - ScreenId.AuditRun, - ScreenId.DoctorReport, + AuditScreenId.Run, + PosthogDoctorScreenId.Report, // The detector + picker are interactive; no headless e2e drives this screen. - ScreenId.SelfDrivingIntegrationDetect, - ScreenId.SelfDrivingIntegrationCheck, - ScreenId.SelfDrivingIntegrationDetect, - ScreenId.SelfDrivingHandoff, + SelfDrivingScreenId.IntegrationDetect, + SelfDrivingScreenId.IntegrationCheck, + SelfDrivingScreenId.IntegrationDetect, + SelfDrivingScreenId.Handoff, // The e2e host injects the pick, as it does for self-driving's detect screen. - ScreenId.ErrorTrackingDetect, + ErrorTrackingScreenId.Detect, Overlay.ManagedSettings, Overlay.AuthError, Overlay.SessionTimeout, @@ -100,23 +117,23 @@ const confirmSetupAction: DriverAction = { apply: (store) => store.completeSetup(), }; -export const ACTION_REGISTRY: Partial> = { +export const ACTION_REGISTRY: Partial> = { // ── Program intros — confirm & continue ─────────────────────────────── - [ScreenId.Intro]: [confirmSetupAction], - [ScreenId.RevenueIntro]: [confirmSetupAction], - [ScreenId.SourceMapsIntro]: [confirmSetupAction], - [ScreenId.MigrationIntro]: [confirmSetupAction], - [ScreenId.AgentSkillIntro]: [confirmSetupAction], - [ScreenId.AiObservabilityIntro]: [confirmSetupAction], - [ScreenId.MetricsIntro]: [confirmSetupAction], - [ScreenId.ErrorTrackingIntro]: [confirmSetupAction], - [ScreenId.AuditIntro]: [confirmSetupAction], - [ScreenId.DoctorIntro]: [confirmSetupAction], - [ScreenId.WarehouseIntro]: [confirmSetupAction], - [ScreenId.SelfDrivingIntro]: [confirmSetupAction], + [PostHogIntegrationScreenId.Intro]: [confirmSetupAction], + [RevenueAnalyticsScreenId.Intro]: [confirmSetupAction], + [SourceMapsScreenId.Intro]: [confirmSetupAction], + [MigrationScreenId.Intro]: [confirmSetupAction], + [SkillScreenId.Intro]: [confirmSetupAction], + [AiObservabilityScreenId.Intro]: [confirmSetupAction], + [MetricsScreenId.Intro]: [confirmSetupAction], + [ErrorTrackingScreenId.Intro]: [confirmSetupAction], + [AuditScreenId.Intro]: [confirmSetupAction], + [PosthogDoctorScreenId.Intro]: [confirmSetupAction], + [WarehouseSourceScreenId.Intro]: [confirmSetupAction], + [SelfDrivingScreenId.Intro]: [confirmSetupAction], // ── Self-driving integration check ──────────────────────────────────── - [ScreenId.SelfDrivingIntegrationCheck]: [ + [SelfDrivingScreenId.IntegrationCheck]: [ { id: 'set_integrate', description: @@ -128,7 +145,7 @@ export const ACTION_REGISTRY: Partial> = { ], // ── Self-driving handoff (after the integration run) ─────────────────── - [ScreenId.SelfDrivingHandoff]: [ + [SelfDrivingScreenId.Handoff]: [ { id: 'confirm_self_driving_handoff', description: @@ -138,7 +155,7 @@ export const ACTION_REGISTRY: Partial> = { ], // ── Source-maps project pick + outro ─────────────────────────────────── - [ScreenId.SourceMapsDetect]: [ + [SourceMapsScreenId.Detect]: [ { id: 'pick_source_maps_project', description: @@ -168,7 +185,7 @@ export const ACTION_REGISTRY: Partial> = { }, }, ], - [ScreenId.SourceMapsOutro]: [ + [SourceMapsScreenId.Outro]: [ { id: 'dismiss_outro', description: 'Dismiss the source-maps outro (sets outroDismissed).', @@ -209,7 +226,7 @@ export const ACTION_REGISTRY: Partial> = { apply: (store) => store.setOutroDismissed(), }, ], - [ScreenId.AuditOutro]: [ + [AuditScreenId.Outro]: [ { id: 'dismiss_outro', description: @@ -251,7 +268,7 @@ export const ACTION_REGISTRY: Partial> = { }, }, ], - [ScreenId.McpAdd]: [ + [McpScreenId.Add]: [ { id: 'set_mcp_outcome', description: 'Complete the standalone MCP-add flow.', @@ -264,7 +281,7 @@ export const ACTION_REGISTRY: Partial> = { }, }, ], - [ScreenId.McpRemove]: [ + [McpScreenId.Remove]: [ { id: 'set_mcp_outcome', description: 'Complete the standalone MCP-remove flow.', @@ -277,7 +294,7 @@ export const ACTION_REGISTRY: Partial> = { }, }, ], - [ScreenId.McpSuggestedPrompts]: [ + [McpScreenId.SuggestedPrompts]: [ { id: 'dismiss', description: 'Dismiss the suggested-prompts step.', @@ -286,7 +303,7 @@ export const ACTION_REGISTRY: Partial> = { ], // ── Slack ───────────────────────────────────────────────────────────── - [ScreenId.SelfDrivingGithub]: [ + [SelfDrivingScreenId.Github]: [ { id: 'set_github_connected', description: 'Resolve the GitHub App connection check', @@ -397,6 +414,6 @@ export const ACTION_REGISTRY: Partial> = { }; /** Actions legal on the given screen — empty array if none. */ -export function actionsForScreen(screen: ScreenName): DriverAction[] { +export function actionsForScreen(screen: string): DriverAction[] { return ACTION_REGISTRY[screen] ?? []; } diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index fbe5964b9..ab6c47c86 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -2,13 +2,14 @@ * WizardE2eProfile — a program's declarative e2e "test definition": the UI * choices a headless e2e run makes at each decision point. * - * Per-program choices live in {@link ./profiles}, keyed by program id. - * {@link decideE2eAction} maps the current screen + a profile to the commit to - * make. Add a program's profile to {@link ./profiles} to make it e2e-drivable. + * Each program's choices live in its own `test/e2e.json`, which + * {@link ./profiles} reads by program id. {@link decideE2eAction} maps the + * current screen + a profile to the commit to make: a core screen by its own + * case, a program screen by the commits the state lists for it. */ -import { ScreenId, Overlay, type ScreenName } from '@tui/router'; -import type { AskAnswers, AskQuestion } from '@programs/session/wizard-session'; +import { ScreenId, Overlay } from '@tui'; +import type { AskAnswers, AskQuestion } from '@agent/types'; import type { CiState } from './wizard-ci-driver.js'; /** Which option to pick for a setup disambiguation question. */ @@ -277,20 +278,6 @@ export function decideE2eAction( profile: WizardE2eProfile, ): E2eDecision { switch (state.currentScreen) { - case ScreenId.Intro: - case ScreenId.RevenueIntro: - case ScreenId.MigrationIntro: - case ScreenId.AgentSkillIntro: - case ScreenId.AiObservabilityIntro: - case ScreenId.MetricsIntro: - case ScreenId.ErrorTrackingIntro: - case ScreenId.AuditIntro: - case ScreenId.SourceMapsIntro: - case ScreenId.DoctorIntro: - case ScreenId.WarehouseIntro: - case ScreenId.SelfDrivingIntro: - return { action: { id: 'confirm_setup' } }; - case ScreenId.HealthCheck: return profile.healthCheck === 'dismiss' ? { action: { id: 'dismiss_outage' } } @@ -308,20 +295,7 @@ export function decideE2eAction( }; } - case ScreenId.SelfDrivingIntegrationCheck: - return { - action: { - id: 'set_integrate', - params: { integrate: profile.integrate === true }, - }, - }; - - case ScreenId.SelfDrivingHandoff: - return { action: { id: 'confirm_self_driving_handoff' } }; - case ScreenId.Outro: - case ScreenId.SourceMapsOutro: - case ScreenId.AuditOutro: return { action: { id: 'dismiss_outro' } }; case ScreenId.Mcp: @@ -334,9 +308,6 @@ export function decideE2eAction( }, }; - case ScreenId.McpSuggestedPrompts: - return { action: { id: 'dismiss' } }; - case ScreenId.SlackConnect: return { action: { id: 'dismiss_slack' } }; @@ -387,22 +358,59 @@ export function decideE2eAction( // auth (runner), run (agent), ai-opt-in (ci), exit, terminal overlays. default: - return { wait: true }; + return CORE_SCREENS.has(state.currentScreen) + ? { wait: true } + : decideProgramScreen(state, profile); } } -/** Screens this profile knows how to act on — for completeness checks/tests. */ -export const E2E_DRIVABLE_SCREENS: readonly ScreenName[] = [ - ScreenId.Intro, +/** Core screens and overlays this profile knows how to act on — for completeness checks/tests. */ +export const E2E_DRIVABLE_SCREENS: readonly string[] = [ ScreenId.HealthCheck, ScreenId.Setup, - ScreenId.SelfDrivingIntegrationCheck, ScreenId.Outro, - ScreenId.SourceMapsOutro, ScreenId.Mcp, - ScreenId.McpSuggestedPrompts, ScreenId.SlackConnect, ScreenId.KeepSkills, Overlay.WizardAsk, Overlay.TaskNotice, ]; + +/** The core screens and overlays; every other screen is a program's own. */ +const CORE_SCREENS: ReadonlySet = new Set([ + ...Object.values(ScreenId), + ...Object.values(Overlay), +]); + +/** The params a program-screen commit takes from the profile, if any. */ +type ProgramScreenCommit = ( + profile: WizardE2eProfile, +) => Record | undefined; + +/** + * The commit a run makes on a program's own screen, by action id. A program + * screen commits the first of its actions listed here and waits when it offers + * none of them. + */ +const PROGRAM_SCREEN_COMMITS: ReadonlyMap = + new Map([ + ['confirm_setup', () => undefined], + ['dismiss_outro', () => undefined], + ['dismiss', () => undefined], + ['confirm_self_driving_handoff', () => undefined], + ['set_integrate', (profile) => ({ integrate: profile.integrate === true })], + ]); + +/** Decide a program screen from the commits the state lists for it. */ +function decideProgramScreen( + state: CiState, + profile: WizardE2eProfile, +): E2eDecision { + for (const { id } of state.actions) { + const commit = PROGRAM_SCREEN_COMMITS.get(id); + if (!commit) continue; + const params = commit(profile); + return { action: { id, ...(params ? { params } : {}) } }; + } + return { wait: true }; +} diff --git a/e2e-harness/e2e-result.ts b/e2e-harness/e2e-result.ts index 0ff8355d9..dca85304d 100644 --- a/e2e-harness/e2e-result.ts +++ b/e2e-harness/e2e-result.ts @@ -21,11 +21,11 @@ import fs from 'fs'; import path from 'path'; -import { OutroKind, type WizardSession } from '@programs/session/wizard-session'; -import { TASK_OUTCOMES_KEY } from '@agent'; +import { OutroKind } from '@shared/outro'; +import type { DetectedSource, WizardSession } from '@programs/types'; +import { TASK_OUTCOMES_KEY } from '@programs'; +import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@programs/warehouse-source'; import type { TaskOutcome } from '@agent/types'; -import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@programs/warehouse-source/detect'; -import type { DetectedSource } from '@programs/warehouse-sources/types'; import type { E2eDecisionReport } from './e2e-profile.js'; /** One `wizard_ask` batch the run was shown. */ @@ -202,7 +202,7 @@ function isInside(root: string, child: string): boolean { /** * The abort reason for a run, or null when it did not abort. * - * `wizardAbort` renders an error outro and then exits, so `outroData` is the + * `wizardAbort` renders an error outro and then ends the run, so `outroData` is the * only durable trace of *why* by the time the host writes its result. */ export function abortReasonFrom( diff --git a/e2e-harness/profiles.ts b/e2e-harness/profiles.ts index d0c343e6e..93b965765 100644 --- a/e2e-harness/profiles.ts +++ b/e2e-harness/profiles.ts @@ -2,16 +2,20 @@ * Per-program e2e profiles — the UI choices a headless run makes driving each * program's flow. * - * Each program declares its test path as JSON next to it - * (`src/programs//test/e2e.json`): a `profile` (the options the run - * auto-takes) plus a documented `path`. {@link profileFor} loads the `profile` - * and maps it by program id. + * Each program declares its test path as JSON in its own folder + * (`src/programs//test/e2e.json`): the `program` id it drives, a + * `profile` (the options the run auto-takes), optional `variations` and a + * documented `path`. This module reads every such file once and keys it by + * `program`, so a new program's `e2e.json` needs no change here. * * {@link resolveE2eProfile} folds the run's env-var inputs into a profile once, * so `decideE2eAction` stays a pure function of (state, profile). */ -import { Program, type ProgramId } from '@programs'; +import { existsSync, readdirSync, readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { PROGRAM_REGISTRY, type ProgramId } from '@programs'; import { DEFAULT_E2E_PROFILE, DEFAULT_E2E_VARIATION, @@ -19,51 +23,44 @@ import { type WizardE2eProfile, type WizardE2eVariation, } from './e2e-profile.js'; -import posthogIntegrationE2e from '@programs/posthog-integration/test/e2e.json'; -import aiObservabilityE2e from '@programs/ai-observability/test/e2e.json'; -import metricsE2e from '@programs/metrics/test/e2e.json'; -import replayVisionE2e from '@programs/replay-vision/test/e2e.json'; -import selfDrivingE2e from '@programs/self-driving/test/e2e.json'; -import sourceMapsE2e from '@programs/error-tracking-upload-source-maps/test/e2e.json'; -import errorTrackingE2e from '@programs/error-tracking/test/e2e.json'; -import warehouseSourceE2e from '@programs/warehouse-source/test/e2e.json'; -import auditE2e from '@programs/audit/test/e2e.json'; -const PROFILES: Partial> = { - [Program.PostHogIntegration]: - posthogIntegrationE2e.profile as WizardE2eProfile, - [Program.AiObservability]: aiObservabilityE2e.profile as WizardE2eProfile, - [Program.Metrics]: metricsE2e.profile as WizardE2eProfile, - [Program.ReplayVision]: replayVisionE2e.profile as WizardE2eProfile, - [Program.SelfDriving]: selfDrivingE2e.profile as WizardE2eProfile, - [Program.ErrorTrackingUploadSourceMaps]: - sourceMapsE2e.profile as WizardE2eProfile, - [Program.ErrorTracking]: errorTrackingE2e.profile as WizardE2eProfile, - [Program.WarehouseSource]: warehouseSourceE2e.profile as WizardE2eProfile, - [Program.Audit]: auditE2e.profile as WizardE2eProfile, -}; +/** The machine-read part of a program's `test/e2e.json`. */ +interface E2eDefinition { + program: ProgramId; + profile: WizardE2eProfile; + variations?: WizardE2eVariation[]; +} + +const PROGRAMS_DIR = fileURLToPath( + new URL('../src/programs/', import.meta.url), +); + +/** Every program folder's `test/e2e.json`, by the registered program it names. */ +function loadDefinitions(): ReadonlyMap { + const registered = new Set(PROGRAM_REGISTRY.map((c) => c.id)); + const definitions = new Map(); + for (const entry of readdirSync(PROGRAMS_DIR, { withFileTypes: true })) { + const file = path.join(PROGRAMS_DIR, entry.name, 'test', 'e2e.json'); + if (!entry.isDirectory() || !existsSync(file)) continue; + const definition = JSON.parse(readFileSync(file, 'utf8')) as E2eDefinition; + if (!registered.has(definition.program)) { + throw new Error(`${file}: no registered program "${definition.program}"`); + } + definitions.set(definition.program, definition); + } + return definitions; +} -const VARIATIONS: Partial> = { - [Program.PostHogIntegration]: - posthogIntegrationE2e.variations as WizardE2eVariation[], - [Program.AiObservability]: - aiObservabilityE2e.variations as WizardE2eVariation[], - [Program.Metrics]: metricsE2e.variations as WizardE2eVariation[], - [Program.ReplayVision]: replayVisionE2e.variations as WizardE2eVariation[], - [Program.ErrorTracking]: errorTrackingE2e.variations as WizardE2eVariation[], - [Program.WarehouseSource]: - warehouseSourceE2e.variations as WizardE2eVariation[], - [Program.Audit]: auditE2e.variations as WizardE2eVariation[], -}; +const DEFINITIONS = loadDefinitions(); /** The e2e profile for a program, or the happy-path default if none is set. */ export function profileFor(program: ProgramId): WizardE2eProfile { - return PROFILES[program] ?? DEFAULT_E2E_PROFILE; + return DEFINITIONS.get(program)?.profile ?? DEFAULT_E2E_PROFILE; } /** Whether a program has an explicit (non-default) e2e profile. */ export function hasProfile(program: ProgramId): boolean { - return program in PROFILES; + return DEFINITIONS.has(program); } /** @@ -71,7 +68,7 @@ export function hasProfile(program: ProgramId): boolean { * back to the single no-override baseline when a program declares none. */ export function variationsFor(program: ProgramId): WizardE2eVariation[] { - return VARIATIONS[program] ?? [DEFAULT_E2E_VARIATION]; + return DEFINITIONS.get(program)?.variations ?? [DEFAULT_E2E_VARIATION]; } /** Env-var inputs a run may layer over a program's declared profile. */ diff --git a/e2e-harness/tui-capture.ts b/e2e-harness/tui-capture.ts index 2df7bafd8..d71d0fa21 100644 --- a/e2e-harness/tui-capture.ts +++ b/e2e-harness/tui-capture.ts @@ -8,6 +8,7 @@ import fsmod from 'fs'; import pathmod from 'path'; import * as pty from 'node-pty'; +// eslint-disable-next-line no-restricted-imports -- loads @xterm/headless's CJS entry; its module field is the browser build import { createRequire } from 'module'; import type { IBufferLine } from '@xterm/headless'; diff --git a/e2e-harness/wizard-ci-driver.ts b/e2e-harness/wizard-ci-driver.ts index 73891998a..f533ac46c 100644 --- a/e2e-harness/wizard-ci-driver.ts +++ b/e2e-harness/wizard-ci-driver.ts @@ -18,9 +18,9 @@ * the driver issues the final commit directly instead. */ -import type { WizardStore } from '@tui/store'; -import type { ScreenName } from '@tui/router'; -import type { PendingQuestion, RunPhase } from '@programs/session/wizard-session'; +import type { WizardStore } from '@tui'; +import type { PendingQuestion } from '@agent/types'; +import type { RunPhase } from '@shared/run-state'; import { actionsForScreen, MissingParamError } from './action-registry.js'; /** A setup question projected for the harness (no `detect` fn, no closures). */ @@ -53,7 +53,7 @@ export interface TaskNoticeView { * are reduced to a boolean so secrets never reach a driver LLM. */ export interface CiState { - currentScreen: ScreenName; + currentScreen: string; hasOverlay: boolean; runPhase: RunPhase; session: { @@ -86,7 +86,7 @@ export interface CiState { } export class UnknownActionError extends Error { - constructor(action: string, screen: ScreenName) { + constructor(action: string, screen: string) { super( `No action "${action}" on screen "${screen}". ` + `Call list_actions / read read_state.actions first.`, @@ -111,14 +111,14 @@ export class WizardCiDriver { integration: s.integration, detectedFrameworkLabel: s.detectedFrameworkLabel, detectionComplete: s.detectionComplete, - setupConfirmed: s.setupConfirmed, - integrate: s.integrate, + setupConfirmed: this.store.setupConfirmed, + integrate: this.store.integrate, hasCredentials: s.credentials !== null, projectId: s.credentials?.projectId ?? null, - mcpComplete: s.mcpComplete, - slackStepDismissed: s.slackStepDismissed, - skillsComplete: s.skillsComplete, - outroDismissed: s.outroDismissed, + mcpComplete: this.store.mcpComplete, + slackStepDismissed: this.store.slackStepDismissed, + skillsComplete: this.store.skillsComplete, + outroDismissed: this.store.outroDismissed, discoveredFeatures: [...s.discoveredFeatures], }, tasks: this.store.tasks.map((t) => ({ diff --git a/e2e-tests/mocks/preload.ts b/e2e-tests/mocks/preload.ts new file mode 100644 index 000000000..b482d72b7 --- /dev/null +++ b/e2e-tests/mocks/preload.ts @@ -0,0 +1,8 @@ +/** + * Starts the MSW mock server inside the wizard's own process. The e2e suite + * spawns the built wizard, so the jest-side server in `setup.ts` can't + * intercept its requests; `startWizardInstance` preloads this file instead. + */ +import { server } from './server'; + +server.listen({ onUnhandledRequest: 'bypass' }); diff --git a/e2e-tests/utils/index.ts b/e2e-tests/utils/index.ts index 37e0fec7a..1878bdf92 100644 --- a/e2e-tests/utils/index.ts +++ b/e2e-tests/utils/index.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +import { pathToFileURL } from 'url'; import { spawn, execSync } from 'child_process'; import type { ChildProcess } from 'child_process'; @@ -33,9 +34,14 @@ export class WizardTestEnv { opts?: { cwd?: string; debug?: boolean; + env?: NodeJS.ProcessEnv; }, ) { - this.taskHandle = spawn(cmd, args, { cwd: opts?.cwd, stdio: 'pipe' }); + this.taskHandle = spawn(cmd, args, { + cwd: opts?.cwd, + env: opts?.env, + stdio: 'pipe', + }); if (opts?.debug) { this.taskHandle.stdout?.pipe(process.stdout); @@ -232,9 +238,23 @@ export function startWizardInstance( cleanupGit(projectDir); initGit(projectDir); - return new WizardTestEnv('node', [binPath, '--debug'], { + // The mock server has to run inside the wizard's process to intercept its + // requests. The mocks are TypeScript, so tsx loads them, with the root + // tsconfig for the `@shared/*` aliases they reach. + const mockServer = [ + '--import', + pathToFileURL(require.resolve('tsx')).href, + '--import', + pathToFileURL(path.join(__dirname, '../mocks/preload.ts')).href, + ]; + + return new WizardTestEnv('node', [...mockServer, binPath, '--debug'], { cwd: projectDir, debug, + env: { + ...process.env, + TSX_TSCONFIG_PATH: path.join(__dirname, '../../tsconfig.json'), + }, }); } diff --git a/main.ts b/main.ts index ca85b889f..22fc13287 100644 --- a/main.ts +++ b/main.ts @@ -13,40 +13,7 @@ import { Agent, setGlobalDispatcher } from 'undici'; */ setGlobalDispatcher(new Agent({ allowH2: false })); -// Test mock server — only loaded when NODE_ENV is 'test'. -// In production builds, tsdown replaces process.env.NODE_ENV with 'production', -// making this block dead code. -if (process.env.NODE_ENV === 'test') { - void (async () => { - try { - const { server } = await import('./e2e-tests/mocks/server.js'); - server.listen({ - onUnhandledRequest: 'bypass', - }); - } catch (error) { - // Mock server import failed - this can happen during non-E2E tests - } - })(); -} - -import { Wizard } from './src/cli/wizard'; -import { basicIntegrationCommand } from './src/cli/commands/basic-integration'; -import { mcpCommand } from './src/cli/commands/mcp'; -import { mcpAnalyticsCommand } from './src/cli/commands/mcp-analytics'; -import { replayVisionCommand } from './src/cli/commands/replay-vision'; -import { aiObservabilityCommand } from './src/cli/commands/ai-observability'; -import { metricsCommand } from './src/cli/commands/metrics'; -import { auditCommand } from './src/cli/commands/audit'; -import { doctorCommand } from './src/tools/doctor/report'; -import { migrateCommand } from './src/cli/commands/migrate'; -import { revenueCommand } from './src/cli/commands/revenue'; -import { warehouseCommand } from './src/cli/commands/warehouse'; -import { selfDrivingCommand } from './src/cli/commands/self-driving'; -import { slackCommand } from './src/cli/commands/slack'; -import { uploadSourcemapsCommand } from './src/cli/commands/upload-sourcemaps'; -import { errorTrackingCommand } from './src/cli/commands/error-tracking'; -import { skillCommand } from './src/cli/commands/skill'; -import { cliCommand } from './src/cli/commands/cli'; +import { runCli } from '@cli'; import { recoverOrphanedSettingsBackups } from '@shared/claude-settings'; // Heal any .claude/settings backup a previous interrupted run left orphaned, @@ -64,21 +31,4 @@ function resolveInstallDir(): string { return process.env.POSTHOG_WIZARD_INSTALL_DIR ?? process.cwd(); } -Wizard.use(basicIntegrationCommand) - .use(mcpCommand) - .use(mcpAnalyticsCommand) - .use(replayVisionCommand) - .use(aiObservabilityCommand) - .use(metricsCommand) - .use(cliCommand) - .use(auditCommand) - .use(doctorCommand) - .use(migrateCommand) - .use(revenueCommand) - .use(warehouseCommand) - .use(selfDrivingCommand) - .use(slackCommand) - .use(uploadSourcemapsCommand) - .use(errorTrackingCommand) - .use(skillCommand) - .init(); +runCli(); diff --git a/scripts/mcp-install-smoke-test.ts b/scripts/mcp-install-smoke-test.ts index 228688d93..73b6cf73b 100644 --- a/scripts/mcp-install-smoke-test.ts +++ b/scripts/mcp-install-smoke-test.ts @@ -15,7 +15,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { HEADLESS_FLAG } from '../src/shared/headless-mode'; +import { HEADLESS_FLAG } from '@env'; const PROVIDERS = ['claude-code', 'codex'] as const; type Provider = (typeof PROVIDERS)[number]; @@ -100,7 +100,7 @@ function wizard(...args: string[]): Run { /** * The non-interactive install. Reuses the run pipeline's headless flag, so the - * name is imported rather than spelled out — see @lib/headless-mode. + * name is imported rather than spelled out — see @env. */ function mcpAdd(...extra: string[]): Run { return wizard('mcp', 'add', `--${HEADLESS_FLAG}`, ...extra); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index f5e28a1cc..675f452b1 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -86,7 +86,7 @@ fi # not reject the flag, and it must not fall through to the --ci rejection. With # no api-key the run exits fast on "Headless mode requires --api-key" — all this # asserts is that the flag is recognized and live in the published binary. The -# flag name is intentionally undocumented; keep it in sync with @lib/headless-mode. +# flag name is intentionally undocumented; keep it in sync with HEADLESS_FLAG in src/env.ts. HEADLESS_FLAG='--headless-DONOTUSE-EXPERIMENTAL' hl_output=$(node "$DIST_BIN" "$HEADLESS_FLAG" --install-dir /tmp/wizard-smoke-probe 2>&1) || true if echo "$hl_output" | grep -qiE 'unknown argument|not currently supported'; then diff --git a/scripts/warlock-smoke-test.ts b/scripts/warlock-smoke-test.ts index ad261bc5e..6eaa33888 100644 --- a/scripts/warlock-smoke-test.ts +++ b/scripts/warlock-smoke-test.ts @@ -11,8 +11,7 @@ import { scan, CATEGORIES, type Category } from '@posthog/warlock'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { scanVerdict } from '@agent/yara-policy'; -import { scanInstalledSkill } from '@agent/yara-hooks'; +import { scanVerdict, scanInstalledSkill } from '@agent'; // Assembled at runtime so no scannable secret literal is committed. const fakeStripeKey = 'sk_' + 'live_' + '5'.repeat(24); diff --git a/src/cli/commands/ai-observability.ts b/src/cli/commands/ai-observability.ts index c51fe2b0a..eb50b0f7c 100644 --- a/src/cli/commands/ai-observability.ts +++ b/src/cli/commands/ai-observability.ts @@ -1,4 +1,4 @@ -import { aiObservabilityConfig } from '@programs/ai-observability/index'; +import { config as aiObservabilityConfig } from '@programs/ai-observability'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/audit.ts b/src/cli/commands/audit.ts index 39ed26dfe..12cb353cc 100644 --- a/src/cli/commands/audit.ts +++ b/src/cli/commands/audit.ts @@ -1,4 +1,4 @@ -import { auditConfig } from '@programs/audit/index'; +import { getProgramConfig, Program } from '@programs'; import type { Command } from './command'; import { familyCommandFactory } from './factories/family-command-factory'; @@ -15,8 +15,10 @@ import { familyCommandFactory } from './factories/family-command-factory'; * Adding a new skill-backed audit subcommand is a context-mill release — * no wizard release needed. */ +const audit = getProgramConfig(Program.Audit); + export const auditCommand: Command = familyCommandFactory({ family: 'audit', - description: auditConfig.description, - optionsFrom: auditConfig, + description: audit.description, + optionsFrom: audit, }); diff --git a/src/cli/commands/basic-integration/ci-install.ts b/src/cli/commands/basic-integration/ci-install.ts index cdb025746..44d3cb2b9 100644 --- a/src/cli/commands/basic-integration/ci-install.ts +++ b/src/cli/commands/basic-integration/ci-install.ts @@ -1,10 +1,9 @@ import type { Arguments } from 'yargs'; -import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@headless/renderers/logging-ui'; +import { consoleLog } from '@shared/console-log'; import { API_KEY_HINT, runWizardCI, runWizardHeadless } from '@cli/runners'; -import type { NonInteractiveMode } from '@cli/runners'; +import type { NonInteractiveMode } from '@headless'; import { provisionNewAccount } from '@utils/provisioning'; -import { posthogIntegrationConfig } from '@programs/posthog-integration/index'; +import { config as posthogIntegrationConfig } from '@programs/posthog-integration'; import { ErrorCodes, type ErrorCode } from '@shared/errors'; import { emitWizardError } from '@shared/errors'; @@ -29,7 +28,7 @@ export function runCIInstall(argv: Arguments): void { /** * Headless install entry point (the experimental published-build run path; see - * @lib/headless-mode). Thin shell over the shared non-interactive install. + * @shared/headless-mode). Thin shell over the shared non-interactive install. * Today it behaves exactly like `runCIInstall`; it is a separate function so * headless can diverge later (auth, prompts, …) without touching the CI path. */ @@ -93,9 +92,8 @@ function runNonInteractiveInstall( } function failCI(message: string, code?: ErrorCode): void { - setUI(new LoggingUI()); - getUI().intro('PostHog Wizard'); - getUI().log.error(message); + consoleLog.intro('PostHog Wizard'); + consoleLog.log.error(message); if (code) emitWizardError({ code, message }); process.exit(1); } @@ -124,9 +122,8 @@ export function keyPrefixWarning(apiKey: string | undefined): string | null { function warnOnUnexpectedKeyPrefix(apiKey: string | undefined): void { const message = keyPrefixWarning(apiKey); if (!message) return; - setUI(new LoggingUI()); - getUI().intro('PostHog Wizard'); - getUI().log.warn(message); + consoleLog.intro('PostHog Wizard'); + consoleLog.log.warn(message); } /** @@ -137,12 +134,11 @@ function warnOnUnexpectedKeyPrefix(apiKey: string | undefined): void { async function provisionForSignup( options: Options, ): Promise<{ personalApiKey: string; projectId: string }> { - setUI(new LoggingUI()); - getUI().intro('PostHog Wizard'); + consoleLog.intro('PostHog Wizard'); const signupRegion = ((options.region as string) || 'us').toUpperCase() as | 'US' | 'EU'; - getUI().log.info( + consoleLog.log.info( `Provisioning new PostHog account for ${String( options.email, )} in ${signupRegion}...`, @@ -158,21 +154,21 @@ async function provisionForSignup( ); } catch (error) { const msg = error instanceof Error ? error.message : String(error); - getUI().log.error(`Provisioning failed: ${msg}`); + consoleLog.log.error(`Provisioning failed: ${msg}`); throw error; } if (!result.personalApiKey) { - getUI().log.error( + consoleLog.log.error( 'Provisioning succeeded but no personal API key was returned — cannot continue install.', ); throw new Error('provisioning returned no personal API key'); } - getUI().log.success('Account ready.'); - getUI().log.info(` Project API Key: ${result.projectApiKey}`); - getUI().log.info(` Personal API Key: ${result.personalApiKey}`); - getUI().log.info(` Host: ${result.host}`); + consoleLog.log.success('Account ready.'); + consoleLog.log.info(` Project API Key: ${result.projectApiKey}`); + consoleLog.log.info(` Personal API Key: ${result.personalApiKey}`); + consoleLog.log.info(` Host: ${result.host}`); return { personalApiKey: result.personalApiKey, projectId: result.projectId, diff --git a/src/cli/commands/basic-integration/index.ts b/src/cli/commands/basic-integration/index.ts index 799912bf4..abf9261d1 100644 --- a/src/cli/commands/basic-integration/index.ts +++ b/src/cli/commands/basic-integration/index.ts @@ -54,7 +54,7 @@ export const basicIntegrationCommand: Command = { void (async () => { // ── The CI / headless division ─────────────────────────────────── // --ci (dev/test only) and the experimental headless flag (the - // published-build, non-interactive path; see @lib/headless-mode) both + // published-build, non-interactive path; see @shared/headless-mode) both // request a non-interactive install, but route to dedicated entry points // — runHeadlessInstall vs runCIInstall (and below them runWizardHeadless // vs runWizardCI). Both share one pipeline today but are separate diff --git a/src/cli/commands/basic-integration/interactive.ts b/src/cli/commands/basic-integration/interactive.ts index 2db092bd6..8cc62980b 100644 --- a/src/cli/commands/basic-integration/interactive.ts +++ b/src/cli/commands/basic-integration/interactive.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { runWizard } from '@cli/runners'; -import { posthogIntegrationConfig } from '@programs/posthog-integration/index'; +import { config as posthogIntegrationConfig } from '@programs/posthog-integration'; /** Default flow: run the posthog-integration program through the TUI. */ export function runInteractive(argv: Arguments): void { diff --git a/src/cli/commands/basic-integration/non-interactive.ts b/src/cli/commands/basic-integration/non-interactive.ts index 30877ee93..d4031931a 100644 --- a/src/cli/commands/basic-integration/non-interactive.ts +++ b/src/cli/commands/basic-integration/non-interactive.ts @@ -1,11 +1,11 @@ -import { getUI } from '@ui'; +import { consoleLog } from '@shared/console-log'; import { ErrorCodes } from '@shared/errors'; import { emitWizardError } from '@shared/errors'; /** Print the "needs a TTY" error and exit. Used when no `--ci` flag and no TTY. */ export function failNonInteractive(): void { - getUI().intro('PostHog Wizard'); - getUI().log.error( + consoleLog.intro('PostHog Wizard'); + consoleLog.log.error( 'This installer requires an interactive terminal (TTY) to run.\n' + 'It appears you are running in a non-interactive environment.\n' + 'Please run the wizard in an interactive terminal.\n\n' + diff --git a/src/cli/commands/basic-integration/playground.ts b/src/cli/commands/basic-integration/playground.ts index ae356e4ec..4a3907fd9 100644 --- a/src/cli/commands/basic-integration/playground.ts +++ b/src/cli/commands/basic-integration/playground.ts @@ -1,7 +1,8 @@ import { VERSION } from '@shared/version'; -import { startPlayground } from '@tui/playground/start-playground'; +import { runPlayground as runTuiPlayground } from '@tui'; +import { exitWith } from '@cli/runners'; /** Launch the TUI primitives playground. */ export function runPlayground(): void { - startPlayground(VERSION); + exitWith(() => runTuiPlayground(VERSION)); } diff --git a/src/cli/commands/basic-integration/skill.ts b/src/cli/commands/basic-integration/skill.ts index 509a6d92c..fd03d3a2a 100644 --- a/src/cli/commands/basic-integration/skill.ts +++ b/src/cli/commands/basic-integration/skill.ts @@ -1,7 +1,7 @@ import type { Arguments } from 'yargs'; import { POSTHOG_DOCS_URL } from '@shared/constants'; import { runWizard, runWizardCI } from '@cli/runners'; -import { createSkillProgram } from '@programs/shared/skill-program'; +import { createSkillProgram } from '@programs'; /** Run an arbitrary context-mill skill by id (`wizard skill `, headless with `--ci`). */ export function runSkillMode(argv: Arguments): void { diff --git a/src/cli/commands/dispatch-family.ts b/src/cli/commands/dispatch-family.ts index 6049bd098..fc982b41a 100644 --- a/src/cli/commands/dispatch-family.ts +++ b/src/cli/commands/dispatch-family.ts @@ -1,11 +1,11 @@ import type { Arguments } from 'yargs'; -import { auditConfig } from '@programs/audit/index'; -import { AUDIT_CHECKS_FILE } from '@programs/audit/types'; +import { AUDIT_CHECKS_FILE } from '@programs/audit'; import { WIZARD_TOOL_NAMES } from '@agent'; -import { agentSkillConfig } from '@programs/program-registry'; -import { webAnalyticsDoctorConfig } from '@programs/web-analytics-doctor/index'; -import type { ProgramConfig } from '@programs/program-step'; +import { getProgramConfig, Program } from '@programs'; +import { config as agentSkillConfig } from '@programs/agent-skill'; +import { config as webAnalyticsDoctorConfig } from '@programs/web-analytics-doctor'; +import type { ProgramConfig } from '@programs/types'; import { getSkillsBaseUrl } from '@shared/constants'; import { fetchSkillMenu, type CliEntry } from '@shared/skill-menu'; import { analytics } from '@utils/analytics'; @@ -57,14 +57,14 @@ const NATIVE_HANDLERS: Record> = { * Resolve a fetched CliEntry to the ProgramConfig that actually runs it. * Most entries run via the generic agent-skill program with the entry's * `skillId` injected. The comprehensive `audit all` is the one exception — - * skillId 'audit' triggers the specialized auditConfig (custom hooks, + * skillId 'audit' triggers the specialized `audit` program (custom hooks, * content blocks, screens). * * This is the one place that knows a subcommand belongs to `audit`, so the * generic skill program picks up the ledger here rather than for every skill. */ function configForCliEntry(entry: CliEntry, family: string): ProgramConfig { - if (entry.skillId === 'audit') return auditConfig; + if (entry.skillId === 'audit') return getProgramConfig(Program.Audit); return { ...agentSkillConfig, skillId: entry.skillId, diff --git a/src/cli/commands/error-tracking.ts b/src/cli/commands/error-tracking.ts index 962b1d612..898066c66 100644 --- a/src/cli/commands/error-tracking.ts +++ b/src/cli/commands/error-tracking.ts @@ -1,4 +1,4 @@ -import { errorTrackingConfig } from '@programs/error-tracking/index'; +import { config as errorTrackingConfig } from '@programs/error-tracking'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/factories/family-command-factory.ts b/src/cli/commands/factories/family-command-factory.ts index 6b564cc84..752ebfc4b 100644 --- a/src/cli/commands/factories/family-command-factory.ts +++ b/src/cli/commands/factories/family-command-factory.ts @@ -5,7 +5,7 @@ import { buildFamilyPickerChildren, dispatchFamily, pickerChildrenToShow, -} from '@cli/commands/dispatch-family'; +} from '../dispatch-family'; import { getSkillsBaseUrl } from '@shared/constants'; import { fetchSkillMenu } from '@shared/skill-menu'; diff --git a/src/cli/commands/factories/family-picker.ts b/src/cli/commands/factories/family-picker.ts index 7fabbc7fe..89eb3fb62 100644 --- a/src/cli/commands/factories/family-picker.ts +++ b/src/cli/commands/factories/family-picker.ts @@ -17,44 +17,9 @@ */ import type { Arguments } from 'yargs'; -import { Box, Text, render } from 'ink'; -import { createElement } from 'react'; - -import { Colors } from '@tui/styles'; -import { PickerMenu } from '@tui/primitives/PickerMenu'; import { commandKeys, type Command } from '../command'; -interface FamilyPickerAppProps { - parentLabel: string; - options: { label: string; value: Command; hint?: string }[]; - onSelect: (cmd: Command) => void; -} - -function FamilyPickerApp(props: FamilyPickerAppProps) { - return createElement( - Box, - { flexDirection: 'column', paddingX: 1, paddingY: 1 }, - createElement( - Text, - { bold: true, color: Colors.accent }, - props.parentLabel, - ), - createElement(Box, { height: 1 }), - createElement(PickerMenu, { - message: 'Pick a subcommand', - options: props.options, - optionMarginBottom: 1, - onSelect: (value) => { - // PickerMenu in single mode returns one value; only the multi-mode - // signature is the array variant. Narrow defensively. - const cmd = Array.isArray(value) ? value[0] : value; - if (cmd) props.onSelect(cmd); - }, - }), - ); -} - function describe(child: Command): string { // Strip positional syntax (`search ` → `search`) for the picker label. return commandKeys(child.name)[0] ?? ''; @@ -77,37 +42,27 @@ export function orderFamilyChildren(children: readonly Command[]): Command[] { } /** - * Render the picker. Resolves once the user has selected a child; - * dispatching the child's handler is the caller's responsibility (so this - * function stays pure-UI and easy to test by stubbing `render`). + * Render the picker over a family's children. Resolves once the user has + * selected a child; dispatching the child's handler is the caller's + * responsibility. */ -export function chooseFamilyChild( +export async function chooseFamilyChild( parentLabel: string, children: readonly Command[], ): Promise { const ordered = orderFamilyChildren(children); - if (ordered.length === 0) return Promise.resolve(null); + if (ordered.length === 0) return null; - const options = ordered.map((child) => ({ - label: describe(child), - value: child, - hint: child.description, - })); - - return new Promise((resolve) => { - let app: ReturnType | null = null; - const handleSelect = (cmd: Command): void => { - app?.unmount(); - resolve(cmd); - }; - app = render( - createElement(FamilyPickerApp, { - parentLabel, - options, - onSelect: handleSelect, - }), - ); - }); + // Loaded here: a headless run never loads the TUI. + const { renderFamilyPicker } = await import('@tui'); + return renderFamilyPicker( + parentLabel, + ordered.map((child) => ({ + label: describe(child), + value: child, + hint: child.description, + })), + ); } /** diff --git a/src/cli/commands/mcp-analytics.ts b/src/cli/commands/mcp-analytics.ts index 4b36a2f95..b0c8e3384 100644 --- a/src/cli/commands/mcp-analytics.ts +++ b/src/cli/commands/mcp-analytics.ts @@ -1,4 +1,4 @@ -import { mcpAnalyticsConfig } from '@programs/mcp-analytics/index'; +import { config as mcpAnalyticsConfig } from '@programs/mcp-analytics'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/metrics.ts b/src/cli/commands/metrics.ts index 0f0978b1a..4136cb25e 100644 --- a/src/cli/commands/metrics.ts +++ b/src/cli/commands/metrics.ts @@ -1,4 +1,4 @@ -import { metricsConfig } from '@programs/metrics/index'; +import { config as metricsConfig } from '@programs/metrics'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/migrate.ts b/src/cli/commands/migrate.ts index ec937197d..8b8645be2 100644 --- a/src/cli/commands/migrate.ts +++ b/src/cli/commands/migrate.ts @@ -1,4 +1,4 @@ -import { migrationConfig } from '@programs/migration/index'; +import { config as migrationConfig } from '@programs/migration'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/replay-vision.ts b/src/cli/commands/replay-vision.ts index 8b13e19c2..05faae0dc 100644 --- a/src/cli/commands/replay-vision.ts +++ b/src/cli/commands/replay-vision.ts @@ -1,4 +1,4 @@ -import { replayVisionConfig } from '@programs/replay-vision/index'; +import { config as replayVisionConfig } from '@programs/replay-vision'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/revenue.ts b/src/cli/commands/revenue.ts index 76f50c629..da897a250 100644 --- a/src/cli/commands/revenue.ts +++ b/src/cli/commands/revenue.ts @@ -1,4 +1,4 @@ -import { revenueAnalyticsConfig } from '@programs/revenue-analytics/index'; +import { config as revenueAnalyticsConfig } from '@programs/revenue-analytics'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/commands/self-driving.ts b/src/cli/commands/self-driving.ts index 64b7a25bb..afde11198 100644 --- a/src/cli/commands/self-driving.ts +++ b/src/cli/commands/self-driving.ts @@ -1,5 +1,5 @@ import { runWizard, runWizardCI } from '@cli/runners'; -import { selfDrivingConfig } from '@programs/self-driving/index'; +import { config as selfDrivingConfig } from '@programs/self-driving'; import { skillProgramOptions } from './skill-program-options'; import type { Command } from './command'; diff --git a/src/cli/commands/upload-sourcemaps.ts b/src/cli/commands/upload-sourcemaps.ts index bfbfc894b..8559b4a97 100644 --- a/src/cli/commands/upload-sourcemaps.ts +++ b/src/cli/commands/upload-sourcemaps.ts @@ -1,5 +1,5 @@ import { runWizard, runWizardCI } from '@cli/runners'; -import { errorTrackingUploadSourceMapsConfig } from '@programs/error-tracking-upload-source-maps/index'; +import { config as errorTrackingUploadSourceMapsConfig } from '@programs/error-tracking-upload-source-maps'; import { skillProgramOptions } from './skill-program-options'; import type { Command } from './command'; diff --git a/src/cli/commands/warehouse.ts b/src/cli/commands/warehouse.ts index 1b9c52637..b2104f69c 100644 --- a/src/cli/commands/warehouse.ts +++ b/src/cli/commands/warehouse.ts @@ -1,4 +1,4 @@ -import { warehouseSourceConfig } from '@programs/warehouse-source/index'; +import { config as warehouseSourceConfig } from '@programs/warehouse-source'; import type { Command } from './command'; import { nativeCommandFactory } from './factories/native-command-factory'; diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 000000000..2c489fc20 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,41 @@ +/** The CLI's one entry: `main.ts` runs the `wizard` command line through it. */ +import { Wizard } from './wizard'; +import { basicIntegrationCommand } from './commands/basic-integration'; +import { mcpCommand } from './commands/mcp'; +import { mcpAnalyticsCommand } from './commands/mcp-analytics'; +import { replayVisionCommand } from './commands/replay-vision'; +import { aiObservabilityCommand } from './commands/ai-observability'; +import { metricsCommand } from './commands/metrics'; +import { cliCommand } from './commands/cli'; +import { auditCommand } from './commands/audit'; +import { doctorCommand } from './commands/doctor'; +import { migrateCommand } from './commands/migrate'; +import { revenueCommand } from './commands/revenue'; +import { warehouseCommand } from './commands/warehouse'; +import { selfDrivingCommand } from './commands/self-driving'; +import { slackCommand } from './commands/slack'; +import { uploadSourcemapsCommand } from './commands/upload-sourcemaps'; +import { errorTrackingCommand } from './commands/error-tracking'; +import { skillCommand } from './commands/skill'; + +/** Register every command and run the one `process.argv` names. */ +export function runCli(): void { + Wizard.use(basicIntegrationCommand) + .use(mcpCommand) + .use(mcpAnalyticsCommand) + .use(replayVisionCommand) + .use(aiObservabilityCommand) + .use(metricsCommand) + .use(cliCommand) + .use(auditCommand) + .use(doctorCommand) + .use(migrateCommand) + .use(revenueCommand) + .use(warehouseCommand) + .use(selfDrivingCommand) + .use(slackCommand) + .use(uploadSourcemapsCommand) + .use(errorTrackingCommand) + .use(skillCommand) + .init(); +} diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index 7e2ee083d..1f051d330 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -5,6 +5,7 @@ import { IS_PRODUCTION_BUILD } from '@env'; import { Harness, Sequence } from '@shared/constants'; import { regionOption } from '@shared/headless-mode'; import { initLocalDev, localMcpSkillsNotice } from '@shared/local-dev'; +import { configureLogFile } from '@utils/debug'; import { toCommandModule, type Command } from './commands/command'; import { ErrorCodes } from '@shared/errors'; import { emitWizardError } from '@shared/errors'; @@ -24,6 +25,11 @@ export const GLOBAL_OPTIONS = { describe: 'Enable verbose logging\nenv: POSTHOG_WIZARD_DEBUG', type: 'boolean' as const, }, + 'log-file': { + describe: + 'Write the debug log to this file (default: posthog-wizard.log in the temp dir)\nenv: POSTHOG_WIZARD_LOG_FILE', + type: 'string' as const, + }, signup: { default: false, describe: @@ -54,7 +60,7 @@ export const GLOBAL_OPTIONS = { // ── Internal modes ───────────────────────────────────────────────── // Hidden from `--help`. // NB: the experimental headless flag is deliberately NOT global. Supported - // commands declare it through `headlessOption` in @lib/headless-mode. + // commands declare it through `headlessOption` in @shared/headless-mode. 'base-url': { describe: 'Override the PostHog base URL (e.g. http://localhost:8010), bypassing region resolution. Pins the API host, cloud URL, and OAuth server.\nenv: POSTHOG_WIZARD_BASE_URL', @@ -96,7 +102,7 @@ export class Wizard { // flag. init() additionally detects it up front to print a clearer message. // The published-build, non-interactive path is the experimental headless // flag, declared per-command through `headlessOption` (see - // @lib/headless-mode). CI needs `region` globally because the workbench + // @shared/headless-mode). CI needs `region` globally because the workbench // passes it to every command. --ci and headless stay separate so their // behavior can diverge. if (!IS_PRODUCTION_BUILD) { @@ -178,6 +184,9 @@ export class Wizard { // Middleware rather than an argv scan so the env path is covered too, // and it runs before any TUI takes the terminal. .middleware((argv) => { + if (typeof argv.logFile === 'string' && argv.logFile) { + configureLogFile({ path: argv.logFile, pin: true }); + } // The one place local targets are resolved; everything downstream reads // getLocalDev(). initLocalDev(argv); diff --git a/vitest.config.ts b/vitest.config.ts index 5fc143e88..0c17270cd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { tmpdir } from 'os'; import { defineConfig, type Plugin } from 'vitest/config'; const r = (...p: string[]) => path.resolve(__dirname, ...p); @@ -36,6 +37,7 @@ const AGENT_TESTS = [`src/agent/**/${TESTS}`]; const PROGRAM_TESTS = [`src/programs/**/${TESTS}`]; const TOOL_TESTS = [`src/tools/**/${TESTS}`]; const TUI_TESTS = [`src/tui/**/${TESTS}`]; +const HEADLESS_TESTS = [`src/headless/**/${TESTS}`]; const CLI_TESTS = [`src/cli/**/${TESTS}`]; const HARNESS_TESTS = [ `e2e-harness/${TESTS}`, @@ -73,6 +75,11 @@ export default defineConfig({ replacement: r('__mocks__/@posthog/warlock.ts'), }, { find: /^ink$/, replacement: r('__mocks__/ink.ts') }, + // The real Ink, for the tests that render: `vi.importActual('ink')` gets the stub above. + { + find: /^ink-actual$/, + replacement: r('node_modules/ink/build/index.js'), + }, { find: /^@shared\/(.*)$/, replacement: `${r('src/shared')}/$1` }, { find: /^@agent$/, replacement: r('src/agent/index.ts') }, { find: /^@agent\/types$/, replacement: r('src/agent/types.ts') }, @@ -80,38 +87,51 @@ export default defineConfig({ { find: /^@programs$/, replacement: r('src/programs/index.ts') }, { find: /^@programs\/types$/, replacement: r('src/programs/types.ts') }, { find: /^@programs\/(.*)$/, replacement: `${r('src/programs')}/$1` }, + { find: /^@tools$/, replacement: r('src/tools/index.ts') }, // Path aliases — mirror tsconfig `paths`. + { find: /^@env$/, replacement: r('src/env.ts') }, { find: /^@host\/(.*)$/, replacement: `${r('src/host')}/$1` }, - { find: /^@tools\/(.*)$/, replacement: `${r('src/tools')}/$1` }, + { find: /^@tui$/, replacement: r('src/tui/index.ts') }, { find: /^@tui\/(.*)$/, replacement: `${r('src/tui')}/$1` }, - { find: /^@headless\/(.*)$/, replacement: `${r('src/headless')}/$1` }, + { find: /^@headless$/, replacement: r('src/headless/index.ts') }, + { find: /^@cli$/, replacement: r('src/cli/index.ts') }, { find: /^@cli\/(.*)$/, replacement: `${r('src/cli')}/$1` }, - { find: /^@env$/, replacement: r('src/env.ts') }, - { find: /^@lib\/(.*)$/, replacement: `${r('src/lib')}/$1` }, { find: /^@e2e-harness\/(.*)$/, replacement: `${r('e2e-harness')}/$1` }, { find: /^@utils\/(.*)$/, replacement: `${r('src/shared/utils')}/$1` }, - { find: /^@ui$/, replacement: r('src/ui/index.ts') }, - { find: /^@ui\/(.*)$/, replacement: `${r('src/ui')}/$1` }, - { find: /^@steps$/, replacement: r('src/steps/index.ts') }, - { find: /^@steps\/(.*)$/, replacement: `${r('src/steps')}/$1` }, ], }, test: { globals: true, environment: 'node', + // Tests log to their own file, not the one real runs share. + env: { + POSTHOG_WIZARD_LOG_FILE: path.join( + tmpdir(), + `posthog-wizard-vitest-${process.pid}.log`, + ), + }, projects: [ project('agent', AGENT_TESTS), project('programs', PROGRAM_TESTS), project('tools', TOOL_TESTS), project('tui', TUI_TESTS), + project('headless', HEADLESS_TESTS), project('cli', CLI_TESTS), project('harness', HARNESS_TESTS), project( - 'legacy', - // The second glob keeps the pre-split behavior: a test file outside - // a __tests__ directory still runs, here, rather than nowhere. + 'shared', + // Shared, the host layer and anything else no layer project claims. + // The second glob runs a test file outside a __tests__ directory here, + // rather than nowhere. [`src/**/${TESTS}`, 'src/**/*.{test,spec}.{js,jsx,ts,tsx}'], - [...AGENT_TESTS, ...PROGRAM_TESTS, ...TOOL_TESTS, ...TUI_TESTS, ...CLI_TESTS], + [ + ...AGENT_TESTS, + ...PROGRAM_TESTS, + ...TOOL_TESTS, + ...TUI_TESTS, + ...HEADLESS_TESTS, + ...CLI_TESTS, + ], ), ], coverage: {