From 5817fa8d22b351b838d59d8ace3b8045104e323d Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 25 May 2026 14:41:19 +0200 Subject: [PATCH 01/10] feat: base --- src/lib/agent/agent-interface.ts | 2 + src/lib/wizard-tools.ts | 141 ++++++++ .../content/index.tsx | 7 + .../detect.ts | 325 ++++++++++++++++++ .../index.ts | 227 ++++++++++++ .../steps.ts | 54 +++ src/lib/workflows/workflow-registry.ts | 2 + src/ui/tui/flows.ts | 2 + src/ui/tui/screen-registry.tsx | 2 + src/ui/tui/screens/SourceMapsIntroScreen.tsx | 185 ++++++++++ 10 files changed, 947 insertions(+) create mode 100644 src/lib/workflows/error-tracking-upload-source-maps/content/index.tsx create mode 100644 src/lib/workflows/error-tracking-upload-source-maps/detect.ts create mode 100644 src/lib/workflows/error-tracking-upload-source-maps/index.ts create mode 100644 src/lib/workflows/error-tracking-upload-source-maps/steps.ts create mode 100644 src/ui/tui/screens/SourceMapsIntroScreen.tsx diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 2144f49d..71c02cb6 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -717,6 +717,8 @@ export async function initializeAgent( skillsBaseUrl: config.skillsBaseUrl, askBridge: config.askBridge, askMaxQuestions: config.askMaxQuestions, + posthogAccessToken: config.posthogApiKey, + posthogHost: config.posthogApiHost, }); mcpServers['wizard-tools'] = wizardToolsServer; diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index 7c69abd3..a77f46eb 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -192,6 +192,17 @@ export interface WizardToolsOptions { * of this cap — see {@link ASK_BATCH_THRESHOLD}. */ askMaxQuestions?: number; + + /** + * Authenticated PostHog access token for the current user. Used by + * `create_personal_api_key` to mint new keys on the user's behalf. + * Omit in non-authenticated test environments — the tool then refuses + * to run. + */ + posthogAccessToken?: string; + + /** PostHog host (e.g. https://us.posthog.com). Paired with the access token. */ + posthogHost?: string; } /** Default per-run cap on wizard_ask calls when no override is provided. */ @@ -494,6 +505,8 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { skillsBaseUrl, askBridge, askMaxQuestions = DEFAULT_ASK_MAX_QUESTIONS, + posthogAccessToken, + posthogHost, } = options; const sdk = await getSDKModule(); const { tool, createSdkMcpServer } = sdk; @@ -740,6 +753,132 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { }, ); + // -- create_personal_api_key --------------------------------------------- + + const createPersonalApiKey = tool( + 'create_personal_api_key', + "Create a new PostHog personal API key with the requested scopes on the authenticated user's account. Returns the raw key value — never log it or echo it back to the user. Use set_env_values to write it to .env. Intended for workflows like source map upload where the wizard cannot reuse its own credentials.", + { + label: z + .string() + .min(1) + .max(120) + .describe( + 'Human-readable label so the user can find and revoke the key later (e.g. "Wizard source maps · ")', + ), + scopes: z + .array(z.string()) + .min(1) + .describe( + 'PostHog API scopes to grant (e.g. ["error_tracking:write", "organization:read"])', + ), + }, + async (args: { label: string; scopes: string[] }) => { + if (!posthogAccessToken || !posthogHost) { + return { + content: [ + { + type: 'text' as const, + text: 'Error: create_personal_api_key is unavailable in this environment (no PostHog credentials). Ask the user to paste an existing personal API key instead.', + }, + ], + isError: true, + }; + } + + const url = `${posthogHost.replace(/\/$/, '')}/api/personal_api_keys/`; + logToFile( + `create_personal_api_key: POST ${url} (scopes=${args.scopes.join( + ',', + )})`, + ); + + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${posthogAccessToken}`, + }, + body: JSON.stringify({ + label: args.label, + scopes: args.scopes, + }), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + logToFile( + `create_personal_api_key: HTTP ${resp.status} ${text.slice( + 0, + 200, + )}`, + ); + return { + content: [ + { + type: 'text' as const, + text: `Error: create_personal_api_key failed (HTTP ${ + resp.status + }). The server said: ${text.slice(0, 300) || '(empty body)'}`, + }, + ], + isError: true, + }; + } + + const data = (await resp.json()) as { value?: string }; + if (!data.value) { + return { + content: [ + { + type: 'text' as const, + text: 'Error: create_personal_api_key succeeded but the response did not include a key value.', + }, + ], + isError: true, + }; + } + + analytics.wizardCapture('personal api key created', { + scopes: args.scopes, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + value: data.value, + label: args.label, + scopes: args.scopes, + }, + null, + 2, + ), + }, + ], + }; + } catch (err: any) { + logToFile( + `create_personal_api_key: network error: ${err?.message ?? err}`, + ); + return { + content: [ + { + type: 'text' as const, + text: `Error: create_personal_api_key network error: ${ + err?.message ?? String(err) + }`, + }, + ], + isError: true, + }; + } + }, + ); + // -- audit_seed_checks ---------------------------------------------------- const auditLedgerPath = path.join(workingDirectory, AUDIT_CHECKS_FILE); @@ -1017,6 +1156,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { detectPM, loadSkillMenu, installSkill, + createPersonalApiKey, auditSeedChecks, auditAddChecks, auditResolveChecks, @@ -1032,6 +1172,7 @@ export const WIZARD_TOOL_NAMES = [ `${SERVER_NAME}:detect_package_manager`, `${SERVER_NAME}:load_skill_menu`, `${SERVER_NAME}:install_skill`, + `${SERVER_NAME}:create_personal_api_key`, `${SERVER_NAME}:audit_seed_checks`, `${SERVER_NAME}:audit_add_checks`, `${SERVER_NAME}:audit_resolve_checks`, diff --git a/src/lib/workflows/error-tracking-upload-source-maps/content/index.tsx b/src/lib/workflows/error-tracking-upload-source-maps/content/index.tsx new file mode 100644 index 00000000..8490695e --- /dev/null +++ b/src/lib/workflows/error-tracking-upload-source-maps/content/index.tsx @@ -0,0 +1,7 @@ +/** + * Source-maps learn-deck. Delegates to the generic skill deck — the + * workflow doesn't yet have content unique enough to justify a + * dedicated script. + */ + +export { getContentBlocks } from '../../agent-skill/content/index.js'; diff --git a/src/lib/workflows/error-tracking-upload-source-maps/detect.ts b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts new file mode 100644 index 00000000..38e7b1a0 --- /dev/null +++ b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts @@ -0,0 +1,325 @@ +/** + * Source maps upload prerequisite detection. + * + * Scans the project for signals that identify the platform and build system, + * then maps to one of the context-mill `error-tracking-upload-source-maps-*` + * skill variants. Results are written to frameworkContext for the intro + * screen to render and for the agent prompt to consume. + */ + +import type { Dirent } from 'fs'; +import { readFileSync, readdirSync, existsSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { IGNORED_DIRS } from '../../../utils/file-utils.js'; +import type { WizardSession } from '../../wizard-session.js'; +import type { AbortCase } from '../../agent/agent-runner.js'; + +/** + * Skill variants published under the `error-tracking-upload-source-maps` + * category in context-mill. The agent loads + * `error-tracking-upload-source-maps-`. + */ +export type SkillVariant = + | 'web' + | 'nextjs' + | 'node' + | 'react' + | 'angular' + | 'nuxt' + | 'react-native' + | 'android' + | 'flutter' + | 'ios' + | 'vite' + | 'webpack' + | 'rollup'; + +const DISPLAY_NAME: Record = { + web: 'Web (JavaScript)', + nextjs: 'Next.js', + node: 'Node.js', + react: 'React', + angular: 'Angular', + nuxt: 'Nuxt', + 'react-native': 'React Native', + android: 'Android', + flutter: 'Flutter', + ios: 'iOS', + vite: 'Vite', + webpack: 'Webpack', + rollup: 'Rollup', +}; + +const POSTHOG_SDKS = [ + 'posthog-js', + 'posthog-node', + 'posthog-react-native', + 'posthog-android', + 'posthog-ios', +]; + +/** + * Structured detection errors. The screen renders each kind into JSX + * with proper formatting — keeps error data separate from presentation. + */ +export type SourceMapsDetectError = + | { + kind: 'bad-directory'; + path: string; + reason: 'missing' | 'not-dir' | 'unreadable'; + } + | { kind: 'no-project-files' } + | { kind: 'unsupported-platform'; detected: string } + | { kind: 'no-posthog-sdk'; platform: SkillVariant }; + +/** `[ABORT] ` cases the source maps skill can emit. */ +export const SOURCE_MAPS_ABORT_CASES: AbortCase[] = [ + { + match: /^no posthog sdk detected$/i, + message: 'No PostHog SDK detected', + body: + 'The agent could not find a PostHog SDK in your project. ' + + 'Source map upload requires the SDK to already be installed so it can ' + + 'report errors. Run `npx @posthog/wizard` first to install the SDK.', + docsUrl: 'https://posthog.com/docs/error-tracking', + }, + { + match: /^build command not found$/i, + message: 'Build command not found', + body: + 'The agent could not identify how to build your project. Source map ' + + 'upload runs as part of the production build. Add a build script to ' + + 'your project and run this wizard again.', + docsUrl: 'https://posthog.com/docs/error-tracking/upload-source-maps', + }, + { + match: /^source map upload failed/i, + message: 'Source map upload failed', + body: + 'The build ran but the PostHog CLI reported an upload failure. ' + + 'Check your personal API key has the `error_tracking:write` scope ' + + 'and that POSTHOG_CLI_HOST points to the right region.', + docsUrl: 'https://posthog.com/docs/error-tracking/upload-source-maps/cli', + }, + { + match: /^verification failed/i, + message: 'Could not verify uploaded source maps', + body: + 'The build completed but the agent could not confirm a symbol set ' + + 'arrived in PostHog. Open Error Tracking → Symbol sets in the PostHog ' + + 'UI to check manually.', + docsUrl: 'https://posthog.com/docs/error-tracking/upload-source-maps', + }, +]; + +// ── File / dependency probes ───────────────────────────────────────── + +interface ProjectSignals { + packageJsons: Array<{ path: string; deps: Set }>; + hasXcodeProject: boolean; + hasPodfile: boolean; + hasSwiftPackage: boolean; + hasGradle: boolean; + hasPubspec: boolean; + scannedFileCount: number; +} + +function collectSignals(installDir: string, maxDepth = 3): ProjectSignals { + const signals: ProjectSignals = { + packageJsons: [], + hasXcodeProject: false, + hasPodfile: false, + hasSwiftPackage: false, + hasGradle: false, + hasPubspec: false, + scannedFileCount: 0, + }; + + function scan(dir: string, depth: number): void { + if (depth > maxDepth) return; + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.') continue; + if (IGNORED_DIRS.has(entry.name)) continue; + + const fullPath = join(dir, entry.name); + + if (entry.isFile()) { + signals.scannedFileCount += 1; + if (entry.name === 'package.json') { + try { + const pkg = JSON.parse(readFileSync(fullPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + const deps = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]); + signals.packageJsons.push({ + path: relative(installDir, fullPath) || 'package.json', + deps, + }); + } catch { + // skip malformed package.json + } + } else if (entry.name === 'Podfile') { + signals.hasPodfile = true; + } else if (entry.name === 'Package.swift') { + signals.hasSwiftPackage = true; + } else if (entry.name === 'pubspec.yaml') { + signals.hasPubspec = true; + } else if ( + entry.name === 'build.gradle' || + entry.name === 'build.gradle.kts' || + entry.name === 'settings.gradle' || + entry.name === 'settings.gradle.kts' + ) { + signals.hasGradle = true; + } + } else if (entry.isDirectory()) { + if (entry.name.endsWith('.xcodeproj')) { + signals.hasXcodeProject = true; + } else { + scan(fullPath, depth + 1); + } + } + } + } + + scan(installDir, 0); + return signals; +} + +// ── Skill selection ────────────────────────────────────────────────── + +function pickJsVariant(deps: Set): SkillVariant { + // Frameworks first (most specific). Order matters. + if (deps.has('react-native')) return 'react-native'; + if (deps.has('nuxt')) return 'nuxt'; + if (deps.has('next')) return 'nextjs'; + if (deps.has('@angular/core')) return 'angular'; + if (deps.has('react')) return 'react'; + // Then bundlers + if (deps.has('vite')) return 'vite'; + if (deps.has('webpack')) return 'webpack'; + if (deps.has('rollup')) return 'rollup'; + // Server-only Node project + if (deps.has('posthog-node') && !deps.has('react')) return 'node'; + // Fallback: generic web + return 'web'; +} + +function selectVariant(signals: ProjectSignals): SkillVariant | null { + // Mobile / native first — they don't coexist with JS bundlers in the + // detection signals we look at. + if (signals.hasPubspec) return 'flutter'; + if (signals.hasXcodeProject || signals.hasPodfile || signals.hasSwiftPackage) + return 'ios'; + if (signals.hasGradle) return 'android'; + + if (signals.packageJsons.length > 0) { + // Union all deps across package.json files (covers monorepos) + const allDeps = new Set(); + for (const pkg of signals.packageJsons) { + for (const dep of pkg.deps) allDeps.add(dep); + } + return pickJsVariant(allDeps); + } + + return null; +} + +function hasPostHogSdk(signals: ProjectSignals): boolean { + for (const pkg of signals.packageJsons) { + for (const sdk of POSTHOG_SDKS) { + if (pkg.deps.has(sdk)) return true; + } + } + // For native platforms the PostHog SDK lives outside package.json and + // is detected by the agent during the skill run. Assume present here. + return ( + signals.hasXcodeProject || + signals.hasPodfile || + signals.hasSwiftPackage || + signals.hasGradle || + signals.hasPubspec + ); +} + +// ── Entry point ────────────────────────────────────────────────────── + +export const SOURCE_MAPS_CONTEXT_KEYS = { + skillVariant: 'sourceMapsSkillVariant', + displayName: 'sourceMapsDisplayName', + packagePaths: 'sourceMapsPackagePaths', + detectError: 'detectError', +} as const; + +/** + * Scan `session.installDir` for platform / build-system signals. Writes + * detection results into frameworkContext via the callback — either the + * picked skill variant + display name, or a `SourceMapsDetectError`. + * + * The skill install happens later in the agent run, not here. This step + * only picks which variant the prompt should ask the agent to load. + */ +export function detectSourceMapsPrerequisites( + session: WizardSession, + setFrameworkContext: (key: string, value: unknown) => void, +): void { + const fail = (error: SourceMapsDetectError) => + setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.detectError, error); + + const installDir = session.installDir; + + if (!existsSync(installDir)) { + fail({ kind: 'bad-directory', path: installDir, reason: 'missing' }); + return; + } + try { + if (!statSync(installDir).isDirectory()) { + fail({ kind: 'bad-directory', path: installDir, reason: 'not-dir' }); + return; + } + } catch { + fail({ kind: 'bad-directory', path: installDir, reason: 'unreadable' }); + return; + } + + const signals = collectSignals(installDir); + const variant = selectVariant(signals); + + if (!variant) { + if (signals.scannedFileCount === 0) { + fail({ kind: 'no-project-files' }); + } else { + fail({ kind: 'unsupported-platform', detected: 'unknown' }); + } + return; + } + + if (!hasPostHogSdk(signals)) { + fail({ kind: 'no-posthog-sdk', platform: variant }); + return; + } + + setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.skillVariant, variant); + setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.displayName, + DISPLAY_NAME[variant], + ); + setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.packagePaths, + signals.packageJsons.map((p) => p.path), + ); +} + +export { DISPLAY_NAME as VARIANT_DISPLAY_NAME }; diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts new file mode 100644 index 00000000..6b262af8 --- /dev/null +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -0,0 +1,227 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import type { WorkflowRun } from '../../agent/agent-runner.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { OutroKind } from '../../wizard-session.js'; +import { AgentSignals } from '../../agent/agent-interface.js'; +import { ERROR_TRACKING_UPLOAD_SOURCE_MAPS_WORKFLOW } from './steps.js'; +import { + SOURCE_MAPS_ABORT_CASES, + SOURCE_MAPS_CONTEXT_KEYS, + type SkillVariant, +} from './detect.js'; +import { getContentBlocks } from './content/index.js'; + +const REPORT_FILE = 'posthog-source-maps-report.md'; +const DOCS_URL = 'https://posthog.com/docs/error-tracking/upload-source-maps'; +const SCOPE_LIST = ['error_tracking:write', 'organization:read']; + +export const errorTrackingUploadSourceMapsConfig: WorkflowConfig = { + command: 'upload-sourcemaps', + description: 'Upload source maps to PostHog Error Tracking', + flowKey: 'error-tracking-upload-source-maps', + steps: ERROR_TRACKING_UPLOAD_SOURCE_MAPS_WORKFLOW, + reportFile: REPORT_FILE, + getContentBlocks, + requires: ['posthog-integration'], + + run: (session: WizardSession): Promise => { + const variant = session.frameworkContext[ + SOURCE_MAPS_CONTEXT_KEYS.skillVariant + ] as SkillVariant | undefined; + const displayName = session.frameworkContext[ + SOURCE_MAPS_CONTEXT_KEYS.displayName + ] as string | undefined; + + const skillId = variant + ? `error-tracking-upload-source-maps-${variant}` + : undefined; + + return Promise.resolve({ + integrationLabel: 'error-tracking-upload-source-maps', + // Skill is installed by the agent (after the API-key choice is made) + // rather than pre-installed by the runner, so leave skillId unset. + successMessage: 'Source maps wired up!', + reportFile: REPORT_FILE, + docsUrl: DOCS_URL, + spinnerMessage: 'Uploading source maps...', + estimatedDurationMinutes: 5, + abortCases: SOURCE_MAPS_ABORT_CASES, + + customPrompt: (ctx) => { + if (!skillId || !variant) { + // Detection failed but the user got past the intro somehow. + // Tell the agent to abort with a structured signal so the runner + // renders a friendly outro. + return `Detection did not pick a source maps skill variant for this project. +Emit: ${AgentSignals.ABORT} unsupported-platform +Then halt.`; + } + + const scopes = SCOPE_LIST.map((s) => `\`${s}\``).join(' and '); + + return `You are wiring up PostHog Error Tracking source map upload for this ${ + displayName ?? variant + } project. + +Project context: +- PostHog Project ID: ${ctx.projectId} +- PostHog Host: ${ctx.host} +- Detected platform: ${displayName ?? variant} +- Skill to use: ${skillId} + +Follow these steps IN ORDER. Do not skip or reorder. + +STEP 1 — Ask the user how to obtain the personal API key. + Call the wizard_ask MCP tool with one question in the questions array: + { + id: "key-source", + prompt: "Personal API key for source map upload", + kind: "single", + options: [ + { label: "Generate a new one (recommended)", value: "generate" }, + { label: "I already have one", value: "existing" } + ] + } + + Branch on the answer (the returned value, not the label): + + (a) value === "generate": + Call the create_personal_api_key MCP tool (wizard-tools server) with: + label: "Wizard source maps · ${ctx.projectId}" + scopes: ${JSON.stringify(SCOPE_LIST)} + The tool returns the raw key value. Keep it in memory for STEP 4 — + do NOT echo it back to the user and do NOT write it anywhere except + the .env file via set_env_values. + + (b) value === "existing": + Call wizard_ask again with one text question: + { + id: "existing-key", + prompt: "Paste your PostHog personal API key (must have ${scopes} scope)", + kind: "text" + } + Treat the answer as the key value for STEP 4. + + If wizard_ask is unavailable (CI / non-interactive), emit + ${AgentSignals.ABORT} requires-interactive-mode and halt. + +STEP 2 — Install the skill. + Call install_skill (wizard-tools MCP server) with skillId "${skillId}". + Do NOT run shell commands to install skills. + If install fails, emit ${ + AgentSignals.ERROR_RESOURCE_MISSING + } skill ${skillId} could not be installed. + +STEP 3 — Load and follow the skill. + Read the installed skill's SKILL.md and reference files. Make all + bundler / build-config changes the skill instructs. Do not invent steps + that are not in the skill — the skill is the source of truth for this + platform. + +STEP 4 — Seed the personal API key into the environment. + Use the wizard-tools MCP server. Do NOT write the key to source code or + echo it to the terminal. + - First call check_env_keys to see which keys exist in the project's + .env file (.env, .env.local, etc.). + - Then call set_env_values to write the key. The variable name depends + on which CLI/plugin the skill uses — follow the skill's reference. + Common conventions: + - posthog-cli direct upload → POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID, POSTHOG_CLI_HOST + - bundler plugin variants → POSTHOG_API_KEY, POSTHOG_PROJECT_ID, POSTHOG_HOST + Always set the project id (${ctx.projectId}) and host (${ctx.host}) + alongside the key. + +STEP 5 — Identify the build command and ask for permission to run it. + Inspect the project to find the production build command. Look at: + - package.json scripts ("build", "build:prod", etc.) + - gradle wrapper / xcodebuild scheme / Makefile target as appropriate + Then call wizard_ask with one question: + { + id: "run-build", + prompt: "Run now to upload source maps?", + kind: "single", + options: [ + { label: "Yes, run it", value: "run" }, + { label: "No, I'll run it myself later", value: "skip" } + ] + } + + If value === "run": execute the build via the Bash tool. Capture stdout and stderr. + Scan output for PostHog CLI markers: + - Success indicators: "Uploaded N source maps", "Symbol set", a UUID, + "posthog-cli sourcemap upload" exit 0. + - Failure indicators: "401", "403", "Invalid API key", "no source + maps found". + + If failure indicators appear, emit ${ + AgentSignals.ABORT + } source map upload failed: . + If value === "skip", do not run the build. Include manual instructions + in the summary at STEP 7. + +STEP 6 — Verify the upload via the PostHog MCP. + Only run this step if STEP 5 ran the build and reported success markers. + Query the posthog-wizard MCP server for symbol sets on project ${ + ctx.projectId + }. + If at least one symbol set with a recent created_at exists, the upload + worked. + If zero symbol sets exist for this project, emit ${ + AgentSignals.ABORT + } verification failed: no symbol sets found. + +STEP 7 — Summarise. + Tell the user what changed: which files you edited, which env vars you + set (key names only, never values), whether the build ran, and either + the verified symbol set or the manual command for them to run. + +Important: +- Use detect_package_manager (wizard-tools MCP server) before running any + npm/pnpm/yarn install commands. +- Always install packages in the background. Don't await completion. +- You must read a file immediately before writing it, even if you have + already read it. +- Never log or print the personal API key value. +`; + }, + + postRun: (sess) => { + // Stash a hint for the outro about what variant we shipped. + if (variant) { + sess.frameworkContext['sourceMapsCompletedVariant'] = variant; + } + return Promise.resolve(); + }, + + buildOutroData: (sess) => { + const completed = sess.frameworkContext[ + 'sourceMapsCompletedVariant' + ] as SkillVariant | undefined; + const changes = [ + completed + ? `Configured source map upload for ${displayName ?? completed}` + : '', + 'Added PostHog credentials to .env', + ].filter(Boolean); + + return { + kind: OutroKind.Success as const, + message: 'Source maps wired up!', + reportFile: REPORT_FILE, + changes, + docsUrl: DOCS_URL, + }; + }, + }); + }, +}; + +export { ERROR_TRACKING_UPLOAD_SOURCE_MAPS_WORKFLOW } from './steps.js'; +export { + detectSourceMapsPrerequisites, + SOURCE_MAPS_ABORT_CASES, + SOURCE_MAPS_CONTEXT_KEYS, + VARIANT_DISPLAY_NAME, + type SkillVariant, + type SourceMapsDetectError, +} from './detect.js'; diff --git a/src/lib/workflows/error-tracking-upload-source-maps/steps.ts b/src/lib/workflows/error-tracking-upload-source-maps/steps.ts new file mode 100644 index 00000000..a662e058 --- /dev/null +++ b/src/lib/workflows/error-tracking-upload-source-maps/steps.ts @@ -0,0 +1,54 @@ +/** + * Error tracking source maps upload workflow step list. + * + * Detection runs headless via onReady, then the user sees a custom intro + * showing the picked skill variant. Auth → agent run → outro mirrors the + * other workflows. + */ + +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../../wizard-session.js'; +import { detectSourceMapsPrerequisites } from './detect.js'; + +export const ERROR_TRACKING_UPLOAD_SOURCE_MAPS_WORKFLOW: Workflow = [ + { + id: 'detect', + label: 'Detecting platform', + // Headless: scans for platform / build-system signals and picks the + // matching context-mill skill variant. Writes either the variant or + // a detectError to frameworkContext. + onReady: (ctx) => + detectSourceMapsPrerequisites(ctx.session, ctx.setFrameworkContext), + }, + { + id: 'intro', + label: 'Welcome', + screen: 'source-maps-intro', + gate: (session) => session.setupConfirmed, + }, + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Upload source maps', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, + { + id: 'skills', + label: 'Skills', + screen: 'keep-skills', + }, +]; diff --git a/src/lib/workflows/workflow-registry.ts b/src/lib/workflows/workflow-registry.ts index 3f02e5c9..c96c77b5 100644 --- a/src/lib/workflows/workflow-registry.ts +++ b/src/lib/workflows/workflow-registry.ts @@ -18,6 +18,7 @@ import { auditConfig } from './audit/index.js'; import { eventsAuditConfig } from './events-audit/index.js'; import { audit3000Config } from './audit-3000/index.js'; import { posthogDoctorConfig } from './posthog-doctor/index.js'; +import { errorTrackingUploadSourceMapsConfig } from './error-tracking-upload-source-maps/index.js'; export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ posthogIntegrationConfig, @@ -26,6 +27,7 @@ export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ eventsAuditConfig, audit3000Config, posthogDoctorConfig, + errorTrackingUploadSourceMapsConfig, ]; /** Look up a workflow config by its flowKey. */ diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index 93727f73..6e83246d 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -23,6 +23,7 @@ import { AGENT_SKILL_STEPS } from '../../lib/workflows/agent-skill/index.js'; export enum Screen { Intro = 'intro', RevenueIntro = 'revenue-intro', + SourceMapsIntro = 'source-maps-intro', AgentSkillIntro = 'agent-skill-intro', AuditIntro = 'audit-intro', AuditRun = 'audit-run', @@ -48,6 +49,7 @@ export enum Screen { export enum Flow { PostHogIntegration = 'posthog-integration', RevenueAnalyticsSetup = 'revenue-analytics-setup', + ErrorTrackingUploadSourceMaps = 'error-tracking-upload-source-maps', Audit = 'audit', EventsAudit = 'events-audit', Audit3000 = 'audit-3000', diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 7bea2952..6c594de6 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -20,6 +20,7 @@ import { ManagedSettingsScreen } from './screens/ManagedSettingsScreen.js'; import { PortConflictScreen } from './screens/PortConflictScreen.js'; import { PostHogIntegrationIntroScreen } from './screens/PostHogIntegrationIntroScreen.js'; import { RevenueIntroScreen } from './screens/RevenueIntroScreen.js'; +import { SourceMapsIntroScreen } from './screens/SourceMapsIntroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { AuditIntroScreen } from './screens/audit/AuditIntroScreen.js'; import { AuditRunScreen } from './screens/audit/AuditRunScreen.js'; @@ -64,6 +65,7 @@ export function createScreens( // Wizard flow [Screen.Intro]: , [Screen.RevenueIntro]: , + [Screen.SourceMapsIntro]: , [Screen.AgentSkillIntro]: , [Screen.AuditIntro]: , [Screen.AuditRun]: , diff --git a/src/ui/tui/screens/SourceMapsIntroScreen.tsx b/src/ui/tui/screens/SourceMapsIntroScreen.tsx new file mode 100644 index 00000000..e0c78443 --- /dev/null +++ b/src/ui/tui/screens/SourceMapsIntroScreen.tsx @@ -0,0 +1,185 @@ +/** + * SourceMapsIntroScreen — Welcome screen for the source-maps upload flow. + * + * Reads detection results from frameworkContext (written by + * detectSourceMapsPrerequisites). On success: shows the detected platform. + * On failure: shows the structured error with an Exit prompt. + */ + +import { Box, Text } from 'ink'; +import { useSyncExternalStore } from 'react'; +import type { WizardStore } from '../store.js'; +import { PickerMenu } from '../primitives/index.js'; +import { IntroScreenLayout, type DetectionRow } from './IntroScreenLayout.js'; +import { + SOURCE_MAPS_CONTEXT_KEYS, + VARIANT_DISPLAY_NAME, + type SkillVariant, + type SourceMapsDetectError, +} from '../../../lib/workflows/error-tracking-upload-source-maps/index.js'; + +interface SourceMapsIntroScreenProps { + store: WizardStore; +} + +export const SourceMapsIntroScreen = ({ + store, +}: SourceMapsIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const { session } = store; + const detectError = session.frameworkContext[ + SOURCE_MAPS_CONTEXT_KEYS.detectError + ] as SourceMapsDetectError | undefined; + const variant = session.frameworkContext[ + SOURCE_MAPS_CONTEXT_KEYS.skillVariant + ] as SkillVariant | undefined; + const displayName = session.frameworkContext[ + SOURCE_MAPS_CONTEXT_KEYS.displayName + ] as string | undefined; + const packagePaths = + (session.frameworkContext[SOURCE_MAPS_CONTEXT_KEYS.packagePaths] as + | string[] + | undefined) ?? []; + + const detectionRows: DetectionRow[] = []; + if (displayName) { + detectionRows.push({ label: 'Platform', value: displayName }); + } + if (variant) { + detectionRows.push({ + label: 'Skill', + value: `error-tracking-upload-source-maps-${variant}`, + }); + } + + const body = ( + <> + + Upload source maps so error stack traces de-minify. + + The agent will wire it into your build. + + + + {packagePaths.length > 1 && ( + + Found {packagePaths.length} package.json files: + {packagePaths.map((p) => ( + + {' '} + {'•'} {p} + + ))} + + )} + + ); + + const errorView = detectError ? ( + <> + + + {'✘'} Cannot set up source map upload + + + + + + + process.exit(1)} + /> + + ) : undefined; + + const menuOptions = [ + { label: 'Continue', value: 'continue' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + return ( + { + if (value === 'cancel') { + process.exit(0); + } else { + store.completeSetup(); + } + }} + /> + ); +}; + +const DetectErrorBody = ({ error }: { error: SourceMapsDetectError }) => { + switch (error.kind) { + case 'bad-directory': { + const reasonText = { + missing: 'does not exist', + 'not-dir': 'is not a directory', + unreadable: 'could not be accessed', + }[error.reason]; + return ( + <> + This path {reasonText}: + + {' '} + {error.path} + + + ); + } + + case 'no-project-files': + return ( + <> + No recognizable project files were found. + + Source map upload needs a package.json, Xcode project, Gradle build, + or Flutter pubspec.yaml. + + Run this command from your project root. + + ); + + case 'unsupported-platform': + return ( + <> + Source map upload isn't supported for this stack yet. + + Open an issue at https://github.com/PostHog/wizard/issues with + details about your build setup — we'd like to add it. + + + ); + + case 'no-posthog-sdk': { + const platformLabel = + VARIANT_DISPLAY_NAME[error.platform] ?? error.platform; + return ( + <> + Detected {platformLabel} but no PostHog SDK is installed. + + + Source map upload only resolves stack traces from errors the SDK + reports. Run npx @posthog/wizard first to + install the SDK, then run this command again. + + + + ); + } + } +}; From 1697ae365a7e7649cce13cd441d07a3dcbab7cf3 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 25 May 2026 17:16:46 +0200 Subject: [PATCH 02/10] feat: secret vault --- src/lib/__tests__/secret-vault.test.ts | 74 ++++++ src/lib/agent/agent-interface.ts | 2 - src/lib/constants.ts | 12 +- src/lib/secret-vault.ts | 79 ++++++ src/lib/wizard-session.ts | 8 + src/lib/wizard-tools.ts | 247 +++++++----------- .../index.ts | 72 +++-- tsdown.config.ts | 2 +- 8 files changed, 306 insertions(+), 190 deletions(-) create mode 100644 src/lib/__tests__/secret-vault.test.ts create mode 100644 src/lib/secret-vault.ts diff --git a/src/lib/__tests__/secret-vault.test.ts b/src/lib/__tests__/secret-vault.test.ts new file mode 100644 index 00000000..6e1db366 --- /dev/null +++ b/src/lib/__tests__/secret-vault.test.ts @@ -0,0 +1,74 @@ +import { createSecretVault, isSecretRef } from '../secret-vault'; + +describe('createSecretVault', () => { + it('stores a value and returns a ref', () => { + const vault = createSecretVault(); + const ref = vault.put('phx_super_secret', { + label: 'PostHog key', + source: 'test', + }); + + expect(isSecretRef(ref)).toBe(true); + expect(ref).toMatch(/^secret:[0-9a-f-]{36}$/); + expect(vault.has(ref)).toBe(true); + expect(vault.get(ref)).toBe('phx_super_secret'); + }); + + it('mints a fresh ref per put even for the same value', () => { + const vault = createSecretVault(); + const a = vault.put('same-value', { label: 'A', source: 'test' }); + const b = vault.put('same-value', { label: 'B', source: 'test' }); + + expect(a).not.toBe(b); + expect(vault.get(a)).toBe('same-value'); + expect(vault.get(b)).toBe('same-value'); + }); + + it('returns undefined for unknown refs', () => { + const vault = createSecretVault(); + expect(vault.get('secret:does-not-exist')).toBeUndefined(); + expect(vault.has('secret:does-not-exist')).toBe(false); + }); + + it('list() returns metadata only, never values', () => { + const vault = createSecretVault(); + vault.put('value-1', { label: 'one', source: 'src-a' }); + vault.put('value-2', { label: 'two', source: 'src-b' }); + + const metas = vault.list(); + expect(metas).toHaveLength(2); + expect(metas.map((m) => m.label).sort()).toEqual(['one', 'two']); + expect(metas.map((m) => m.source).sort()).toEqual(['src-a', 'src-b']); + // Ensure no `value` key bled into the metadata + for (const m of metas) { + expect(m).not.toHaveProperty('value'); + } + }); + + it('clear() drops every secret', () => { + const vault = createSecretVault(); + const ref = vault.put('gone', { label: 'temp', source: 'test' }); + vault.clear(); + expect(vault.has(ref)).toBe(false); + expect(vault.get(ref)).toBeUndefined(); + expect(vault.list()).toEqual([]); + }); + + it('isolates vault instances from each other', () => { + const a = createSecretVault(); + const b = createSecretVault(); + const ref = a.put('only-in-a', { label: 'a-only', source: 'test' }); + + expect(a.has(ref)).toBe(true); + expect(b.has(ref)).toBe(false); + }); + + it('isSecretRef recognises refs and rejects garbage', () => { + expect(isSecretRef('secret:abc')).toBe(true); + expect(isSecretRef('not a ref')).toBe(false); + expect(isSecretRef('')).toBe(false); + expect(isSecretRef(null)).toBe(false); + expect(isSecretRef(undefined)).toBe(false); + expect(isSecretRef({ secretRef: 'secret:abc' })).toBe(false); + }); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 71c02cb6..2144f49d 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -717,8 +717,6 @@ export async function initializeAgent( skillsBaseUrl: config.skillsBaseUrl, askBridge: config.askBridge, askMaxQuestions: config.askMaxQuestions, - posthogAccessToken: config.posthogApiKey, - posthogHost: config.posthogApiHost, }); mcpServers['wizard-tools'] = wizardToolsServer; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d48ebb7f..36f3b40c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -118,15 +118,21 @@ export const WIZARD_PROVISIONING_SCOPES = [ /** * Scopes the wizard requests during the OAuth login flow. Superset of - * `WIZARD_PROVISIONING_SCOPES` with two scopes that only apply to the login + * `WIZARD_PROVISIONING_SCOPES` with extra scopes that only apply to the login * path and are not in the provisioning allowlist: - * - introspection lets the wizard introspect its own token - * - health_issue:read used by `wizard doctor` + * - introspection lets the wizard introspect its own token + * - health_issue:read used by `wizard doctor` + * - personal_api_key:write lets the `create_personal_api_key` MCP tool mint + * PATs for workflows like source-map upload. Note: + * the OAuth token's scopes do NOT constrain the + * scopes of the resulting PAT — keep the requested + * PAT scopes narrow (see wizard-tools.ts callers). */ export const WIZARD_OAUTH_SCOPES = [ ...WIZARD_PROVISIONING_SCOPES, 'introspection', 'health_issue:read', + 'personal_api_key:write', ] as const; // ── Wizard run / variants ─────────────────────────────────────────── diff --git a/src/lib/secret-vault.ts b/src/lib/secret-vault.ts new file mode 100644 index 00000000..2359bb75 --- /dev/null +++ b/src/lib/secret-vault.ts @@ -0,0 +1,79 @@ +/** + * Session-scoped secret vault. + * + * Tools that handle sensitive values (personal API keys pasted by the user + * or minted by the wizard, OAuth tokens, etc.) store them here and return + * an opaque `secret:` ref to the agent. The agent passes the ref + * around to subsequent tools — `set_env_values` resolves it host-side + * before writing — but the raw value never enters the LLM conversation. + * + * The vault is created once per `createWizardToolsServer` call and lives + * for the duration of a single wizard run. There is no persistence and + * no cross-session sharing; refs minted in one run cannot be resolved in + * another. + */ + +import { randomUUID } from 'crypto'; + +const REF_PREFIX = 'secret:'; + +export interface SecretMeta { + /** Opaque reference handed to the agent. */ + ref: string; + /** Human-readable label shown to the user (e.g. "Personal API key"). */ + label: string; + /** Where the secret came from (e.g. "create_personal_api_key", "wizard_ask"). */ + source: string; + /** ms epoch when the secret was stored. */ + createdAt: number; +} + +export interface SecretVault { + /** Store a value and return its ref. */ + put(value: string, meta: Omit): string; + /** Resolve a ref to its value, or undefined if unknown. */ + get(ref: string): string | undefined; + /** Whether the vault knows about this ref. */ + has(ref: string): boolean; + /** Metadata for every stored secret (never the values). */ + list(): SecretMeta[]; + /** Drop every secret. Call at session teardown. */ + clear(): void; +} + +/** True when the value looks like a secret ref. Does not assert resolvability. */ +export function isSecretRef(value: unknown): value is string { + return typeof value === 'string' && value.startsWith(REF_PREFIX); +} + +export function createSecretVault(): SecretVault { + const store = new Map(); + + return { + put(value, meta) { + const ref = `${REF_PREFIX}${randomUUID()}`; + store.set(ref, { + value, + meta: { + ref, + label: meta.label, + source: meta.source, + createdAt: Date.now(), + }, + }); + return ref; + }, + get(ref) { + return store.get(ref)?.value; + }, + has(ref) { + return store.has(ref); + }, + list() { + return [...store.values()].map((s) => s.meta); + }, + clear() { + store.clear(); + }, + }; +} diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 36cc070f..a86543bf 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -105,6 +105,14 @@ export interface AskQuestion { options?: { label: string; value: string }[]; /** Defaults to true */ required?: boolean; + /** + * Only meaningful for kind='text'. When true, the wizard-tools `wizard_ask` + * tool stores the user's answer in the session secret vault and returns + * `{ secretRef }` to the agent instead of the plain string — so the value + * never enters the LLM conversation. The TUI may also mask input + * accordingly. See `secret-vault.ts`. + */ + sensitive?: boolean; } /** Map of question id → answer (string for single/text, string[] for multi). */ diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index a77f46eb..e601a0f7 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -24,6 +24,7 @@ import { type AuditStatus, } from './workflows/audit/types'; import type { WizardAskBridge } from './wizard-ask-bridge'; +import { createSecretVault, type SecretVault } from './secret-vault'; // --------------------------------------------------------------------------- // SDK dynamic import (ESM module loaded once, cached) @@ -194,15 +195,13 @@ export interface WizardToolsOptions { askMaxQuestions?: number; /** - * Authenticated PostHog access token for the current user. Used by - * `create_personal_api_key` to mint new keys on the user's behalf. - * Omit in non-authenticated test environments — the tool then refuses - * to run. + * Optional secret vault. When provided, tools that handle sensitive + * values (wizard_ask with `sensitive: true`, set_env_values) route + * those values through the vault and return opaque refs to the agent + * instead of raw strings — so the LLM never sees them. When omitted + * (e.g. in unit tests), a fresh vault is created internally. */ - posthogAccessToken?: string; - - /** PostHog host (e.g. https://us.posthog.com). Paired with the access token. */ - posthogHost?: string; + secretVault?: SecretVault; } /** Default per-run cap on wizard_ask calls when no override is provided. */ @@ -505,8 +504,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { skillsBaseUrl, askBridge, askMaxQuestions = DEFAULT_ASK_MAX_QUESTIONS, - posthogAccessToken, - posthogHost, + secretVault = createSecretVault(), } = options; const sdk = await getSDKModule(); const { tool, createSdkMcpServer } = sdk; @@ -566,16 +564,24 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { const setEnvValues = tool( 'set_env_values', - 'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage.', + 'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage. Each value can be either a literal string or a secret reference of the form `{ "secretRef": "secret:..." }` returned by another tool (create_personal_api_key, wizard_ask). Secret references are resolved locally — the actual value is written to the file but never returned to the agent.', { filePath: z .string() .describe('Path to the .env file, relative to the project root'), values: z - .record(z.string(), z.string()) - .describe('Key-value pairs to set'), + .record( + z.string(), + z.union([z.string(), z.object({ secretRef: z.string() })]), + ) + .describe( + 'Key → (literal string OR { secretRef } pointing to a vaulted secret)', + ), }, - (args: { filePath: string; values: Record }) => { + (args: { + 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', @@ -592,17 +598,45 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { }; } + // Resolve any secret refs from the vault before writing. + const resolvedValues: Record = {}; + const resolvedRefKeys: string[] = []; + for (const [key, val] of Object.entries(args.values)) { + if (typeof val === 'string') { + resolvedValues[key] = val; + } else { + const secret = secretVault.get(val.secretRef); + if (secret === undefined) { + return { + content: [ + { + type: 'text' as const, + text: `Error: secret reference "${val.secretRef}" for key "${key}" is not known to the vault. The ref may have expired, been minted in a different run, or been mistyped.`, + }, + ], + isError: true, + }; + } + resolvedValues[key] = secret; + resolvedRefKeys.push(key); + } + } + const resolved = resolveEnvPath(workingDirectory, args.filePath); logToFile( - `set_env_values: ${resolved}, keys: ${Object.keys(args.values).join( + `set_env_values: ${resolved}, keys: ${Object.keys(resolvedValues).join( ', ', - )}`, + )}${ + resolvedRefKeys.length > 0 + ? ` (secret refs: ${resolvedRefKeys.join(', ')})` + : '' + }`, ); const existing = fs.existsSync(resolved) ? fs.readFileSync(resolved, 'utf8') : ''; - const content = mergeEnvValues(existing, args.values); + const content = mergeEnvValues(existing, resolvedValues); // Ensure parent directory exists const dir = path.dirname(resolved); @@ -753,132 +787,6 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { }, ); - // -- create_personal_api_key --------------------------------------------- - - const createPersonalApiKey = tool( - 'create_personal_api_key', - "Create a new PostHog personal API key with the requested scopes on the authenticated user's account. Returns the raw key value — never log it or echo it back to the user. Use set_env_values to write it to .env. Intended for workflows like source map upload where the wizard cannot reuse its own credentials.", - { - label: z - .string() - .min(1) - .max(120) - .describe( - 'Human-readable label so the user can find and revoke the key later (e.g. "Wizard source maps · ")', - ), - scopes: z - .array(z.string()) - .min(1) - .describe( - 'PostHog API scopes to grant (e.g. ["error_tracking:write", "organization:read"])', - ), - }, - async (args: { label: string; scopes: string[] }) => { - if (!posthogAccessToken || !posthogHost) { - return { - content: [ - { - type: 'text' as const, - text: 'Error: create_personal_api_key is unavailable in this environment (no PostHog credentials). Ask the user to paste an existing personal API key instead.', - }, - ], - isError: true, - }; - } - - const url = `${posthogHost.replace(/\/$/, '')}/api/personal_api_keys/`; - logToFile( - `create_personal_api_key: POST ${url} (scopes=${args.scopes.join( - ',', - )})`, - ); - - try { - const resp = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${posthogAccessToken}`, - }, - body: JSON.stringify({ - label: args.label, - scopes: args.scopes, - }), - }); - - if (!resp.ok) { - const text = await resp.text().catch(() => ''); - logToFile( - `create_personal_api_key: HTTP ${resp.status} ${text.slice( - 0, - 200, - )}`, - ); - return { - content: [ - { - type: 'text' as const, - text: `Error: create_personal_api_key failed (HTTP ${ - resp.status - }). The server said: ${text.slice(0, 300) || '(empty body)'}`, - }, - ], - isError: true, - }; - } - - const data = (await resp.json()) as { value?: string }; - if (!data.value) { - return { - content: [ - { - type: 'text' as const, - text: 'Error: create_personal_api_key succeeded but the response did not include a key value.', - }, - ], - isError: true, - }; - } - - analytics.wizardCapture('personal api key created', { - scopes: args.scopes, - }); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - value: data.value, - label: args.label, - scopes: args.scopes, - }, - null, - 2, - ), - }, - ], - }; - } catch (err: any) { - logToFile( - `create_personal_api_key: network error: ${err?.message ?? err}`, - ); - return { - content: [ - { - type: 'text' as const, - text: `Error: create_personal_api_key network error: ${ - err?.message ?? String(err) - }`, - }, - ], - isError: true, - }; - } - }, - ); - // -- audit_seed_checks ---------------------------------------------------- const auditLedgerPath = path.join(workingDirectory, AUDIT_CHECKS_FILE); @@ -1034,6 +942,12 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { .optional() .describe('Required for kind=single|multi; ignored for kind=text'), required: z.boolean().optional().describe('Defaults to true'), + sensitive: z + .boolean() + .optional() + .describe( + "Only valid for kind='text'. When true, the user's answer is stored in the wizard's secret vault and returned to you as { secretRef: 'secret:...' } instead of the raw string. Use for API keys, tokens, and any other secret the user types in.", + ), }); const wizardAsk = tool( @@ -1051,6 +965,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { kind: 'single' | 'multi' | 'text'; options?: { label: string; value: string }[]; required?: boolean; + sensitive?: boolean; }>; }) => { if (!askBridge) { @@ -1095,6 +1010,17 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { isError: true, }; } + if (q.sensitive && q.kind !== 'text') { + return { + content: [ + { + type: 'text' as const, + text: `Error: question "${q.id}" sets sensitive=true but kind="${q.kind}". Only kind="text" answers can be vaulted as secrets.`, + }, + ], + isError: true, + }; + } } const ids = new Set(); @@ -1117,6 +1043,37 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { try { const answers = await askBridge.request({ questions: args.questions }); + + // For any question marked sensitive, move the raw answer into the + // vault and replace it with an opaque ref before returning to the + // agent — so the secret never enters the LLM conversation. + const sensitiveById = new Map( + args.questions + .filter((q) => q.sensitive) + .map((q) => [q.id, q.prompt]), + ); + const sanitised: Record< + string, + string | string[] | { secretRef: string } + > = {}; + for (const [id, answer] of Object.entries(answers)) { + const label = sensitiveById.get(id); + if ( + label !== undefined && + typeof answer === 'string' && + answer !== '__cancelled__' + ) { + const ref = secretVault.put(answer, { + label, + source: 'wizard_ask', + }); + sanitised[id] = { secretRef: ref }; + logToFile(`wizard_ask: vaulted answer for "${id}" as ${ref}`); + } else { + sanitised[id] = answer; + } + } + logToFile( `wizard_ask: resolved ${Object.keys(answers).length} answer(s) for ${ args.questions.length @@ -1126,7 +1083,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { content: [ { type: 'text' as const, - text: JSON.stringify({ answers }, null, 2), + text: JSON.stringify({ answers: sanitised }, null, 2), }, ], }; @@ -1156,7 +1113,6 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { detectPM, loadSkillMenu, installSkill, - createPersonalApiKey, auditSeedChecks, auditAddChecks, auditResolveChecks, @@ -1172,7 +1128,6 @@ export const WIZARD_TOOL_NAMES = [ `${SERVER_NAME}:detect_package_manager`, `${SERVER_NAME}:load_skill_menu`, `${SERVER_NAME}:install_skill`, - `${SERVER_NAME}:create_personal_api_key`, `${SERVER_NAME}:audit_seed_checks`, `${SERVER_NAME}:audit_add_checks`, `${SERVER_NAME}:audit_resolve_checks`, diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index 6b262af8..e3a0b632 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -13,7 +13,6 @@ import { getContentBlocks } from './content/index.js'; const REPORT_FILE = 'posthog-source-maps-report.md'; const DOCS_URL = 'https://posthog.com/docs/error-tracking/upload-source-maps'; -const SCOPE_LIST = ['error_tracking:write', 'organization:read']; export const errorTrackingUploadSourceMapsConfig: WorkflowConfig = { command: 'upload-sourcemaps', @@ -57,7 +56,9 @@ Emit: ${AgentSignals.ABORT} unsupported-platform Then halt.`; } - const scopes = SCOPE_LIST.map((s) => `\`${s}\``).join(' and '); + const settingsUrl = `${ctx.host.replace(/\/$/, '')}/project/${ + ctx.projectId + }/settings/user-api-keys`; return `You are wiring up PostHog Error Tracking source map upload for this ${ displayName ?? variant @@ -68,39 +69,27 @@ Project context: - PostHog Host: ${ctx.host} - Detected platform: ${displayName ?? variant} - Skill to use: ${skillId} +- Personal API keys settings page: ${settingsUrl} Follow these steps IN ORDER. Do not skip or reorder. -STEP 1 — Ask the user how to obtain the personal API key. - Call the wizard_ask MCP tool with one question in the questions array: +STEP 1 — Obtain a personal API key from the user. + Source maps upload requires a personal API key the user creates in their + PostHog settings. The wizard cannot mint keys and you must never attempt + to do so via the PostHog API or any other tool. The user supplies the + key; you only receive it as a vaulted secret reference (so the raw + value never enters this conversation). + + Call the wizard_ask MCP tool with exactly one sensitive question that + both explains where to get the key and accepts it: { - id: "key-source", - prompt: "Personal API key for source map upload", - kind: "single", - options: [ - { label: "Generate a new one (recommended)", value: "generate" }, - { label: "I already have one", value: "existing" } - ] + id: "api-key", + prompt: "Paste your PostHog personal API key. If you don't have one yet, open ${settingsUrl} in your browser, create a key with the error_tracking:write scope, then come back and paste it here.", + kind: "text", + sensitive: true } - - Branch on the answer (the returned value, not the label): - - (a) value === "generate": - Call the create_personal_api_key MCP tool (wizard-tools server) with: - label: "Wizard source maps · ${ctx.projectId}" - scopes: ${JSON.stringify(SCOPE_LIST)} - The tool returns the raw key value. Keep it in memory for STEP 4 — - do NOT echo it back to the user and do NOT write it anywhere except - the .env file via set_env_values. - - (b) value === "existing": - Call wizard_ask again with one text question: - { - id: "existing-key", - prompt: "Paste your PostHog personal API key (must have ${scopes} scope)", - kind: "text" - } - Treat the answer as the key value for STEP 4. + The answer comes back as { secretRef: "secret:..." } — never the raw + string. Remember this secretRef for STEP 4. If wizard_ask is unavailable (CI / non-interactive), emit ${AgentSignals.ABORT} requires-interactive-mode and halt. @@ -119,17 +108,22 @@ STEP 3 — Load and follow the skill. platform. STEP 4 — Seed the personal API key into the environment. - Use the wizard-tools MCP server. Do NOT write the key to source code or - echo it to the terminal. + Use the wizard-tools MCP server. - First call check_env_keys to see which keys exist in the project's .env file (.env, .env.local, etc.). - - Then call set_env_values to write the key. The variable name depends - on which CLI/plugin the skill uses — follow the skill's reference. - Common conventions: + - Then call set_env_values to write the key. Pass the secretRef from + STEP 1 as the value object, not a literal string: + values: { + "POSTHOG_CLI_API_KEY": { secretRef: "" }, + "POSTHOG_CLI_PROJECT_ID": "${ctx.projectId}", + "POSTHOG_CLI_HOST": "${ctx.host}" + } + The variable names depend on which CLI/plugin the skill uses — follow + the skill's reference. Common conventions: - posthog-cli direct upload → POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID, POSTHOG_CLI_HOST - bundler plugin variants → POSTHOG_API_KEY, POSTHOG_PROJECT_ID, POSTHOG_HOST - Always set the project id (${ctx.projectId}) and host (${ctx.host}) - alongside the key. + You do not need to know the key value to write it — the wizard resolves + the ref locally before writing the file. STEP 5 — Identify the build command and ask for permission to run it. Inspect the project to find the production build command. Look at: @@ -181,7 +175,9 @@ Important: - Always install packages in the background. Don't await completion. - You must read a file immediately before writing it, even if you have already read it. -- Never log or print the personal API key value. +- You will never see the raw personal API key. Always pass secretRef + objects to set_env_values. Never attempt to mint a key for the user — + always direct them to the settings page. `; }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 15dfd7b6..5dcbcc84 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ // After build, setting NODE_ENV at runtime has zero effect on the wizard. // To add a new build-time constant, add it here AND in src/env.ts. env: { - NODE_ENV: 'production', + NODE_ENV: process.env.NODE_ENV ?? 'production', }, // Keep npm dependencies external — they're installed at runtime. From 7843931d1c4329f99f0cfb9aad939ee09396d7b6 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 25 May 2026 17:42:41 +0200 Subject: [PATCH 03/10] feat: remove key scope --- src/lib/constants.ts | 12 +++--------- src/lib/secret-vault.ts | 2 +- src/lib/wizard-tools.ts | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 36f3b40c..d48ebb7f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -118,21 +118,15 @@ export const WIZARD_PROVISIONING_SCOPES = [ /** * Scopes the wizard requests during the OAuth login flow. Superset of - * `WIZARD_PROVISIONING_SCOPES` with extra scopes that only apply to the login + * `WIZARD_PROVISIONING_SCOPES` with two scopes that only apply to the login * path and are not in the provisioning allowlist: - * - introspection lets the wizard introspect its own token - * - health_issue:read used by `wizard doctor` - * - personal_api_key:write lets the `create_personal_api_key` MCP tool mint - * PATs for workflows like source-map upload. Note: - * the OAuth token's scopes do NOT constrain the - * scopes of the resulting PAT — keep the requested - * PAT scopes narrow (see wizard-tools.ts callers). + * - introspection lets the wizard introspect its own token + * - health_issue:read used by `wizard doctor` */ export const WIZARD_OAUTH_SCOPES = [ ...WIZARD_PROVISIONING_SCOPES, 'introspection', 'health_issue:read', - 'personal_api_key:write', ] as const; // ── Wizard run / variants ─────────────────────────────────────────── diff --git a/src/lib/secret-vault.ts b/src/lib/secret-vault.ts index 2359bb75..f998e6ca 100644 --- a/src/lib/secret-vault.ts +++ b/src/lib/secret-vault.ts @@ -22,7 +22,7 @@ export interface SecretMeta { ref: string; /** Human-readable label shown to the user (e.g. "Personal API key"). */ label: string; - /** Where the secret came from (e.g. "create_personal_api_key", "wizard_ask"). */ + /** Where the secret came from (e.g. "wizard_ask"). */ source: string; /** ms epoch when the secret was stored. */ createdAt: number; diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index e601a0f7..b031ad25 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -564,7 +564,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { const setEnvValues = tool( 'set_env_values', - 'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage. Each value can be either a literal string or a secret reference of the form `{ "secretRef": "secret:..." }` returned by another tool (create_personal_api_key, wizard_ask). Secret references are resolved locally — the actual value is written to the file but never returned to the agent.', + 'Create or update environment variable keys in a .env file. Creates the file if it does not exist. Ensures .gitignore coverage. Each value can be either a literal string or a secret reference of the form `{ "secretRef": "secret:..." }` returned by another tool (e.g. wizard_ask). Secret references are resolved locally — the actual value is written to the file but never returned to the agent.', { filePath: z .string() From 3e932d6e7fc5ba0d313191d1abd9bf11840be703 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 25 May 2026 18:56:02 +0200 Subject: [PATCH 04/10] feat: some progress --- .../detect.ts | 18 --- .../index.ts | 129 +++++++++++++----- 2 files changed, 92 insertions(+), 55 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/detect.ts b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts index 38e7b1a0..233df644 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/detect.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts @@ -92,24 +92,6 @@ export const SOURCE_MAPS_ABORT_CASES: AbortCase[] = [ 'your project and run this wizard again.', docsUrl: 'https://posthog.com/docs/error-tracking/upload-source-maps', }, - { - match: /^source map upload failed/i, - message: 'Source map upload failed', - body: - 'The build ran but the PostHog CLI reported an upload failure. ' + - 'Check your personal API key has the `error_tracking:write` scope ' + - 'and that POSTHOG_CLI_HOST points to the right region.', - docsUrl: 'https://posthog.com/docs/error-tracking/upload-source-maps/cli', - }, - { - match: /^verification failed/i, - message: 'Could not verify uploaded source maps', - body: - 'The build completed but the agent could not confirm a symbol set ' + - 'arrived in PostHog. Open Error Tracking → Symbol sets in the PostHog ' + - 'UI to check manually.', - docsUrl: 'https://posthog.com/docs/error-tracking/upload-source-maps', - }, ]; // ── File / dependency probes ───────────────────────────────────────── diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index e3a0b632..6086576d 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -42,8 +42,8 @@ export const errorTrackingUploadSourceMapsConfig: WorkflowConfig = { successMessage: 'Source maps wired up!', reportFile: REPORT_FILE, docsUrl: DOCS_URL, - spinnerMessage: 'Uploading source maps...', - estimatedDurationMinutes: 5, + spinnerMessage: 'Wiring up source maps...', + estimatedDurationMinutes: 3, abortCases: SOURCE_MAPS_ABORT_CASES, customPrompt: (ctx) => { @@ -125,49 +125,104 @@ STEP 4 — Seed the personal API key into the environment. You do not need to know the key value to write it — the wizard resolves the ref locally before writing the file. -STEP 5 — Identify the build command and ask for permission to run it. - Inspect the project to find the production build command. Look at: +STEP 5 — Identify the build command. + Inspect the project to find the production build command — you'll need + it for the summary and (if the user opts in) for the test affordance + step. Look at: - package.json scripts ("build", "build:prod", etc.) - gradle wrapper / xcodebuild scheme / Makefile target as appropriate - Then call wizard_ask with one question: + Do NOT run the build yourself. The wizard does not run builds — the + user runs their own build after this workflow finishes. + If you cannot identify a build command, emit + ${AgentSignals.ABORT} build command not found. + +STEP 6 — Offer an end-to-end test affordance. + Call wizard_ask: { - id: "run-build", - prompt: "Run now to upload source maps?", + id: "test-affordance", + prompt: "Want me to add a temporary 'throw test error' affordance to your app so you can verify a source-resolved stack trace lands in Error Tracking after you run your build? I'll remove it once you confirm.", kind: "single", options: [ - { label: "Yes, run it", value: "run" }, - { label: "No, I'll run it myself later", value: "skip" } + { label: "Yes, add a test affordance", value: "yes" }, + { label: "No, I'll test on my own later", value: "no" } ] } - If value === "run": execute the build via the Bash tool. Capture stdout and stderr. - Scan output for PostHog CLI markers: - - Success indicators: "Uploaded N source maps", "Symbol set", a UUID, - "posthog-cli sourcemap upload" exit 0. - - Failure indicators: "401", "403", "Invalid API key", "no source - maps found". - - If failure indicators appear, emit ${ - AgentSignals.ABORT - } source map upload failed: . - If value === "skip", do not run the build. Include manual instructions - in the summary at STEP 7. - -STEP 6 — Verify the upload via the PostHog MCP. - Only run this step if STEP 5 ran the build and reported success markers. - Query the posthog-wizard MCP server for symbol sets on project ${ - ctx.projectId - }. - If at least one symbol set with a recent created_at exists, the upload - worked. - If zero symbol sets exist for this project, emit ${ - AgentSignals.ABORT - } verification failed: no symbol sets found. - -STEP 7 — Summarise. - Tell the user what changed: which files you edited, which env vars you - set (key names only, never values), whether the build ran, and either - the verified symbol set or the manual command for them to run. + If "no", skip to STEP 7. + + If "yes": + + a) Pick a platform-appropriate affordance for the detected variant + (${displayName ?? variant}). Use distinctive, easy-to-find copy and + a distinctive error message (e.g. include an ISO timestamp) so the + user can find it in the Error Tracking UI: + - Browser / SPA / SSR (web, nextjs, react, nuxt, angular, vite, + webpack, rollup): add a clearly-labeled button on the most + obvious entry page (home / index / root layout) — e.g. "Test + PostHog Error Tracking" — whose onClick throws a synchronous + Error. posthog-js's global error handler will capture it. + - Node.js: add a temporary HTTP route (e.g. + \`GET /__posthog-test-error\`) on the project's existing server + that throws. If there is no HTTP layer, fall back to a one-off + script the user can \`node\` to trigger the throw. Tell the user + the exact command / URL to hit. + - React Native: add a visible Button on the main screen whose + onPress throws. + - Android: add a Button on the launcher Activity whose onClick + throws. + - iOS: add a UIButton on the root view controller whose action + throws (or calls a fatalError equivalent that the SDK captures). + - Flutter: add an ElevatedButton on the home widget whose + onPressed throws. + + b) Before editing any file, READ it and note the exact contents you + will restore in step (d). Keep the change minimal — one button / one + route, no extra helpers, no new imports beyond the strict minimum. + + c) Pause for the user to test. Call wizard_ask with the build command + you identified in STEP 5 baked into the prompt — the user must run + the build themselves to upload source maps and surface the test + affordance. + + IMPORTANT: separate each numbered step with \\n\\n in the prompt + string so the TUI renders them as distinct lines instead of a wall + of text. Ink's respects \\n as a line break: + { + id: "test-done", + prompt: "1) Run \`\` to upload source maps and build the app with the test affordance.\\n\\n2) Start / open the app, click the test button (or hit the test route).\\n\\n3) Open Error Tracking in PostHog (${ctx.host.replace( + /\/$/, + '', + )}/project/${ + ctx.projectId + }/error_tracking) and confirm the test error appears with a source-resolved stack trace pointing at real source files (not minified bundle paths).\\n\\nWhen you're done, select Continue and I'll revert the test code.", + kind: "single", + options: [{ label: "Continue (revert test code)", value: "continue" }] + } + + d) Revert. Restore every file you touched in step (a) to the exact + contents you captured in step (b). Re-read each file afterwards to + confirm no remnants are left (stray imports, leftover handlers, + orphan comments). Do not leave the test affordance in place under + any circumstances — even if the user reports the test "didn't work", + revert first and surface the failure in STEP 7 instead. + +STEP 7 — Summarise and hand off. + Tell the user clearly: + - Which files you edited (paths only). + - Which env-var KEY names you set in .env (never values). + - Whether a test affordance was added and reverted (if STEP 6 ran). + - The exact build command they need to run themselves to upload source + maps — the wizard does not run builds. Examples by platform: + npm run build / pnpm build / yarn build (JS) + ./gradlew assembleRelease (Android) + xcodebuild ... (iOS) + flutter build apk / flutter build ios (Flutter) + - Where to confirm the upload landed: + ${ctx.host.replace(/\/$/, '')}/project/${ + ctx.projectId + }/error_tracking/configuration + under "Symbol sets". A new symbol set should appear after the build + completes. Important: - Use detect_package_manager (wizard-tools MCP server) before running any From 45588f6da7e97c5534a1205ec6c361a8ec5820b3 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 25 May 2026 19:59:20 +0200 Subject: [PATCH 05/10] feat: ok ok --- .../index.ts | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index 6086576d..6d2c9abe 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -155,25 +155,46 @@ STEP 6 — Offer an end-to-end test affordance. a) Pick a platform-appropriate affordance for the detected variant (${displayName ?? variant}). Use distinctive, easy-to-find copy and a distinctive error message (e.g. include an ISO timestamp) so the - user can find it in the Error Tracking UI: + user can find it in the Error Tracking UI. + + The handler MUST call the PostHog SDK's exception-capture method + directly with a constructed Error / Exception object — do NOT throw. + Throwing creates a visible error overlay (in dev mode) and depends + on the SDK's global error handler being wired correctly; calling + captureException directly is deterministic and works the same across + all platforms. The Error object still has a stack trace, so source + map resolution works exactly the same. Check the loaded skill's + reference for the exact method name on the detected SDK; common + shapes: + - posthog-js / posthog-node / posthog-react-native: + posthog.captureException(new Error("PostHog test - ")) + - posthog-android (Kotlin): + PostHog.capture("$exception", ...) with the Throwable attached + per the skill's reference + - posthog-ios (Swift): posthog.capture(...) with the NSError + - posthog-flutter: Posthog().captureException(...) + + Affordance placement by variant: - Browser / SPA / SSR (web, nextjs, react, nuxt, angular, vite, webpack, rollup): add a clearly-labeled button on the most obvious entry page (home / index / root layout) — e.g. "Test - PostHog Error Tracking" — whose onClick throws a synchronous - Error. posthog-js's global error handler will capture it. + PostHog Error Tracking" — whose onClick calls + posthog.captureException(new Error(...)). - Node.js: add a temporary HTTP route (e.g. \`GET /__posthog-test-error\`) on the project's existing server - that throws. If there is no HTTP layer, fall back to a one-off - script the user can \`node\` to trigger the throw. Tell the user - the exact command / URL to hit. + whose handler calls posthog.captureException(new Error(...)) + and returns 200. If there is no HTTP layer, fall back to a + one-off script the user can \`node\` to trigger the capture. + Tell the user the exact command / URL to hit. - React Native: add a visible Button on the main screen whose - onPress throws. + onPress calls posthog.captureException(...). - Android: add a Button on the launcher Activity whose onClick - throws. + calls the PostHog SDK's exception capture method with a + \`Throwable\`. - iOS: add a UIButton on the root view controller whose action - throws (or calls a fatalError equivalent that the SDK captures). + calls the SDK's exception capture method with an \`NSError\`. - Flutter: add an ElevatedButton on the home widget whose - onPressed throws. + onPressed calls Posthog().captureException(...). b) Before editing any file, READ it and note the exact contents you will restore in step (d). Keep the change minimal — one button / one From 09650be13d1c8650fb522431fb470012d5d4f139 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Tue, 26 May 2026 15:59:01 +0200 Subject: [PATCH 06/10] feat: change detection a bit --- .../error-tracking-upload-source-maps/detect.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/detect.ts b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts index 233df644..349aa6b4 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/detect.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts @@ -183,18 +183,23 @@ function collectSignals(installDir: string, maxDepth = 3): ProjectSignals { // ── Skill selection ────────────────────────────────────────────────── function pickJsVariant(deps: Set): SkillVariant { - // Frameworks first (most specific). Order matters. + // Opinionated full-stack frameworks first — they own their build pipeline + // and have dedicated skill variants, so bundler detection underneath + // them is irrelevant. if (deps.has('react-native')) return 'react-native'; if (deps.has('nuxt')) return 'nuxt'; if (deps.has('next')) return 'nextjs'; if (deps.has('@angular/core')) return 'angular'; - if (deps.has('react')) return 'react'; - // Then bundlers + // Bundlers next — prefer these over the bare `react` variant because + // their skills are simpler (one bundler-plugin config) than wiring + // posthog-cli into an arbitrary React setup. if (deps.has('vite')) return 'vite'; if (deps.has('webpack')) return 'webpack'; if (deps.has('rollup')) return 'rollup'; + // Plain React with no recognised bundler. + if (deps.has('react')) return 'react'; // Server-only Node project - if (deps.has('posthog-node') && !deps.has('react')) return 'node'; + if (deps.has('posthog-node')) return 'node'; // Fallback: generic web return 'web'; } From 4e12affa68e858f76873fb93697a139903b39228 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Wed, 27 May 2026 11:24:17 +0200 Subject: [PATCH 07/10] feat: change test throw button instructions --- .../index.ts | 105 +++++++++++++----- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index 6d2c9abe..cb4c1742 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -89,7 +89,7 @@ STEP 1 — Obtain a personal API key from the user. sensitive: true } The answer comes back as { secretRef: "secret:..." } — never the raw - string. Remember this secretRef for STEP 4. + string. Remember this secretRef for STEP 5. If wizard_ask is unavailable (CI / non-interactive), emit ${AgentSignals.ABORT} requires-interactive-mode and halt. @@ -107,7 +107,52 @@ STEP 3 — Load and follow the skill. that are not in the skill — the skill is the source of truth for this platform. -STEP 4 — Seed the personal API key into the environment. +STEP 4 — Ensure the build tooling can load the .env file. + The credentials you'll write in STEP 5 must be readable by the build + pipeline at build time. Some bundlers/runtimes load .env automatically + (vite, next, nuxt); others don't (rollup, plain webpack, plain node + scripts). + + If the detected platform doesn't load .env on its own, first check + whether the project already has a mechanism for loading secrets — a + pre-installed loader (\`dotenv\`, \`dotenv-flow\`, \`@dotenvx/dotenvx\`, + \`env-cmd\`), a secrets manager wired into the build (Doppler, Infisical, + 1Password, AWS Secrets Manager, etc.), or a custom config loader. Look + at package.json dependencies, the bundler config, any \`scripts\` + prefixes, and config files at the repo root. If one exists, USE it — + add the new PostHog vars through the project's existing pattern + instead of introducing a second loader. + + Only if no loader is present, install \`dotenv\` SILENTLY — do NOT ask + the user, do NOT call wizard_ask. Typical fix for Node/JS projects: + - Run detect_package_manager first. + - Add \`dotenv\` as a devDependency, in the background. + - Require it at the top of the relevant config file, e.g. + \`require('dotenv').config()\` at the top of rollup.config.js, or + \`import 'dotenv/config'\` for ESM configs. + If source map upload runs as a separate CLI step in package.json + scripts (e.g. \`posthog-cli sourcemap upload\` after the bundler), the + CLI is a separate child process and won't see env vars loaded by + \`dotenv\` inside the bundler config. Rather than wiring up a wrapper, + have the user authenticate posthog-cli directly. Call wizard_ask: + { + id: "posthog-cli-login", + prompt: "Source map upload runs as a separate \`posthog-cli\` step in your build. Please:\\n\\n1) Install the CLI: \`npm install -g @posthog/cli\` (or your preferred package manager).\\n\\n2) Run \`posthog-cli login\` and follow the prompts to authenticate.\\n\\n3) Select Continue when you're done.", + kind: "single", + options: [{ label: "Continue", value: "continue" }] + } + Once the user continues, the CLI will use its own stored credentials — + you can skip writing \`POSTHOG_CLI_API_KEY\` to .env in STEP 5 for this + variant (still write \`POSTHOG_CLI_PROJECT_ID\` / \`POSTHOG_CLI_HOST\` + if the skill expects them). + For variants with their own conventions (react-native, android, ios, + flutter), follow the loaded skill — the skill is the source of truth + for that platform. + + Keep the change minimal: one require/import line and the dependency. + If the detected platform already loads .env, skip this step entirely. + +STEP 5 — Seed the personal API key into the environment. Use the wizard-tools MCP server. - First call check_env_keys to see which keys exist in the project's .env file (.env, .env.local, etc.). @@ -125,7 +170,7 @@ STEP 4 — Seed the personal API key into the environment. You do not need to know the key value to write it — the wizard resolves the ref locally before writing the file. -STEP 5 — Identify the build command. +STEP 6 — Identify the build command. Inspect the project to find the production build command — you'll need it for the summary and (if the user opts in) for the test affordance step. Look at: @@ -136,7 +181,7 @@ STEP 5 — Identify the build command. If you cannot identify a build command, emit ${AgentSignals.ABORT} build command not found. -STEP 6 — Offer an end-to-end test affordance. +STEP 7 — Offer an end-to-end test affordance. Call wizard_ask: { id: "test-affordance", @@ -148,46 +193,50 @@ STEP 6 — Offer an end-to-end test affordance. ] } - If "no", skip to STEP 7. + If "no", skip to STEP 8. If "yes": a) Pick a platform-appropriate affordance for the detected variant - (${displayName ?? variant}). Use distinctive, easy-to-find copy and - a distinctive error message (e.g. include an ISO timestamp) so the - user can find it in the Error Tracking UI. + (${displayName ?? variant}). Use distinctive, easy-to-find copy on + the trigger (button label, route path) so the user can find the + resulting event in the Error Tracking UI. The handler MUST call the PostHog SDK's exception-capture method - directly with a constructed Error / Exception object — do NOT throw. - Throwing creates a visible error overlay (in dev mode) and depends - on the SDK's global error handler being wired correctly; calling - captureException directly is deterministic and works the same across - all platforms. The Error object still has a stack trace, so source - map resolution works exactly the same. Check the loaded skill's - reference for the exact method name on the detected SDK; common - shapes: + directly — do NOT throw. Throwing creates a visible error overlay + (in dev mode) and depends on the SDK's global error handler being + wired correctly; calling captureException directly is deterministic + and works the same across all platforms. + + Pass an Error (or platform-equivalent throwable) as the single + argument. Do NOT pass a custom message string, do NOT pass extra + properties, options, or a second argument. The stack trace on the + Error is what gets source-map resolved. Common shapes: - posthog-js / posthog-node / posthog-react-native: - posthog.captureException(new Error("PostHog test - ")) + posthog.captureException(new Error("PostHog source maps test")) - posthog-android (Kotlin): - PostHog.capture("$exception", ...) with the Throwable attached - per the skill's reference - - posthog-ios (Swift): posthog.capture(...) with the NSError - - posthog-flutter: Posthog().captureException(...) + PostHog.capture("$exception") per the skill's reference + - posthog-ios (Swift): posthog.capture(...) per the skill's + reference + - posthog-flutter: + Posthog().captureException(Exception("PostHog source maps test")) Affordance placement by variant: - Browser / SPA / SSR (web, nextjs, react, nuxt, angular, vite, webpack, rollup): add a clearly-labeled button on the most obvious entry page (home / index / root layout) — e.g. "Test PostHog Error Tracking" — whose onClick calls - posthog.captureException(new Error(...)). + posthog.captureException(new Error("PostHog source maps test")). - Node.js: add a temporary HTTP route (e.g. \`GET /__posthog-test-error\`) on the project's existing server - whose handler calls posthog.captureException(new Error(...)) + whose handler calls + posthog.captureException(new Error("PostHog source maps test")) and returns 200. If there is no HTTP layer, fall back to a one-off script the user can \`node\` to trigger the capture. Tell the user the exact command / URL to hit. - React Native: add a visible Button on the main screen whose - onPress calls posthog.captureException(...). + onPress calls + posthog.captureException(new Error("PostHog source maps test")). - Android: add a Button on the launcher Activity whose onClick calls the PostHog SDK's exception capture method with a \`Throwable\`. @@ -201,7 +250,7 @@ STEP 6 — Offer an end-to-end test affordance. route, no extra helpers, no new imports beyond the strict minimum. c) Pause for the user to test. Call wizard_ask with the build command - you identified in STEP 5 baked into the prompt — the user must run + you identified in STEP 6 baked into the prompt — the user must run the build themselves to upload source maps and surface the test affordance. @@ -225,13 +274,13 @@ STEP 6 — Offer an end-to-end test affordance. confirm no remnants are left (stray imports, leftover handlers, orphan comments). Do not leave the test affordance in place under any circumstances — even if the user reports the test "didn't work", - revert first and surface the failure in STEP 7 instead. + revert first and surface the failure in STEP 8 instead. -STEP 7 — Summarise and hand off. +STEP 8 — Summarise and hand off. Tell the user clearly: - Which files you edited (paths only). - Which env-var KEY names you set in .env (never values). - - Whether a test affordance was added and reverted (if STEP 6 ran). + - Whether a test affordance was added and reverted (if STEP 7 ran). - The exact build command they need to run themselves to upload source maps — the wizard does not run builds. Examples by platform: npm run build / pnpm build / yarn build (JS) From 5a30179468b6b3d738749dd06ec1905643689981 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Fri, 29 May 2026 13:57:04 +0200 Subject: [PATCH 08/10] feat: simplify prompt - risky --- .../index.ts | 63 ++++++------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index cb4c1742..5d51dafb 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -107,50 +107,25 @@ STEP 3 — Load and follow the skill. that are not in the skill — the skill is the source of truth for this platform. -STEP 4 — Ensure the build tooling can load the .env file. - The credentials you'll write in STEP 5 must be readable by the build - pipeline at build time. Some bundlers/runtimes load .env automatically - (vite, next, nuxt); others don't (rollup, plain webpack, plain node - scripts). - - If the detected platform doesn't load .env on its own, first check - whether the project already has a mechanism for loading secrets — a - pre-installed loader (\`dotenv\`, \`dotenv-flow\`, \`@dotenvx/dotenvx\`, - \`env-cmd\`), a secrets manager wired into the build (Doppler, Infisical, - 1Password, AWS Secrets Manager, etc.), or a custom config loader. Look - at package.json dependencies, the bundler config, any \`scripts\` - prefixes, and config files at the repo root. If one exists, USE it — - add the new PostHog vars through the project's existing pattern - instead of introducing a second loader. - - Only if no loader is present, install \`dotenv\` SILENTLY — do NOT ask - the user, do NOT call wizard_ask. Typical fix for Node/JS projects: - - Run detect_package_manager first. - - Add \`dotenv\` as a devDependency, in the background. - - Require it at the top of the relevant config file, e.g. - \`require('dotenv').config()\` at the top of rollup.config.js, or - \`import 'dotenv/config'\` for ESM configs. - If source map upload runs as a separate CLI step in package.json - scripts (e.g. \`posthog-cli sourcemap upload\` after the bundler), the - CLI is a separate child process and won't see env vars loaded by - \`dotenv\` inside the bundler config. Rather than wiring up a wrapper, - have the user authenticate posthog-cli directly. Call wizard_ask: - { - id: "posthog-cli-login", - prompt: "Source map upload runs as a separate \`posthog-cli\` step in your build. Please:\\n\\n1) Install the CLI: \`npm install -g @posthog/cli\` (or your preferred package manager).\\n\\n2) Run \`posthog-cli login\` and follow the prompts to authenticate.\\n\\n3) Select Continue when you're done.", - kind: "single", - options: [{ label: "Continue", value: "continue" }] - } - Once the user continues, the CLI will use its own stored credentials — - you can skip writing \`POSTHOG_CLI_API_KEY\` to .env in STEP 5 for this - variant (still write \`POSTHOG_CLI_PROJECT_ID\` / \`POSTHOG_CLI_HOST\` - if the skill expects them). - For variants with their own conventions (react-native, android, ios, - flutter), follow the loaded skill — the skill is the source of truth - for that platform. - - Keep the change minimal: one require/import line and the dependency. - If the detected platform already loads .env, skip this step entirely. +STEP 4 — Make the credentials readable at build time. + The skill's "Making credentials available at build time" section is the + source of truth for HOW .env is loaded for the detected platform — read + it and follow it. The wizard-specific rules layered on top: + + - If a loader is needed and the project doesn't already load .env, + install \`dotenv\` SILENTLY — do NOT ask the user, do NOT call + wizard_ask. Run detect_package_manager first, add \`dotenv\` as a + devDependency in the background, and require/import it at the top of + the relevant config file (\`require('dotenv').config()\`, or + \`import 'dotenv/config'\` for ESM). Keep it to one line plus the + dependency. + - If source map upload runs as a separate \`posthog-cli\` step (a + separate child process — see the skill), pass \`--env-file \` to + that command so the CLI reads the .env file directly (e.g. + \`posthog-cli --env-file .env sourcemap upload ...\`). Do NOT ask the + user to log in. Write all the POSTHOG_CLI_* vars to .env in STEP 5 as + normal. + - If the detected platform already loads .env, skip this step entirely. STEP 5 — Seed the personal API key into the environment. Use the wizard-tools MCP server. From e3b8014859ef10067405d6119f2468f827904be0 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Fri, 29 May 2026 14:36:29 +0200 Subject: [PATCH 09/10] fix: node strange file edge case --- .../error-tracking-upload-source-maps/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index 5d51dafb..7cdf9a7e 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -206,9 +206,14 @@ STEP 7 — Offer an end-to-end test affordance. \`GET /__posthog-test-error\`) on the project's existing server whose handler calls posthog.captureException(new Error("PostHog source maps test")) - and returns 200. If there is no HTTP layer, fall back to a - one-off script the user can \`node\` to trigger the capture. - Tell the user the exact command / URL to hit. + and returns 200. If there is no HTTP layer, add the capture call + to the project's existing entry/main script (the file behind + package.json "main"/"bin" or the "start"/"dev" script, e.g. + src/index.ts) rather than creating a new file — read it, add the + single captureException call where it already initialises the + PostHog client, and revert it in step (d). Only create a new + throwaway script if the project has no suitable existing entry + file. Tell the user the exact command / URL to hit. - React Native: add a visible Button on the main screen whose onPress calls posthog.captureException(new Error("PostHog source maps test")). From 2e9ccb433e75e5426284cea1e683d2e264ccbaaf Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Fri, 29 May 2026 16:02:58 +0200 Subject: [PATCH 10/10] fix: some improvements --- .../error-tracking-upload-source-maps/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lib/workflows/error-tracking-upload-source-maps/index.ts b/src/lib/workflows/error-tracking-upload-source-maps/index.ts index 7cdf9a7e..0cb0750e 100644 --- a/src/lib/workflows/error-tracking-upload-source-maps/index.ts +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -129,8 +129,15 @@ STEP 4 — Make the credentials readable at build time. STEP 5 — Seed the personal API key into the environment. Use the wizard-tools MCP server. - - First call check_env_keys to see which keys exist in the project's - .env file (.env, .env.local, etc.). + - Reuse the env file the project already uses — do NOT create a new one. + If the project already has an env file (the skill's "Picking the + correct env file" section is the source of truth for which), write to + that same file. The prerequisite PostHog integration step has usually + already written POSTHOG_* vars to one — seed your keys alongside them + rather than introducing a second file (e.g. don't add .env.local when + a .env already exists). + - First call check_env_keys on that file to see which keys exist (it + returns present/absent, never values — don't read the file directly). - Then call set_env_values to write the key. Pass the secretRef from STEP 1 as the value object, not a literal string: values: {