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/secret-vault.ts b/src/lib/secret-vault.ts new file mode 100644 index 00000000..f998e6ca --- /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. "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 7c69abd3..b031ad25 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) @@ -192,6 +193,15 @@ export interface WizardToolsOptions { * of this cap — see {@link ASK_BATCH_THRESHOLD}. */ askMaxQuestions?: number; + + /** + * 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. + */ + secretVault?: SecretVault; } /** Default per-run cap on wizard_ask calls when no override is provided. */ @@ -494,6 +504,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { skillsBaseUrl, askBridge, askMaxQuestions = DEFAULT_ASK_MAX_QUESTIONS, + secretVault = createSecretVault(), } = options; const sdk = await getSDKModule(); const { tool, createSdkMcpServer } = sdk; @@ -553,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 (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() .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', @@ -579,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); @@ -895,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( @@ -912,6 +965,7 @@ export async function createWizardToolsServer(options: WizardToolsOptions) { kind: 'single' | 'multi' | 'text'; options?: { label: string; value: string }[]; required?: boolean; + sensitive?: boolean; }>; }) => { if (!askBridge) { @@ -956,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(); @@ -978,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 @@ -987,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), }, ], }; 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..349aa6b4 --- /dev/null +++ b/src/lib/workflows/error-tracking-upload-source-maps/detect.ts @@ -0,0 +1,312 @@ +/** + * 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', + }, +]; + +// ── 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 { + // 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'; + // 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')) 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..0cb0750e --- /dev/null +++ b/src/lib/workflows/error-tracking-upload-source-maps/index.ts @@ -0,0 +1,335 @@ +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'; + +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: 'Wiring up source maps...', + estimatedDurationMinutes: 3, + 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 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 + } project. + +Project context: +- PostHog Project ID: ${ctx.projectId} +- 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 — 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: "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 + } + The answer comes back as { secretRef: "secret:..." } — never the raw + string. Remember this secretRef for STEP 5. + + 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 — 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. + - 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: { + "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 + You do not need to know the key value to write it — the wizard resolves + the ref locally before writing the file. + +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: + - package.json scripts ("build", "build:prod", etc.) + - gradle wrapper / xcodebuild scheme / Makefile target as appropriate + 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 7 — Offer an end-to-end test affordance. + Call wizard_ask: + { + 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, add a test affordance", value: "yes" }, + { label: "No, I'll test on my own later", value: "no" } + ] + } + + 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 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 — 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 source maps test")) + - posthog-android (Kotlin): + 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 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("PostHog source maps test")) + 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")). + - Android: add a Button on the launcher Activity whose onClick + calls the PostHog SDK's exception capture method with a + \`Throwable\`. + - iOS: add a UIButton on the root view controller whose action + calls the SDK's exception capture method with an \`NSError\`. + - Flutter: add an ElevatedButton on the home widget whose + 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 + 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 6 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 8 instead. + +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 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) + ./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 + 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. +- 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. +`; + }, + + 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. + + + + ); + } + } +}; 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.