diff --git a/.claude/skills/adding-skill-program/SKILL.md b/.claude/skills/adding-skill-program/SKILL.md index 86044e94f..883d63108 100644 --- a/.claude/skills/adding-skill-program/SKILL.md +++ b/.claude/skills/adding-skill-program/SKILL.md @@ -125,7 +125,10 @@ programs. Compose real work through `ProgramStep.run`, with `onRunPrep` and `targetDir` when needed. The [integration run step](../../../src/lib/programs/posthog-integration/index.ts) and [self-driving](../../../src/lib/programs/self-driving/) demonstrate this. -Composed sub-runs are structurally linear; orchestrators cannot nest. +Composed sub-runs are structurally linear; orchestrators cannot nest. A host run +step without `run` can also set `targetDir` and `onRunPrep` to scope the +program's own agent to a picked project and keep its sequence, as +[error-tracking](../../../src/lib/programs/error-tracking/) does. ## Validate the affected path diff --git a/bin.ts b/bin.ts index 2f0b20363..2a3d25974 100644 --- a/bin.ts +++ b/bin.ts @@ -80,6 +80,7 @@ import { warehouseCommand } from './src/commands/warehouse'; import { selfDrivingCommand } from './src/commands/self-driving'; import { slackCommand } from './src/commands/slack'; import { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps'; +import { errorTrackingCommand } from './src/commands/error-tracking'; import { skillCommand } from './src/commands/skill'; import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from './src/lib/agent/claude-settings'; @@ -114,5 +115,6 @@ Wizard.use(basicIntegrationCommand) .use(selfDrivingCommand) .use(slackCommand) .use(uploadSourcemapsCommand) + .use(errorTrackingCommand) .use(skillCommand) .init(); diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 71918cdca..d2fd74df1 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -83,6 +83,8 @@ export const NO_ACTION_SCREENS: ReadonlySet = new Set([ ScreenId.SelfDrivingIntegrationCheck, ScreenId.SelfDrivingIntegrationDetect, ScreenId.SelfDrivingHandoff, + // The e2e host injects the pick, as it does for self-driving's detect screen. + ScreenId.ErrorTrackingDetect, Overlay.ManagedSettings, Overlay.AuthError, Overlay.SessionTimeout, @@ -108,6 +110,7 @@ export const ACTION_REGISTRY: Partial> = { [ScreenId.AgentSkillIntro]: [confirmSetupAction], [ScreenId.AiObservabilityIntro]: [confirmSetupAction], [ScreenId.MetricsIntro]: [confirmSetupAction], + [ScreenId.ErrorTrackingIntro]: [confirmSetupAction], [ScreenId.AuditIntro]: [confirmSetupAction], [ScreenId.DoctorIntro]: [confirmSetupAction], [ScreenId.WarehouseIntro]: [confirmSetupAction], diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 360bd3ec6..a09fd96bc 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -283,6 +283,7 @@ export function decideE2eAction( case ScreenId.AgentSkillIntro: case ScreenId.AiObservabilityIntro: case ScreenId.MetricsIntro: + case ScreenId.ErrorTrackingIntro: case ScreenId.AuditIntro: case ScreenId.SourceMapsIntro: case ScreenId.DoctorIntro: diff --git a/e2e-harness/profiles.ts b/e2e-harness/profiles.ts index c846ea2b3..9134faba3 100644 --- a/e2e-harness/profiles.ts +++ b/e2e-harness/profiles.ts @@ -25,6 +25,7 @@ import metricsE2e from '@lib/programs/metrics/test/e2e.json'; import replayVisionE2e from '@lib/programs/replay-vision/test/e2e.json'; import selfDrivingE2e from '@lib/programs/self-driving/test/e2e.json'; import sourceMapsE2e from '@lib/programs/error-tracking-upload-source-maps/test/e2e.json'; +import errorTrackingE2e from '@lib/programs/error-tracking/test/e2e.json'; import warehouseSourceE2e from '@lib/programs/warehouse-source/test/e2e.json'; const PROFILES: Partial> = { @@ -36,6 +37,7 @@ const PROFILES: Partial> = { [Program.SelfDriving]: selfDrivingE2e.profile as WizardE2eProfile, [Program.ErrorTrackingUploadSourceMaps]: sourceMapsE2e.profile as WizardE2eProfile, + [Program.ErrorTracking]: errorTrackingE2e.profile as WizardE2eProfile, [Program.WarehouseSource]: warehouseSourceE2e.profile as WizardE2eProfile, }; @@ -46,6 +48,7 @@ const VARIATIONS: Partial> = { 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[], }; diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 3a78ad0b7..4ae89aeea 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -25,6 +25,7 @@ import { } from '@lib/programs/program-registry'; import type { Harness, Sequence } from '@lib/constants'; import { buildSession } from '@lib/wizard-session'; +import { initLocalDev } from '@lib/local-dev'; import { configureGatewayFromCIEnvironment } from '@lib/gateway-session'; import { runAgent } from '@lib/agent/agent-runner'; import { authenticate } from '@lib/agent/runner/shared/authenticate'; @@ -35,6 +36,7 @@ import { detectFramework } from '@lib/detection/index'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import type { Integration } from '@lib/constants'; import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/detect'; +import { ERROR_TRACKING_PROJECT_PATH_KEY } from '@lib/programs/error-tracking/detect-agentic'; import { detectSourceMapsPrerequisites, SOURCE_MAPS_CONTEXT_KEYS, @@ -196,6 +198,17 @@ async function main() { // requires-interactive-mode the moment they need to ask a question. process.env.WIZARD_ASK_AUTODRIVE = '1'; + // The bin initializes the local-dev singleton from its yargs middleware; + // this host bypasses yargs, so `getSkillsBaseUrl()` would silently resolve + // to production even when the session carries the local flags. Initialize it + // here from the same env-backed spellings, before anything reads it. + initLocalDev({ + localDev: process.env.POSTHOG_WIZARD_LOCAL_DEV === 'true', + localMcp: envFlag('POSTHOG_WIZARD_LOCAL_MCP'), + localContextMill: envFlag('POSTHOG_WIZARD_LOCAL_CONTEXT_MILL'), + localPosthog: envFlag('POSTHOG_WIZARD_LOCAL_POSTHOG'), + }); + const { store } = startTUI(VERSION, programId); store.session = buildSession({ installDir: process.env.APP_DIR!, @@ -262,29 +275,35 @@ async function main() { await store.getGate('health-check'); // Mirror run-wizard's composed walk for programs whose steps splice in - // their own run steps (self-driving: detect → integrate → handoff → run). + // their own run steps (self-driving: detect → integrate → handoff → run), + // or scope their own run to a picked project (error-tracking). // `authenticate` here resolves the phx key, not OAuth, since the session is // built with ci + apiKey. - if (programConfig.steps.some((s) => s.run)) { + if (programConfig.steps.some((s) => s.run || s.targetDir)) { + const runSessionFor = async ( + step: (typeof programConfig.steps)[number], + ) => { + const live = store.session; + const runSession = step.targetDir + ? { + ...live, + installDir: step.targetDir(live), + frameworkContext: { ...live.frameworkContext }, + } + : live; + if (step.onRunPrep) await step.onRunPrep(runSession); + return runSession; + }; for (const step of programConfig.steps) { if (step.screenId === 'outro') break; if (step.show && !step.show(store.session)) continue; if (step.screenId === 'auth') { await authenticate(store.session, programConfig.id); } else if (step.run) { - const live = store.session; - const runSession = step.targetDir - ? { - ...live, - installDir: step.targetDir(live), - frameworkContext: { ...live.frameworkContext }, - } - : live; - if (step.onRunPrep) await step.onRunPrep(runSession); - await step.run(runSession); + await step.run(await runSessionFor(step)); store.completeRunStep(step.id); } else if (step.screenId === 'run') { - await runAgent(programConfig, store.session); + await runAgent(programConfig, await runSessionFor(step)); } else if (step.isComplete) { await store.waitUntil(step.isComplete); } @@ -466,6 +485,25 @@ async function main() { continue; } + // Headless error-tracking detect: the same pick injection as above, into + // the error-tracking path key, so the run is scoped to the picked app. + if ( + state.currentScreen === ScreenId.ErrorTrackingDetect && + state.session.integration == null + ) { + const pick = await pickIntegrationTarget(store.session.installDir); + if (!pick) { + mark('error-tracking detect found no framework to set up'); + process.exit(1); + } + store.setFrameworkContext(ERROR_TRACKING_PROJECT_PATH_KEY, pick.path); + store.setFrameworkConfig( + pick.integration, + FRAMEWORK_REGISTRY[pick.integration], + ); + continue; + } + // Headless source-maps detect: the screen's candidate list lives in // its own agentic report (React state), so compute the pick here with // the static prerequisite detector — right for a single-app fixture — diff --git a/src/commands/error-tracking.ts b/src/commands/error-tracking.ts new file mode 100644 index 000000000..3db0b2816 --- /dev/null +++ b/src/commands/error-tracking.ts @@ -0,0 +1,15 @@ +import { errorTrackingConfig } from '@lib/programs/error-tracking/index'; + +import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; + +/** + * `wizard error-tracking` — flat skill command, set up error tracking today. + * + * Wires up exception capture and — where the platform needs it — source-map / + * debug-symbol upload. Runs the `error-tracking` orchestrator flow, which + * reuses the integration-v2 install/init mini-agents when the repo has no + * PostHog integration yet, so it works on uninstrumented projects too. + */ +export const errorTrackingCommand: Command = + nativeCommandFactory(errorTrackingConfig); diff --git a/src/lib/agent/runner/__tests__/switchboard.test.ts b/src/lib/agent/runner/__tests__/switchboard.test.ts index 17e2fc17a..eb25de20e 100644 --- a/src/lib/agent/runner/__tests__/switchboard.test.ts +++ b/src/lib/agent/runner/__tests__/switchboard.test.ts @@ -68,6 +68,7 @@ describe('switchboard PROGRAM_BINDINGS', () => { if (program === 'error-tracking-upload-source-maps') continue; // pinned below if (program === 'metrics') continue; // pinned below if (program === 'replay-vision') continue; // pinned below + if (program === 'error-tracking') continue; // pinned below expect(resolveBinding({ program, flags: {} })).toEqual(DEFAULT_RESOLVED); } }); @@ -117,6 +118,17 @@ describe('switchboard PROGRAM_BINDINGS', () => { }, trace: { harness: 'binding', model: 'binding', sequence: 'binding' }, }, + { + name: 'binds error-tracking to the orchestrator on pi; stage models come from the flow frontmatter', + ctx: { program: 'error-tracking', flags: {} }, + binding: { + sequence: Sequence.orchestrator, + harness: Harness.pi, + model: DEFAULT_AGENT_MODEL, + thinkingLevel: undefined, + }, + trace: { harness: 'binding', model: 'binding', sequence: 'binding' }, + }, { name: 'falls back to DEFAULT_BINDING for an unmapped program', ctx: { program: 'not-a-program', flags: {} }, @@ -223,7 +235,7 @@ describe('switchboard composed clamp', () => { }; // The flag routes posthog-integration's harness to pi; the composed // clamp holds every sequence at linear — the orchestrator bindings - // (metrics, replay-vision) included; other axes keep their bindings. + // (metrics, replay-vision, error-tracking) included; other axes keep their bindings. expect(resolveBinding(ctx)).toEqual( program === 'ai-observability' ? { @@ -232,7 +244,7 @@ describe('switchboard composed clamp', () => { model: SONNET_5_MODEL, thinkingLevel: undefined, } - : program === 'metrics' + : program === 'metrics' || program === 'error-tracking' ? { ...DEFAULT_RESOLVED, model: DEFAULT_AGENT_MODEL, diff --git a/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts b/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts index 1b1d88d9f..914cc5663 100644 --- a/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts +++ b/src/lib/agent/runner/harness/pi/__tests__/tools.test.ts @@ -4,7 +4,7 @@ * value — and set_env_values resolves refs host-side into the .env file. */ import { mkdtempSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, vi } from 'vitest'; @@ -287,6 +287,91 @@ describe('pi set_env_values — resolves vault refs host-side', () => { expect(env).toContain(`ZENDESK_TOKEN=${SECRET}`); }); + it('gitignores the env file it just wrote, like the MCP facade does', async () => { + // An iOS/Android project's .gitignore lists xcuserdata or build/, never + // .env — so without this pass the personal API key the flow writes is + // staged by the next `git add`. + const { setEnvValues, workingDirectory } = makeTools({}); + await writeFile(join(workingDirectory, '.gitignore'), 'xcuserdata/\n'); + + await call(setEnvValues, { + filePath: '.env', + values: { POSTHOG_CLI_HOST: 'https://us.posthog.com' }, + }); + + const gitignore = await readFile( + join(workingDirectory, '.gitignore'), + 'utf8', + ); + expect(gitignore.split('\n')).toContain('.env'); + expect(gitignore).toContain('xcuserdata/'); + }); + + it('refuses POSTHOG_KEY in a project that does not read it', async () => { + const { setEnvValues, workingDirectory } = makeTools({}); + + const result = await call(setEnvValues, { + filePath: '.env', + values: { POSTHOG_KEY: 'phc_test' }, + }); + + expect(textOf(result)).toContain('is not a valid PostHog env var name'); + await expect( + readFile(join(workingDirectory, '.env'), 'utf8'), + ).rejects.toThrow(); + }); + + it('keeps POSTHOG_KEY when the project code already reads it', async () => { + // Refusing here forces a rename of working code, and a deploy step that + // still passes POSTHOG_KEY then starts the app with an empty token. + const { setEnvValues, workingDirectory } = makeTools({}); + await mkdir(join(workingDirectory, 'src')); + await writeFile( + join(workingDirectory, 'src', 'index.ts'), + "const client = new PostHog(process.env.POSTHOG_KEY ?? '');\n", + ); + + const result = await call(setEnvValues, { + filePath: '.env', + values: { POSTHOG_KEY: 'phc_test' }, + }); + + expect(textOf(result)).toContain('Wrote 1 key(s)'); + expect(await readFile(join(workingDirectory, '.env'), 'utf8')).toMatch( + /^POSTHOG_KEY=.*phc_test/m, + ); + }); + + it('keeps POSTHOG_KEY when only a startup script reads it', async () => { + const { setEnvValues, workingDirectory } = makeTools({}); + await writeFile( + join(workingDirectory, 'start.sh'), + '#!/bin/sh\nAPP_TOKEN="$POSTHOG_KEY" exec ./server\n', + ); + + const result = await call(setEnvValues, { + filePath: '.env', + values: { POSTHOG_KEY: 'phc_test' }, + }); + + expect(textOf(result)).toContain('Wrote 1 key(s)'); + }); + + it('does not count NEXT_PUBLIC_POSTHOG_KEY as a read of POSTHOG_KEY', async () => { + const { setEnvValues, workingDirectory } = makeTools({}); + await writeFile( + join(workingDirectory, 'providers.tsx'), + 'posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!);\n', + ); + + const result = await call(setEnvValues, { + filePath: '.env', + values: { POSTHOG_KEY: 'phc_test' }, + }); + + expect(textOf(result)).toContain('is not a valid PostHog env var name'); + }); + it('mixed values map: literal + secretRef written together, secret still never in output', async () => { const { wizardAsk, setEnvValues, workingDirectory } = makeTools({ token: SECRET, diff --git a/src/lib/agent/runner/harness/pi/tools.ts b/src/lib/agent/runner/harness/pi/tools.ts index 35b5aa8c5..75c695cbb 100644 --- a/src/lib/agent/runner/harness/pi/tools.ts +++ b/src/lib/agent/runner/harness/pi/tools.ts @@ -26,6 +26,7 @@ import { WIZARD_TOOL_NAMES, checkEnvKeys as checkEnvKeysCore, createAskAccounting, + ensureGitignoreCoverage, fetchSkillMenu, installSkillById, mergeEnvValues, @@ -33,6 +34,7 @@ import { resolveEnvPath, resolveEnvSecretRefs, templateEnvWriteRefusal, + legacyKeyNameRefusal, vaultSensitiveAnswers, WIZARD_ASK_SENSITIVE_DESCRIPTION, WIZARD_ASK_SUBJECT_DESCRIPTION, @@ -209,14 +211,11 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { ), }), async execute(_id, args) { - const forbidden = Object.keys(args.values).find( - (k) => k.toUpperCase() === 'POSTHOG_KEY', + const keyRefusal = legacyKeyNameRefusal( + workingDirectory, + Object.keys(args.values), ); - if (forbidden) { - return text( - `Error: "${forbidden}" is not a valid PostHog env var name. Use the framework-specific key (e.g. NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN).`, - ); - } + if (keyRefusal) return text(keyRefusal); // Resolve secret refs host-side; the value never reaches the agent. const resolution = resolveEnvSecretRefs(args.values, secretVault); if (!resolution.ok) { @@ -241,6 +240,9 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] { if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile(resolved, merged, 'utf8'); + // Same post-write pass as the MCP facade: a credential file the + // project does not ignore yet gets committed by the next `git add`. + ensureGitignoreCoverage(workingDirectory, path.basename(resolved)); logToFile( `[pi] set_env_values: ${resolved} keys=${Object.keys(args.values).join( ',', diff --git a/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts b/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts index c4987133b..733df4ebd 100644 --- a/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts +++ b/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts @@ -280,9 +280,9 @@ describe('isolation — everything on at once', () => { model: SONNET_5_MODEL, thinkingLevel: undefined, }); - } else if (program === 'metrics') { - // Orchestrator + pi from its OWN binding, not the flag; stage models - // are pinned context-mill side in the flow frontmatter. + } else if (program === 'metrics' || program === 'error-tracking') { + // Orchestrator + pi from their OWN bindings, not the flag; stage + // models are pinned context-mill side in the flow frontmatter. expect(resolved).toEqual({ ...ORCHESTRATOR_PI_DEFAULT, model: DEFAULT_AGENT_MODEL, diff --git a/src/lib/agent/runner/switchboard/index.ts b/src/lib/agent/runner/switchboard/index.ts index 232d4977a..3476a03c7 100644 --- a/src/lib/agent/runner/switchboard/index.ts +++ b/src/lib/agent/runner/switchboard/index.ts @@ -148,6 +148,14 @@ export const PROGRAM_BINDINGS: Partial> = { harness: Harness.anthropic, model: DEFAULT_AGENT_MODEL, }, + // Orchestrator on pi, like metrics. The binding routes only; every stage's + // model and effort are pinned context-mill side in the flow's frontmatter + // (`model_pi`/`effort_pi`: terra seed, install and init, sol tasks, luna report). + 'error-tracking': { + sequence: Sequence.orchestrator, + harness: Harness.pi, + model: DEFAULT_AGENT_MODEL, + }, 'ai-observability': { sequence: Sequence.linear, harness: Harness.anthropic, diff --git a/src/lib/detection/project-scope.ts b/src/lib/detection/project-scope.ts index 97790ce81..73279b075 100644 --- a/src/lib/detection/project-scope.ts +++ b/src/lib/detection/project-scope.ts @@ -12,6 +12,7 @@ import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { AGENTIC_DETECTION_TIMEOUT_MS, + Integration, WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, } from '@lib/constants'; import type { WizardSession } from '@lib/wizard-session'; @@ -24,6 +25,38 @@ const INTEGRATION_TARGETS: DetectTarget[] = Object.entries( FRAMEWORK_REGISTRY, ).map(([id, config]) => ({ id, name: config.metadata.name })); +const INTEGRATION_IDS = new Set(Object.values(Integration)); + +/** A scanned project matched to a wizard framework. Each program classifies it with its own rule. */ +export type IntegrationCandidate = { + /** Path relative to the repo root ("." for the root). */ + path: string; + /** Human-readable framework the agent detected (e.g. "Next.js"). */ + framework: string; + /** The wizard framework the project matches, else null. */ + integration: Integration | null; + /** Whether a PostHog SDK is already installed in this project. */ + hasPostHog: boolean; + /** The scan's pick for the main app. Always false unless the scan set `recommend`. */ + recommended: boolean; +}; + +/** Match each project of an integration scan to a wizard framework. */ +export function toIntegrationCandidates( + report: AgenticDetectionReport, +): IntegrationCandidate[] { + return report.projects.map((p) => ({ + path: p.path, + framework: p.framework, + integration: + p.targetId && INTEGRATION_IDS.has(p.targetId) + ? (p.targetId as Integration) + : null, + hasPostHog: p.hasPostHog, + recommended: p.recommended === true, + })); +} + /** Run the agentic detector for the wizard's integration frameworks — the single home of targets + purpose. */ export async function detectIntegrationProjects( session: WizardSession, diff --git a/src/lib/oauth/__tests__/program-scopes.test.ts b/src/lib/oauth/__tests__/program-scopes.test.ts index f6ef4453f..773a7f15d 100644 --- a/src/lib/oauth/__tests__/program-scopes.test.ts +++ b/src/lib/oauth/__tests__/program-scopes.test.ts @@ -47,6 +47,15 @@ describe('replay-vision scopes', () => { }); }); +/** Error tracking relies on SDK autocapture and never changes team settings. */ +describe('error-tracking scopes', () => { + it('does not request product enablement', () => { + expect(getOAuthScopesForProgram('error-tracking')).not.toContain( + 'product_enablement:write', + ); + }); +}); + /** * The signup path mints a token from these scopes. A program's additions * must reach its own provisioned tokens and no one else's. diff --git a/src/lib/programs/__tests__/error-tracking.test.ts b/src/lib/programs/__tests__/error-tracking.test.ts new file mode 100644 index 000000000..7cf0561d8 --- /dev/null +++ b/src/lib/programs/__tests__/error-tracking.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { ProgramRun } from '@lib/agent/runner/shared/types'; +import { Integration } from '@lib/constants'; +import type { AgenticDetectionReport } from '@lib/detection/agentic'; +import { detectFramework } from '@lib/detection/index'; +import { ErrorCodes } from '@lib/errors'; +import { ERROR_TRACKING_TIPS } from '@lib/programs/error-tracking/content/tips'; +import { + ERROR_TRACKING_PROJECT_PATH_KEY, + toErrorTrackingReport, +} from '@lib/programs/error-tracking/detect-agentic'; +import { + errorTrackingConfig, + SYMBOL_UPLOAD_CLI_FRAMEWORKS, +} from '@lib/programs/error-tracking/index'; +import { VARIANTS_REQUIRING_POSTHOG_CLI } from '@lib/programs/error-tracking-upload-source-maps/detect'; +import { preinstallPostHogCliOnce } from '@lib/programs/shared/posthog-cli-preinstall'; +import type { WizardSession } from '@lib/wizard-session'; +import { analytics } from '@utils/analytics'; +import { wizardAbort } from '@utils/wizard-abort'; + +vi.mock('@lib/detection/index', async (importOriginal) => ({ + ...(await importOriginal()), + detectFramework: vi.fn(), +})); +vi.mock('@lib/detection/project-scope', async (importOriginal) => ({ + ...(await importOriginal()), + scopeInstallDirToProject: vi.fn(), + detectIntegrationProjects: vi.fn(), +})); +vi.mock('@lib/programs/shared/posthog-cli-preinstall', () => ({ + preinstallPostHogCliOnce: vi.fn(), +})); +vi.mock('@utils/wizard-abort', async (importOriginal) => ({ + ...(await importOriginal()), + wizardAbort: vi.fn(), +})); + +const resolveRun = errorTrackingConfig.run as ( + session: WizardSession, +) => Promise; + +const step = (id: string) => errorTrackingConfig.steps.find((s) => s.id === id); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(analytics, 'wizardCapture').mockImplementation(() => undefined); +}); + +describe('error-tracking program', () => { + test('runs the error-tracking agent flow', () => { + expect(errorTrackingConfig.agentFlow).toBe('error-tracking'); + }); + + test('declares ci prerequisite work for headless runs', () => { + expect(errorTrackingConfig.ciPreRun).toBeDefined(); + }); + + test('shows the program-specific intro screen', () => { + expect(step('intro')?.screenId).toBe('error-tracking-intro'); + }); + + test('pre-installs no skill — the flow resolves variants per framework', async () => { + // There is no bare `error-tracking` menu entry; a seeded skillId would + // send the linear path to a skill-not-found abort and mislead the intro. + expect(errorTrackingConfig.skillId).toBeUndefined(); + const run = await resolveRun({ integration: null } as WizardSession); + expect(run.skillId).toBeUndefined(); + }); + + test('picks the project after login and before the run', () => { + const ids = errorTrackingConfig.steps.map((s) => s.id); + expect(ids.indexOf('auth')).toBeLessThan(ids.indexOf('detect')); + expect(ids.indexOf('detect')).toBeLessThan(ids.indexOf('run')); + expect(step('detect')?.screenId).toBe('error-tracking-detect'); + }); + + test('runs the agent in the picked project, else the repo root', () => { + const targetDir = step('run')?.targetDir; + const picked = { + installDir: '/repo', + frameworkContext: { [ERROR_TRACKING_PROJECT_PATH_KEY]: 'apps/web' }, + } as unknown as WizardSession; + const unpicked = { + installDir: '/repo', + frameworkContext: {}, + } as unknown as WizardSession; + + expect(targetDir?.(picked)).toBe('/repo/apps/web'); + expect(targetDir?.(unpicked)).toBe('/repo'); + }); +}); + +describe('error-tracking project picker report', () => { + const scan = ( + projects: AgenticDetectionReport['projects'], + ): AgenticDetectionReport => ({ repoType: 'monorepo', projects }); + + test('offers supported frameworks with or without PostHog installed', () => { + const report = toErrorTrackingReport( + scan([ + { + path: 'apps/web', + framework: 'Next.js', + targetId: 'nextjs', + hasPostHog: true, + }, + { + path: 'apps/api', + framework: 'Express', + targetId: 'javascript_node', + hasPostHog: false, + }, + ]), + ); + + expect(report.projects.map((p) => p.instrumentable)).toEqual([true, true]); + }); + + test('does not offer KMP or an unknown framework', () => { + // KMP skill variants have no framework tag, so preflight would abort the run. + const report = toErrorTrackingReport( + scan([ + { + path: 'shared', + framework: 'KMP', + targetId: 'kmp', + hasPostHog: false, + }, + { path: 'tools', framework: 'Zig', targetId: null, hasPostHog: false }, + ]), + ); + + expect(report.projects.map((p) => p.instrumentable)).toEqual([ + false, + false, + ]); + }); + + test('lists the recommended project first', () => { + const report = toErrorTrackingReport( + scan([ + { + path: 'apps/api', + framework: 'Express', + targetId: 'javascript_node', + hasPostHog: false, + }, + { + path: 'apps/web', + framework: 'Next.js', + targetId: 'nextjs', + hasPostHog: false, + recommended: true, + }, + ]), + ); + + expect(report.projects[0]?.path).toBe('apps/web'); + }); +}); + +describe('error-tracking ciPreRun', () => { + test('stops KMP before it sets the framework', async () => { + vi.mocked(detectFramework).mockResolvedValue(Integration.kmp); + const session = { + installDir: '/tmp/error-tracking-ci', + frameworkContext: {}, + } as unknown as WizardSession; + + await errorTrackingConfig.ciPreRun?.(session); + + expect(wizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.DetectUnsupportedPlatform }), + ); + expect(session.integration).toBeUndefined(); + }); +}); + +describe('error-tracking run config', () => { + test('pre-installs posthog-cli when run resolves, after the project pick', async () => { + await resolveRun({ integration: Integration.swift } as WizardSession); + + expect(preinstallPostHogCliOnce).toHaveBeenCalledWith( + 'error tracking posthog-cli preinstall failed', + { integration: Integration.swift }, + ); + }); + + test('skips the pre-install for platforms without symbol upload', async () => { + await resolveRun({ integration: Integration.nextjs } as WizardSession); + + expect(preinstallPostHogCliOnce).not.toHaveBeenCalled(); + }); +}); + +describe('error-tracking posthog-cli pre-install set', () => { + test('contains only real Integration values', () => { + for (const integration of SYMBOL_UPLOAD_CLI_FRAMEWORKS) { + expect(Object.values(Integration)).toContain(integration); + } + }); + + test('matches the source-maps program set, keyed by Integration', () => { + // Both programs pre-install the CLI for the same platforms. The source-maps + // program keys them by uploader variant, and only `ios` is spelled + // differently (`swift` in Integration). + const expected = [...VARIANTS_REQUIRING_POSTHOG_CLI] + .map((variant) => (variant === 'ios' ? Integration.swift : variant)) + .sort(); + expect([...SYMBOL_UPLOAD_CLI_FRAMEWORKS].sort()).toEqual(expected); + }); +}); + +describe('error-tracking tips', () => { + const replayTip = ERROR_TRACKING_TIPS.find((t) => t.id === 'session-replay'); + const storeFor = (integration: Integration | null) => + ({ session: { integration } } as never); + + test('shows the replay tip only where session replay records', () => { + expect(replayTip?.visible?.(storeFor(Integration.nextjs))).toBe(true); + expect(replayTip?.visible?.(storeFor(Integration.javascriptNode))).toBe( + false, + ); + expect(replayTip?.visible?.(storeFor(null))).toBe(false); + }); +}); diff --git a/src/lib/programs/__tests__/posthog-cli-preinstall.test.ts b/src/lib/programs/__tests__/posthog-cli-preinstall.test.ts new file mode 100644 index 000000000..355fd10b1 --- /dev/null +++ b/src/lib/programs/__tests__/posthog-cli-preinstall.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + preinstallPostHogCliOnce, + resetPostHogCliPreinstallForTests, +} from '@lib/programs/shared/posthog-cli-preinstall'; +import { installOrUpdatePostHogCli } from '@steps/install-cli-steering'; +import { getUI } from '@ui'; +import { analytics } from '@utils/analytics'; + +vi.mock('@steps/install-cli-steering', () => ({ + installOrUpdatePostHogCli: vi.fn(), +})); +vi.mock('@utils/analytics', () => ({ + analytics: { wizardCapture: vi.fn(), captureException: vi.fn() }, +})); +vi.mock('@ui', () => ({ getUI: vi.fn() })); + +const warn = vi.fn(); + +describe('preinstallPostHogCliOnce', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPostHogCliPreinstallForTests(); + vi.mocked(getUI).mockReturnValue({ log: { warn } } as never); + }); + + test('installs at most once per process, whichever program calls', () => { + vi.mocked(installOrUpdatePostHogCli).mockReturnValue({ success: true }); + + preinstallPostHogCliOnce('source maps posthog-cli preinstall failed', { + variant: 'ios', + }); + preinstallPostHogCliOnce('error tracking posthog-cli preinstall failed', { + integration: 'swift', + }); + + expect(installOrUpdatePostHogCli).toHaveBeenCalledTimes(1); + }); + + test('a failed install is an event and a warning, never an exception', () => { + vi.mocked(installOrUpdatePostHogCli).mockReturnValue({ + success: false, + error: 'EACCES', + }); + + preinstallPostHogCliOnce('error tracking posthog-cli preinstall failed', { + integration: 'swift', + }); + + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'error tracking posthog-cli preinstall failed', + { integration: 'swift', error: 'EACCES' }, + ); + expect(analytics.captureException).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('EACCES')); + }); + + test('a successful install stays silent', () => { + vi.mocked(installOrUpdatePostHogCli).mockReturnValue({ success: true }); + + preinstallPostHogCliOnce('error tracking posthog-cli preinstall failed', { + integration: 'swift', + }); + + expect(analytics.wizardCapture).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/programs/error-tracking-upload-source-maps/content/index.tsx b/src/lib/programs/error-tracking-upload-source-maps/content/index.tsx index d8eb3ed90..bb7d1216a 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/content/index.tsx +++ b/src/lib/programs/error-tracking-upload-source-maps/content/index.tsx @@ -123,7 +123,14 @@ const GROUPING: ContentBlock = { ], }; -export const getContentBlocks = (store?: WizardStore): ContentBlock[] => +/** The two lines that say what the run wires. The rest of the deck is shared. */ +export type SourceMapsDeckCopy = { intro: string; wiring: string }; + +/** The source-maps narrative. The error-tracking program reuses it with its own copy. */ +export const buildSourceMapsDeck = ( + store: WizardStore | undefined, + copy: SourceMapsDeckCopy, +): ContentBlock[] => pace([ { content: 'Welcome.', @@ -133,7 +140,7 @@ export const getContentBlocks = (store?: WizardStore): ContentBlock[] => }, { - content: "I'm wiring PostHog Error Tracking into your build.", + content: copy.intro, pause: 5000, }, @@ -165,8 +172,7 @@ export const getContentBlocks = (store?: WizardStore): ContentBlock[] => pause: 6000, }, { - content: - "Right now I'm hooking source-map generation and upload into your build, tied to each release you ship.", + content: copy.wiring, pause: 6000, }, @@ -267,3 +273,10 @@ export const getContentBlocks = (store?: WizardStore): ContentBlock[] => ), }, ]); + +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => + buildSourceMapsDeck(store, { + intro: "I'm wiring PostHog Error Tracking into your build.", + wiring: + "Right now I'm hooking source-map generation and upload into your build, tied to each release you ship.", + }); diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index 80db004f5..c79f245e2 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -15,39 +15,20 @@ import { } from './detect.js'; import { getContentBlocks } from './content/index.js'; import { getUI } from '@ui'; -import { installOrUpdatePostHogCli } from '@steps/install-cli-steering'; -import { analytics } from '@utils/analytics'; +import { preinstallPostHogCliOnce } from '@lib/programs/shared/posthog-cli-preinstall'; const REPORT_FILE = 'posthog-source-maps-report.md'; const DOCS_URL = 'https://posthog.com/docs/error-tracking/upload-source-maps'; -let postHogCliInstallAttempted = false; - /** * Pre-install posthog-cli for variants that need a machine-global copy - * (`VARIANTS_REQUIRING_POSTHOG_CLI`). The agent can't — warlock blocks - * `npm install -g` — so the wizard does it in-process. Warn, don't fail. + * (`VARIANTS_REQUIRING_POSTHOG_CLI`). See `preinstallPostHogCliOnce` for the + * once-per-process guard and the warn-don't-fail handling. */ function ensurePostHogCli(variant: SkillVariant): void { - if (postHogCliInstallAttempted) return; - postHogCliInstallAttempted = true; - - const result = installOrUpdatePostHogCli(); - if (!result.success) { - analytics.wizardCapture('source maps posthog-cli preinstall failed', { - variant, - error: String(result.error).slice(0, 500), - }); - analytics.captureException( - result.errorObject ?? - new Error(`posthog-cli pre-install failed: ${result.error}`), - { source: 'source_maps_cli_preinstall', variant }, - ); - getUI().log.warn( - `Could not pre-install posthog-cli (${result.error}). Your release build ` + - `will fail to upload debug symbols until it's installed: npm install -g @posthog/cli@latest`, - ); - } + preinstallPostHogCliOnce('source maps posthog-cli preinstall failed', { + variant, + }); } export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { diff --git a/src/lib/programs/error-tracking/content/index.tsx b/src/lib/programs/error-tracking/content/index.tsx new file mode 100644 index 000000000..815ad34fc --- /dev/null +++ b/src/lib/programs/error-tracking/content/index.tsx @@ -0,0 +1,12 @@ +/** Error-tracking learn-deck: the source-maps narrative, worded to also fit platforms that upload nothing. */ + +import type { WizardStore } from '@ui/tui/store'; +import type { ContentBlock } from '@ui/tui/primitives/content-types'; +import { buildSourceMapsDeck } from '@lib/programs/error-tracking-upload-source-maps/content/index'; + +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => + buildSourceMapsDeck(store, { + intro: "I'm wiring PostHog Error Tracking into your project.", + wiring: + 'If your platform ships minified code or stripped binaries, I also hook source-map or debug-symbol upload into your build, tied to each release you ship.', + }); diff --git a/src/lib/programs/error-tracking/content/tips.ts b/src/lib/programs/error-tracking/content/tips.ts new file mode 100644 index 000000000..edf968de4 --- /dev/null +++ b/src/lib/programs/error-tracking/content/tips.ts @@ -0,0 +1,32 @@ +/** Sidebar tips for the error-tracking run: product features the learn deck does not cover. */ + +import type { Tip } from '@ui/tui/components/TipsCard'; +import { REPLAY_VISION_SUPPORTED } from '@lib/programs/replay-vision/index'; + +export const ERROR_TRACKING_TIPS: Tip[] = [ + { + id: 'session-replay', + title: 'Watch the crash happen', + description: + 'An exception from a recorded session links to its replay. See the clicks that led to the error, not only the stack trace.', + // Replay records only on platforms with a client SDK. + visible: (store) => { + const integration = store.session.integration; + return integration != null && REPLAY_VISION_SUPPORTED.has(integration); + }, + }, + { + id: 'alerts', + title: 'Know when a bug comes back from the dead', + description: + 'Set alerts for new issues, issues that return after you resolve them, and sudden spikes. Hear about them before your users do.', + }, + { + id: 'external-issues', + title: 'From crash to ticket in one click', + description: + 'Create a GitHub, GitLab, Jira or Linear issue straight from an error.', + }, +]; + +export const getTips = (): Tip[] => ERROR_TRACKING_TIPS; diff --git a/src/lib/programs/error-tracking/detect-agentic.ts b/src/lib/programs/error-tracking/detect-agentic.ts new file mode 100644 index 000000000..5f7fd5268 --- /dev/null +++ b/src/lib/programs/error-tracking/detect-agentic.ts @@ -0,0 +1,111 @@ +/** + * Error-tracking adapter over the shared integration scan: classifies each + * project for the post-login picker and scopes the run to the picked one. + * + * Self-driving classifies the same scan (`toIntegrationCandidates`) with a + * different rule, so each program keeps its own: + * - A project that already has PostHog is a setup target here. Self-driving + * offers it as "continue with existing" instead. + * - KMP is not offered here (see `ERROR_TRACKING_UNSUPPORTED`). + * - The scan asks for a recommended project, and the picker lists it first. + * - The scan bills to error-tracking. + */ + +import { Integration } from '@lib/constants'; +import { + resolveProjectDir, + type AgenticDetectionReport, + type DetectEvent, +} from '@lib/detection/agentic'; +import { gatherFrameworkContext } from '@lib/detection/index'; +import { + detectIntegrationProjects, + toIntegrationCandidates, +} from '@lib/detection/project-scope'; +import type { WizardSession } from '@lib/wizard-session'; + +/** frameworkContext key for the picked project's path, relative to the repo root. */ +export const ERROR_TRACKING_PROJECT_PATH_KEY = 'errorTrackingProjectPath'; + +/** KMP skill variants carry no `framework` tag, so orchestrator preflight aborts every KMP run. */ +export const ERROR_TRACKING_UNSUPPORTED: ReadonlySet = new Set([ + Integration.kmp, +]); + +/** One project, classified for the picker. */ +export type ErrorTrackingProject = { + /** Path relative to the repo root ("." for the root). */ + path: string; + /** Human-readable framework the agent detected (e.g. "Next.js"). */ + framework: string; + /** A wizard framework when the project matches one, else null. */ + integration: Integration | null; + /** The flow can run here. PostHog does not have to be installed yet. */ + instrumentable: boolean; +}; + +export type ErrorTrackingDetectionReport = { + repoType: 'monorepo' | 'single'; + projects: ErrorTrackingProject[]; +}; + +/** Classify the scan for the picker, recommended project first (exported for tests). */ +export function toErrorTrackingReport( + report: AgenticDetectionReport, +): ErrorTrackingDetectionReport { + const candidates = toIntegrationCandidates(report).sort( + (a, b) => Number(b.recommended) - Number(a.recommended), + ); + return { + repoType: report.repoType, + projects: candidates.map((p) => ({ + path: p.path, + framework: p.framework, + integration: p.integration, + instrumentable: + p.integration != null && !ERROR_TRACKING_UNSUPPORTED.has(p.integration), + })), + }; +} + +/** Scan the repo for projects, billed to error tracking. */ +export async function detectErrorTrackingProjects( + session: WizardSession, + onEvent?: DetectEvent, +): Promise { + const report = await detectIntegrationProjects(session, { + programId: 'error-tracking', + recommend: true, + onEvent, + }); + return toErrorTrackingReport(report); +} + +/** The run's working directory: the picked project, else the repo root. */ +export function errorTrackingProjectDir(session: WizardSession): string { + return resolveProjectDir( + session.installDir, + session.frameworkContext[ERROR_TRACKING_PROJECT_PATH_KEY], + ); +} + +/** Gather framework context for `session.installDir`, keeping keys already set. */ +export async function gatherErrorTrackingContext( + session: WizardSession, +): Promise { + const frameworkConfig = session.frameworkConfig; + if (!frameworkConfig) return; + const context = await gatherFrameworkContext(frameworkConfig, { + installDir: session.installDir, + debug: session.debug, + signup: session.signup, + ci: session.ci, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }); + for (const [key, value] of Object.entries(context)) { + if (!(key in session.frameworkContext)) { + session.frameworkContext[key] = value; + } + } +} diff --git a/src/lib/programs/error-tracking/index.ts b/src/lib/programs/error-tracking/index.ts new file mode 100644 index 000000000..2fd6a7cb1 --- /dev/null +++ b/src/lib/programs/error-tracking/index.ts @@ -0,0 +1,204 @@ +import { Integration } from '@lib/constants'; +import { detectFramework } from '@lib/detection/index'; +import { scopeInstallDirToProject } from '@lib/detection/project-scope'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import type { ProgramRun } from '@lib/agent/runner/shared/types'; +import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/steps'; +import { getContentBlocks } from '@lib/programs/error-tracking/content/index'; +import { getTips } from '@lib/programs/error-tracking/content/tips'; +import { + ERROR_TRACKING_UNSUPPORTED, + errorTrackingProjectDir, + gatherErrorTrackingContext, +} from '@lib/programs/error-tracking/detect-agentic'; +import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; +import type { WizardSession } from '@lib/wizard-session'; +import { preinstallPostHogCliOnce } from '@lib/programs/shared/posthog-cli-preinstall'; +import { analytics } from '@utils/analytics'; +import { wizardAbort } from '@utils/wizard-abort'; +import { ErrorCodes } from '@lib/errors'; + +const ERROR_TRACKING_REPORT_FILE = 'posthog-error-tracking-report.md'; +const ERROR_TRACKING_DOCS_URL = 'https://posthog.com/docs/error-tracking'; + +/** + * Frameworks whose symbol upload shells out to a machine-global `posthog-cli` + * with no npx / local-dep fallback. The wizard pre-installs the CLI for them + * because warlock blocks the agent's `npm install -g`. Mirrors + * `VARIANTS_REQUIRING_POSTHOG_CLI` in the source-maps program, but keyed by + * wizard `Integration` because here the framework is known before the flow's + * seed picks an uploader variant (`swift` maps to the `ios` uploader). + */ +export const SYMBOL_UPLOAD_CLI_FRAMEWORKS: ReadonlySet = new Set([ + Integration.swift, + Integration.android, + Integration.reactNative, + Integration.flutter, + Integration.go, + Integration.rust, +]); + +async function abortUnsupportedPlatform( + integration: Integration, +): Promise { + const name = FRAMEWORK_REGISTRY[integration]?.metadata.name ?? integration; + // A clean exit, not a crash: an event, never an `error` for captureException. + analytics.wizardCapture('error tracking unsupported platform', { + integration, + }); + await wizardAbort({ + code: ErrorCodes.DetectUnsupportedPlatform, + message: + `The wizard cannot set up error tracking for ${name} projects yet.\n\n` + + `Set it up manually:\n ${ERROR_TRACKING_DOCS_URL}`, + }); +} + +/** + * Pre-install posthog-cli when the detected framework's symbol upload will + * shell out to it. See `preinstallPostHogCliOnce` for the once-per-process + * guard and the warn-don't-fail handling. + */ +function maybePreinstallPostHogCli(integration: Integration | null): void { + if (!integration || !SYMBOL_UPLOAD_CLI_FRAMEWORKS.has(integration)) return; + preinstallPostHogCliOnce('error tracking posthog-cli preinstall failed', { + integration, + }); +} + +/** + * After login, the scan lists the repo's projects and the user picks one, as in + * the legacy upload-source-maps program. The pick sets the framework preflight + * resolves task skills against, and the project path the run is scoped to. + */ +const PICK_PROJECT_STEP: ProgramStep = { + id: 'detect', + label: 'Detecting projects', + screenId: 'error-tracking-detect', + isComplete: (session) => session.integration != null, +}; + +const ERROR_TRACKING_STEPS: ProgramStep[] = AGENT_SKILL_STEPS.flatMap( + (step): ProgramStep[] => { + if (step.id === 'intro') { + return [{ ...step, screenId: 'error-tracking-intro' }]; + } + if (step.id === 'auth') return [step, PICK_PROJECT_STEP]; + if (step.id === 'run') { + // targetDir makes run-wizard walk the steps and run in the picked project. + return [ + { + ...step, + targetDir: errorTrackingProjectDir, + onRunPrep: gatherErrorTrackingContext, + }, + ]; + } + return [step]; + }, +); + +/** + * Run instructions for a linear override (`--sequence=linear`), the only + * sequence that reads `customPrompt`. The orchestrator runs the flow's own + * prompts, so these spell out the skill-menu lookups its tasks perform. + */ +const ERROR_TRACKING_PROMPT = `Set up PostHog error tracking end-to-end: + +1. If PostHog is not integrated yet, install and initialize the SDK first — + do not abort. Pick the matching variant from the skill menu's + "integration-v2/install" and "integration-v2/init" categories. + +2. Wire up exception capture: install the "error-tracking" skill variant that + matches this project's platform (\`load_skill_menu\` with + \`category: "error-tracking"\`) and follow it. Set capture up in one place — + the SDK's own mechanism, never manual capture calls sprinkled across files. + +3. When the platform ships minified bundles or stripped binaries (browser JS, + React Native, iOS, Android, Flutter, Go, Rust), wire up source-map / + debug-symbol upload too: install the matching + "error-tracking-upload-source-maps" skill variant and follow it, including + credentials and CI. Skip this step on platforms with readable stack traces + (plain Python, Ruby, PHP, Elixir, JVM servers). + +The final report is written to ./${ERROR_TRACKING_REPORT_FILE}.`; + +const ERROR_TRACKING_RUN: ProgramRun = { + integrationLabel: 'error-tracking', + customPrompt: () => ERROR_TRACKING_PROMPT, + successMessage: `Error tracking configured! View the report at ./${ERROR_TRACKING_REPORT_FILE}`, + reportFile: ERROR_TRACKING_REPORT_FILE, + docsUrl: ERROR_TRACKING_DOCS_URL, + spinnerMessage: 'Setting up error tracking...', + estimatedDurationMinutes: 8, + // The flow can park on wizard_ask while the user does slow work (mint a + // personal API key in the browser, run a build and trigger the test + // error). The orchestrator caps per-task asks itself; this covers the + // linear fallback. + askTimeoutMs: 30 * 60 * 1000, +}; + +/** + * `wizard error-tracking` — flat command on the orchestrator sequence. + * + * Makes uncaught errors reach PostHog with readable stack traces. The + * orchestrator runs the `error-tracking` agent flow (context-mill + * `context/agents/error-tracking`): the seed enqueues the install/init tasks + * (sharing integration-v2's step-skills, like replay-vision) when the project + * has no PostHog yet, then exception capture, then — when the platform needs + * it — the source-map subgraph adapted from the standalone + * `upload-source-maps` flow. + * + * Departures from a plain `createSkillProgram`: + * - No `run.skillId`: the flow's tasks resolve per-framework mini-skills + * themselves (there is no bare `error-tracking` menu entry), so the intro is + * a custom screen rather than the generic skill intro. + * - `PICK_PROJECT_STEP` after auth: the user picks the project, which sets the + * framework preflight needs and the directory the run is scoped to. + * - `run` is a function: `runAgent` resolves it after the pick (and after + * `ciPreRun` headless), so the posthog-cli pre-install, which the agent + * cannot do (warlock blocks \`npm install -g\`), waits for the user. + * - `agentFlow` pinned (the id would default to the same value — explicit so + * renaming the program can't silently detach the flow). + * - `ciPreRun` mirrors replay-vision: scope the install dir to the right + * project (monorepos), then detect the framework — the headless equivalent + * of the project picker. + */ +export const errorTrackingConfig: ProgramConfig = { + command: 'error-tracking', + description: 'Set up PostHog error tracking, source-map upload included', + id: 'error-tracking', + agentFlow: 'error-tracking', + steps: ERROR_TRACKING_STEPS, + reportFile: ERROR_TRACKING_REPORT_FILE, + getContentBlocks, + getTips, + + run: (session: WizardSession): Promise => { + maybePreinstallPostHogCli(session.integration); + return Promise.resolve(ERROR_TRACKING_RUN); + }, + + ciPreRun: async (session: WizardSession): Promise => { + await scopeInstallDirToProject(session); + + const integration = await detectFramework(session.installDir); + if (!integration) { + await wizardAbort({ + code: ErrorCodes.DetectNoFramework, + message: 'Could not auto-detect your framework for this project.', + }); + return; + } + if (ERROR_TRACKING_UNSUPPORTED.has(integration)) { + await abortUnsupportedPlatform(integration); + return; + } + session.integration = integration; + analytics.setTag('integration', integration); + session.frameworkConfig = FRAMEWORK_REGISTRY[integration]; + session.skillId = integration; + + await gatherErrorTrackingContext(session); + }, +}; diff --git a/src/lib/programs/error-tracking/test/e2e.json b/src/lib/programs/error-tracking/test/e2e.json new file mode 100644 index 000000000..635977156 --- /dev/null +++ b/src/lib/programs/error-tracking/test/e2e.json @@ -0,0 +1,53 @@ +{ + "program": "error-tracking", + "summary": "Happy path: detect framework, confirm intro, run the error-tracking orchestrator flow (install/init when PostHog is absent, exception capture, the source-map subgraph where the platform needs it, report), delete installed skills. The driver answers the flow's asks: the upload key via wizard_ask (id \"api-key\"; the raw key is vaulted by the ask tool, the agent only sees a secretRef) and declines the test-affordance offer.", + "profile": { + "setup": "first", + "healthCheck": "dismiss", + "mcp": "skip", + "slack": "skip", + "skills": "delete", + "ask": "first", + "askAnswers": [ + { "match": "api-key", "value": "${SOURCE_MAPS_CLI_KEY}", "secret": true }, + { "match": "test-affordance", "value": "no" } + ] + }, + "variations": [ + { + "name": "default", + "summary": "orchestrator / pi — the program's pinned binding; stage models from the flow frontmatter (terra seed, install and init, sol tasks, luna report)" + }, + { + "name": "linear-fallback", + "summary": "linear arm: the customPrompt drives a single agent through menu-installed skills", + "sequence": "linear" + } + ], + "path": [ + { + "screen": "detect", + "auto": "(headless) — framework detection puts the framework id on session.skillId; pre-installs posthog-cli for symbol-upload platforms" + }, + { + "screen": "error-tracking-intro", + "auto": "confirm & continue" + }, + { + "screen": "auth", + "auto": "(external) — the runner resolves credentials from the phx key" + }, + { + "screen": "run", + "auto": "(external) — the orchestrator flow; the profile answers wizard_ask \"api-key\" with SOURCE_MAPS_CLI_KEY and \"test-affordance\" with \"no\"" + }, + { + "screen": "outro", + "auto": "dismiss" + }, + { + "screen": "keep-skills", + "auto": "delete the installed skills" + } + ] +} diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 2633c295e..7e3156948 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -21,6 +21,7 @@ import { posthogDoctorConfig } from './posthog-doctor/index.js'; import { webAnalyticsDoctorConfig } from './web-analytics-doctor/index.js'; import { migrationConfig } from './migration/index.js'; import { errorTrackingUploadSourceMapsConfig } from './error-tracking-upload-source-maps/index.js'; +import { errorTrackingConfig } from './error-tracking/index.js'; import { selfDrivingConfig } from './self-driving/index.js'; import { AGENT_SKILL_STEPS } from './agent-skill/index.js'; import { getContentBlocks as agentSkillContentBlocks } from './agent-skill/content/index.js'; @@ -70,6 +71,7 @@ export const PROGRAM_REGISTRY = [ revenueAnalyticsConfig, warehouseSourceConfig, errorTrackingUploadSourceMapsConfig, + errorTrackingConfig, auditConfig, eventsAuditConfig, posthogDoctorConfig, @@ -97,6 +99,7 @@ export const Program = { RevenueAnalyticsSetup: revenueAnalyticsConfig.id, WarehouseSource: warehouseSourceConfig.id, ErrorTrackingUploadSourceMaps: errorTrackingUploadSourceMapsConfig.id, + ErrorTracking: errorTrackingConfig.id, Migration: migrationConfig.id, Audit: auditConfig.id, EventsAudit: eventsAuditConfig.id, diff --git a/src/lib/programs/self-driving/detect-agentic.ts b/src/lib/programs/self-driving/detect-agentic.ts index a5e9e9869..ddb416941 100644 --- a/src/lib/programs/self-driving/detect-agentic.ts +++ b/src/lib/programs/self-driving/detect-agentic.ts @@ -4,9 +4,9 @@ * Mirrors source-maps: the integration-detect screen runs `detectSelfDriving- * IntegrationProjects` (the shared integration scan from * @lib/detection/project-scope), shows a project map, and the user picks one. - * This file maps the result back to `Integration`s and classifies each project - * as instrumentable (a framework the wizard supports that doesn't already have - * PostHog). The screen writes the choice to the session; `prepSelfDriving- + * `toIntegrationCandidates` matches each project to an `Integration`; this file + * classifies it as instrumentable (a framework the wizard supports that doesn't + * already have PostHog). The screen writes the choice to the session; `prepSelfDriving- * Integration` then gathers the chosen project's framework context before the * integration agent runs (the runner scopes the install dir). */ @@ -15,15 +15,16 @@ import type { AgenticDetectionReport, DetectEvent, } from '@lib/detection/agentic'; -import { detectIntegrationProjects } from '@lib/detection/project-scope'; +import { + detectIntegrationProjects, + toIntegrationCandidates, +} from '@lib/detection/project-scope'; import { gatherFrameworkContext } from '@lib/detection/index'; -import { Integration } from '@lib/constants'; +import type { Integration } from '@lib/constants'; import type { WizardSession } from '@lib/wizard-session'; export type { DetectEvent }; -const INTEGRATION_IDS = new Set(Object.values(Integration)); - /** One project, classified for a PostHog SDK integration. */ export type IntegrationProject = { /** Path relative to the repo root ("." for the root). */ @@ -69,20 +70,14 @@ export function toIntegrationReport( ): IntegrationDetectionReport { return { repoType: report.repoType, - projects: report.projects.map((p) => { - const integration = - p.targetId && INTEGRATION_IDS.has(p.targetId) - ? (p.targetId as Integration) - : null; - return { - path: p.path, - framework: p.framework, - integration, - hasPostHog: p.hasPostHog, - continuable: p.hasPostHog, - ...classify(integration, p.hasPostHog), - }; - }), + projects: toIntegrationCandidates(report).map((p) => ({ + path: p.path, + framework: p.framework, + integration: p.integration, + hasPostHog: p.hasPostHog, + continuable: p.hasPostHog, + ...classify(p.integration, p.hasPostHog), + })), }; } diff --git a/src/lib/programs/shared/posthog-cli-preinstall.ts b/src/lib/programs/shared/posthog-cli-preinstall.ts new file mode 100644 index 000000000..865a25a9f --- /dev/null +++ b/src/lib/programs/shared/posthog-cli-preinstall.ts @@ -0,0 +1,43 @@ +/** + * Machine-global posthog-cli pre-install, shared by the programs whose symbol + * upload shells out to it (error-tracking and error-tracking-upload-source-maps). + * + * The agent cannot install a global package — warlock blocks `npm install -g` + * — so the wizard does it in-process, at most once per process. Warn, don't + * fail: the run still instruments capture, and only the release build's upload + * needs the CLI. + */ + +import { installOrUpdatePostHogCli } from '@steps/install-cli-steering'; +import { getUI } from '@ui'; +import { analytics } from '@utils/analytics'; + +let attempted = false; + +export function preinstallPostHogCliOnce( + failureEvent: string, + properties: Record, +): void { + if (attempted) return; + attempted = true; + + const result = installOrUpdatePostHogCli(); + if (result.success) return; + + // No npm, or no permission for a global install, is the user's environment, + // not a wizard bug: an event, never captureException, which would mint an + // error-tracking issue per machine. + analytics.wizardCapture(failureEvent, { + ...properties, + error: String(result.error).slice(0, 500), + }); + getUI().log.warn( + `Could not pre-install posthog-cli (${result.error}). Your release build ` + + `will fail to upload debug symbols until it's installed: npm install -g @posthog/cli@latest`, + ); +} + +/** Test seam: forget that the install was attempted. */ +export function resetPostHogCliPreinstallForTests(): void { + attempted = false; +} diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 66b6b5f55..90d48a740 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -196,10 +196,11 @@ export function runWizard( const shown = (s: ProgramConfig['steps'][number]) => !s.show || s.show(activeTui.store.session); - if (config.steps.some((s) => s.run)) { + if (config.steps.some((s) => s.run || s.targetDir)) { // A composed program: its step list splices in run steps that carry // their own agent (self-driving runs the integration before its own - // run). Walk the list once, advancing each step to completion. + // run), or scopes its own run to a picked project (error-tracking). + // Walk the list once, advancing each step to completion. for (const step of config.steps) { if (step.screenId === 'outro') break; // run-completion wait owns it if (shown(step)) await advanceStep(step, activeTui.store, config); diff --git a/src/lib/wizard-tools/mcp.ts b/src/lib/wizard-tools/mcp.ts index ef7a038ee..e0dd7fd28 100644 --- a/src/lib/wizard-tools/mcp.ts +++ b/src/lib/wizard-tools/mcp.ts @@ -52,6 +52,7 @@ import { resolveEnvPath, resolveEnvSecretRefs, templateEnvWriteRefusal, + legacyKeyNameRefusal, vaultSensitiveAnswers, writeLedgerAtomic, type SkillEntry, @@ -230,18 +231,13 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { filePath: string; values: Record; }) => { - // Block the wrong key name — the correct key is NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN or similar - const forbidden = Object.keys(args.values).find( - (k) => k.toUpperCase() === 'POSTHOG_KEY', + const keyRefusal = legacyKeyNameRefusal( + workingDirectory, + Object.keys(args.values), ); - if (forbidden) { + if (keyRefusal) { return { - content: [ - { - type: 'text' as const, - text: `Error: "${forbidden}" is not a valid PostHog env var name. Use the project-specific key name from your framework's integration guide (e.g. NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN).`, - }, - ], + content: [{ type: 'text' as const, text: keyRefusal }], isError: true, }; } diff --git a/src/lib/wizard-tools/tools.ts b/src/lib/wizard-tools/tools.ts index 25b54bc07..cacb1491d 100644 --- a/src/lib/wizard-tools/tools.ts +++ b/src/lib/wizard-tools/tools.ts @@ -11,7 +11,7 @@ import fs from 'fs'; import { unzipSync } from 'fflate'; import { logToFile } from '@utils/debug'; import { analytics } from '@utils/analytics'; -import { readProjectFile } from '@utils/bounded-fs'; +import { readProjectFile, walkProjectFiles } from '@utils/bounded-fs'; import { collectProjectEnvKeys, isTemplateEnvFileName, @@ -761,6 +761,49 @@ export function templateEnvWriteRefusal(resolvedPath: string): string | null { ); } +/** Whole-word, so `NEXT_PUBLIC_POSTHOG_KEY` (`_` is a word character) does not count. */ +const LEGACY_KEY_USE = /\bPOSTHOG_KEY\b/; + +/** Files where a project reads or defines an env var: source, config, env, shell and build files. */ +const LEGACY_KEY_SCAN_FILE = + /^(\.env.*|Dockerfile.*|Makefile|Procfile|package\.json|app\.json|eas\.json|.*\.[cm]?[jt]sx?|.*\.(vue|svelte|astro|py|rb|php|go|rs|exs?|java|kts?|swift|dart|cs|ya?ml|toml|sh|bash|gradle|properties|xcconfig|plist))$/; + +function projectReadsLegacyKey(workingDirectory: string): boolean { + let found = false; + walkProjectFiles( + workingDirectory, + (name, fullPath) => { + if (found || !LEGACY_KEY_SCAN_FILE.test(name)) return; + const content = readProjectFile(fullPath); + if (content !== null && LEGACY_KEY_USE.test(content)) found = true; + }, + 6, + ); + return found; +} + +/** + * `set_env_values`' refusal for the legacy `POSTHOG_KEY` name, or null. Shared + * by both facades so the two cannot disagree about it. + * + * `POSTHOG_KEY` is the old name for the project token, so a new project gets + * the canonical one (e.g. NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN). A project whose + * code already reads `POSTHOG_KEY` keeps it: refusing there forces a rename of + * working code, and every deploy step and CI secret that still passes the old + * name then starts the app with an empty token. + */ +export function legacyKeyNameRefusal( + workingDirectory: string, + keys: readonly string[], +): string | null { + const key = keys.find((k) => k.toUpperCase() === 'POSTHOG_KEY'); + if (!key || projectReadsLegacyKey(workingDirectory)) return null; + return ( + `Error: "${key}" is not a valid PostHog env var name. Use the key name from your framework's integration guide (e.g. NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN). ` + + `POSTHOG_KEY is accepted only when the project already reads it.` + ); +} + /** * Escape a key before it is interpolated into the match regex below. The key * comes from the agent, and a stray metacharacter would otherwise build a diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index 5cbea8054..3aa4cb3d9 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -281,4 +281,36 @@ describe('WizardRouter', () => { expect(router.resolve(session)).toBe(ScreenId.Run); }); }); + + describe('error-tracking project picker', () => { + function loggedIn() { + const session = baseWizardSession(); + session.setupConfirmed = true; + session.readinessResult = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }; + session.credentials = { + accessToken: 'tok', + projectApiKey: 'pk', + host: HostResolution.fromApiHost('https://app.posthog.com'), + projectId: 1, + }; + return session; + } + + it('shows the project picker after login, before a project is picked', () => { + const router = new WizardRouter(Program.ErrorTracking); + expect(router.resolve(loggedIn())).toBe(ScreenId.ErrorTrackingDetect); + }); + + it('advances to the run once a project is picked', () => { + const router = new WizardRouter(Program.ErrorTracking); + const session = loggedIn(); + session.integration = Integration.nextjs; + session.frameworkConfig = FRAMEWORK_REGISTRY[Integration.nextjs]; + expect(router.resolve(session)).toBe(ScreenId.Run); + }); + }); }); diff --git a/src/ui/tui/__tests__/skill-entry.test.ts b/src/ui/tui/__tests__/skill-entry.test.ts index 77841f415..1adece346 100644 --- a/src/ui/tui/__tests__/skill-entry.test.ts +++ b/src/ui/tui/__tests__/skill-entry.test.ts @@ -32,6 +32,22 @@ describe('resolveSkillEntry', () => { expect(resolveSkillEntry(MENU, 'nextjs')).toBeNull(); }); + it('matches an expanded bundle by its group id', () => { + // The menu replaces a bundle with per-framework entries, so no entry keeps + // the bundle id. Every one of them carries the bundle's download URL. + const group = 'integration-v2-error-tracking-step'; + const bundled = { + id: `${group}-django`, + group, + bundle: true, + downloadUrl: `https://example.com/${group}.json`, + } as SkillEntry; + + expect(resolveSkillEntry([...MENU, bundled], group)?.downloadUrl).toBe( + `https://example.com/${group}.json`, + ); + }); + it('returns null when nothing matches', () => { expect(resolveSkillEntry(MENU, 'cobol')).toBeNull(); }); diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08d21b3bc..87589e755 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -30,6 +30,8 @@ import { SourceMapsOutroScreen } from './screens/SourceMapsOutroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { AiObservabilityIntroScreen } from './screens/AiObservabilityIntroScreen.js'; import { MetricsIntroScreen } from './screens/MetricsIntroScreen.js'; +import { ErrorTrackingIntroScreen } from './screens/ErrorTrackingIntroScreen.js'; +import { ErrorTrackingDetectScreen } from './screens/ErrorTrackingDetectScreen.js'; import { SelfDrivingIntroScreen } from './screens/SelfDrivingIntroScreen.js'; import { SelfDrivingIntegrationCheckScreen } from './screens/SelfDrivingIntegrationCheckScreen.js'; import { SelfDrivingIntegrationDetectScreen } from './screens/SelfDrivingIntegrationDetectScreen.js'; @@ -96,6 +98,8 @@ export function createScreens( ), [ScreenId.MetricsIntro]: , + [ScreenId.ErrorTrackingIntro]: , + [ScreenId.ErrorTrackingDetect]: , [ScreenId.SelfDrivingIntro]: , [ScreenId.SelfDrivingIntegrationCheck]: ( diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 6b6b36cfb..311e0c332 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -26,6 +26,8 @@ export enum ScreenId { AgentSkillIntro = 'agent-skill-intro', AiObservabilityIntro = 'ai-observability-intro', MetricsIntro = 'metrics-intro', + ErrorTrackingIntro = 'error-tracking-intro', + ErrorTrackingDetect = 'error-tracking-detect', SelfDrivingIntro = 'self-driving-intro', SelfDrivingIntegrationCheck = 'self-driving-integration-check', SelfDrivingIntegrationDetect = 'self-driving-integration-detect', diff --git a/src/ui/tui/screens/ErrorTrackingDetectScreen.tsx b/src/ui/tui/screens/ErrorTrackingDetectScreen.tsx new file mode 100644 index 000000000..9dd79815a --- /dev/null +++ b/src/ui/tui/screens/ErrorTrackingDetectScreen.tsx @@ -0,0 +1,204 @@ +/** + * ErrorTrackingDetectScreen — after login, runs the detection agent over the + * repo, streams its progress, and lets the user pick the project to set error + * tracking up in. Mirrors the legacy SourceMapsDetectScreen. + */ + +import { Box, Text } from 'ink'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { LoadingBox, PickerMenu } from '@ui/tui/primitives/index'; +import { Colors, Icons } from '@ui/tui/styles'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { + detectErrorTrackingProjects, + ERROR_TRACKING_PROJECT_PATH_KEY, + type ErrorTrackingDetectionReport, + type ErrorTrackingProject, +} from '@lib/programs/error-tracking/detect-agentic'; + +interface ErrorTrackingDetectScreenProps { + store: WizardStore; +} + +type DetectState = + | { kind: 'loading' } + | { kind: 'ready'; report: ErrorTrackingDetectionReport } + | { kind: 'error'; message: string }; + +const EXIT = '__exit'; +const MAX_ACTIVITY_LINES = 8; + +function projectLabel(p: ErrorTrackingProject): string { + const where = p.path === '.' ? 'repo root' : p.path; + return `${p.framework} ${Icons.bullet} ${where}`; +} + +export const ErrorTrackingDetectScreen = ({ + store, +}: ErrorTrackingDetectScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const { credentials } = store.session; + const accessToken = credentials?.accessToken; + + const [state, setState] = useState({ kind: 'loading' }); + const [activity, setActivity] = useState([]); + const started = useRef(false); + + useEffect(() => { + if (!accessToken || started.current) return; + started.current = true; + let cancelled = false; + void (async () => { + try { + const report = await detectErrorTrackingProjects( + store.session, + (line) => { + if (!cancelled) { + setActivity((prev) => [...prev, line].slice(-MAX_ACTIVITY_LINES)); + } + }, + ); + if (!cancelled) setState({ kind: 'ready', report }); + } catch (err) { + if (!cancelled) { + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [accessToken, store]); + + if (!credentials) { + return ; + } + + if (state.kind === 'loading') { + return ( + + + Detecting your project... + + + + + + {activity.length === 0 ? ( + {' '}Starting up the detection agent… + ) : ( + activity.map((line, i) => ( + + {' '} + {Icons.triangleSmallRight} {line} + + )) + )} + + + ); + } + + if (state.kind === 'error') { + return ( + + + + {Icons.squareFilled} Detection failed + + {state.message} + + process.exit(1)} + /> + + ); + } + + const { report } = state; + const instrumentable = report.projects.filter((p) => p.instrumentable); + const unsupported = report.projects.length - instrumentable.length; + + if (instrumentable.length === 0) { + return ( + + + + {Icons.squareFilled} Nothing to set up here + + + None of the {report.projects.length} projects found uses a framework + the wizard can set up error tracking for. + + + process.exit(0)} + /> + + ); + } + + const options = [ + ...instrumentable.map((p) => ({ + label: projectLabel(p), + value: p.path, + })), + { label: 'Cancel', value: EXIT }, + ]; + + return ( + + + + {Icons.check} Found{' '} + {report.repoType === 'monorepo' ? 'a monorepo' : 'your project'} + + + + { + const path = Array.isArray(value) ? value[0] : value; + if (path === EXIT) { + process.exit(0); + return; + } + const chosen = instrumentable.find((p) => p.path === path); + if (!chosen?.integration) return; + store.setFrameworkContext( + ERROR_TRACKING_PROJECT_PATH_KEY, + chosen.path, + ); + store.setFrameworkConfig( + chosen.integration, + FRAMEWORK_REGISTRY[chosen.integration], + ); + }} + /> + + {unsupported > 0 && ( + + + (… {unsupported} other project{unsupported === 1 ? '' : 's'} not + supported) + + + )} + + ); +}; diff --git a/src/ui/tui/screens/ErrorTrackingIntroScreen.tsx b/src/ui/tui/screens/ErrorTrackingIntroScreen.tsx new file mode 100644 index 000000000..bd6609234 --- /dev/null +++ b/src/ui/tui/screens/ErrorTrackingIntroScreen.tsx @@ -0,0 +1,91 @@ +import { Box, Text } from 'ink'; +import { useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { IntroScreenLayout } from '@ui/tui/screens/IntroScreenLayout'; +import { + SkillSourceInfo, + useSkillEntry, +} from '@ui/tui/screens/SkillSourceInfo'; + +interface ErrorTrackingIntroScreenProps { + store: WizardStore; +} + +export const ErrorTrackingIntroScreen = ({ + store, +}: ErrorTrackingIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [showingMoreInfo, setShowingMoreInfo] = useState(false); + const { session } = store; + // The flow resolves its skills per framework after the project pick. Point + // "more info" at the capture step, the one skill every run of this flow installs. + const skillId = 'integration-v2-error-tracking-step'; + const { skillEntry, fetchFailed } = useSkillEntry(skillId); + + const body = showingMoreInfo ? ( + + + + The Wizard is an agent, it's here to help you setup PostHog. Its code is open + source: https://github.com/PostHog/wizard + + + + + The{' '} + + error-tracking + {' '} + program makes uncaught errors reach PostHog with readable stack traces. + It installs and initializes the PostHog, wires up exception capture, + and sets up source-map uploading when necessary. In PostHog, + you can analyze these errors and have agents proactively suggest + fixes in self-driving. + + + + + + ) : ( + + + Let's make uncaught errors reach PostHog with readable stack traces. + + + ); + + const menuOptions = showingMoreInfo + ? [{ label: 'Back', value: 'back' }] + : [ + { label: 'Continue', value: 'continue' }, + { label: 'More info', value: 'more-info' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const handleSelect = (value: string) => { + if (value === 'cancel') process.exit(0); + else if (value === 'more-info') setShowingMoreInfo(true); + else if (value === 'back') setShowingMoreInfo(false); + else store.completeSetup(); + }; + + return ( + + ); +}; diff --git a/src/ui/tui/screens/SkillSourceInfo.tsx b/src/ui/tui/screens/SkillSourceInfo.tsx index 09dc93823..b5184b682 100644 --- a/src/ui/tui/screens/SkillSourceInfo.tsx +++ b/src/ui/tui/screens/SkillSourceInfo.tsx @@ -25,9 +25,9 @@ import { CONTEXT_MILL_RELEASES_URL, getSkillsBaseUrl } from '@lib/constants'; * under prefixed ids ('integration-python'); frameworks with variants * publish several ('integration-nextjs-app-router', '-pages-router'). * Match chain: exact id → `integration-` → unique - * `integration--*` prefix. Ambiguous variants (≥2 prefix matches) - * return null — the caller should point at the skills repo instead of - * guessing the wrong variant. + * `integration--*` prefix → bundle group. Ambiguous variants (≥2 prefix + * matches) return null — the caller should point at the skills repo instead + * of guessing the wrong variant. */ export function resolveSkillEntry( entries: SkillEntry[], @@ -42,7 +42,12 @@ export function resolveSkillEntry( const variants = entries.filter((s) => s.id.startsWith(`integration-${skillId}-`), ); - return variants.length === 1 ? variants[0] : null; + if (variants.length === 1) return variants[0]; + if (variants.length > 1) return null; + + // The menu expands a bundle into per-framework entries that all carry the + // bundle's group and download URL, so the bundle id matches only by group. + return entries.find((s) => s.bundle && s.group === skillId) ?? null; } export function useSkillEntry(skillId: string | null): {