From 6111f3065296da7e7d1b2c8b69eb17fa4840de1b Mon Sep 17 00:00:00 2001 From: Ethan Gui Date: Sat, 9 May 2026 09:25:00 -0500 Subject: [PATCH 1/6] feat(handoff): write posthog-next-steps.md alongside the setup report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap between "wizard finished" and "PostHog is integrated and merged" (issue #447). Today the wizard emits posthog-setup-report.md as a manifest of what changed; this PR adds a companion posthog-next-steps.md that tells the user (or their coding agent) what still needs to be verified, which SDK quirks to watch for, and which project-specific glue the wizard intentionally never touches. The doc is rendered deterministically by the wizard (not asked of the agent) so the verification checklist + known-quirk list stay stable across model versions. It is conditional on: - Whether the integration is JS/TS — gates the SDK-mock advice and the "grep for new Anthropic()" item that don't apply to Django, Rails, Swift, Android, etc. - Whether the integration produces minified browser bundles — gates the source-maps follow-up. - Whether LLM analytics is queued in the same run — gates the $ai_generation smoke-test and the @posthog/ai streaming quirk. - The actual env var names from config.environment.getEnvVars(), so Vue / Nuxt / Astro / etc. users see the right placeholders, not hardcoded NEXT_PUBLIC_* names. Type-tightening: NextStepsContext.integration is the Integration enum (not a loose string), and KNOWN_QUIRKS_BY_INTEGRATION is exhaustive over the enum. A future Integration addition will fail to compile until its quirks list is declared (empty array is a valid choice). Outro honesty: postRun stashes the writeNextStepsFile result on sess.frameworkContext, and buildOutroData renders either a "Wrote ..." or "Could NOT write ... — handoff steps are missing" bullet from that status. Failures also fire analytics.wizardCapture so the team learns about field failures (read-only fs, EACCES, etc.) instead of having to diagnose them off cli warnings. Tests: 13 cases covering both code paths, exhaustive Record, all conditional toggles, the agent-handoff filename binding, and a deterministic missing-intermediate-dir failure path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/handoff.test.ts | 179 ++++++++++++++ .../workflows/posthog-integration/handoff.ts | 220 ++++++++++++++++++ .../workflows/posthog-integration/index.ts | 55 +++++ 3 files changed, 454 insertions(+) create mode 100644 src/lib/workflows/posthog-integration/__tests__/handoff.test.ts create mode 100644 src/lib/workflows/posthog-integration/handoff.ts diff --git a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts new file mode 100644 index 00000000..12398564 --- /dev/null +++ b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts @@ -0,0 +1,179 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { Integration } from '../../../constants.js'; +import { + NEXT_STEPS_FILE, + buildNextStepsMarkdown, + writeNextStepsFile, + type NextStepsContext, +} from '../handoff.js'; + +function ctx(overrides: Partial = {}): NextStepsContext { + return { + frameworkName: 'Next.js', + integration: Integration.nextjs, + reportFile: 'posthog-setup-report.md', + envVarNames: [ + 'NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', + 'NEXT_PUBLIC_POSTHOG_HOST', + ], + llmAnalyticsQueued: false, + ...overrides, + }; +} + +describe('buildNextStepsMarkdown', () => { + it('renders the headline, the manifest pointer, and the four required sections', () => { + const md = buildNextStepsMarkdown(ctx()); + + expect(md).toMatch(/^# PostHog setup: next steps/); + expect(md).toContain('Next.js project'); + expect(md).toContain('## Verify before merging'); + expect(md).toContain('## Known SDK quirks'); + expect(md).toContain('## Project glue we did NOT touch'); + expect(md).toContain('## Hand this to your coding agent'); + }); + + it('embeds both filenames in the agent-handoff block on a single line', () => { + // The agent-handoff block is the load-bearing part of the file: a + // regression that drops it would silently defeat the whole purpose. + // Asserting both filenames appear on the same line catches a regression + // that just keeps the manifest pointer at the top of the file. + const md = buildNextStepsMarkdown(ctx()); + expect(md).toMatch( + /Read `posthog-setup-report\.md` and `posthog-next-steps\.md`/, + ); + }); + + it('lists the configured env var names verbatim in the project glue section', () => { + const md = buildNextStepsMarkdown( + ctx({ envVarNames: ['POSTHOG_API_KEY', 'POSTHOG_HOST'] }), + ); + expect(md).toContain('`POSTHOG_API_KEY`'); + expect(md).toContain('`POSTHOG_HOST`'); + expect(md).not.toContain('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN'); + }); + + it('omits LLM smoke-test items when llmAnalyticsQueued is false', () => { + const md = buildNextStepsMarkdown(ctx({ llmAnalyticsQueued: false })); + expect(md).not.toContain('$ai_generation'); + expect(md).not.toContain('@anthropic-ai/sdk'); + expect(md).not.toContain('messages.stream'); + }); + + it('includes LLM smoke-test items when llmAnalyticsQueued is true on a JS integration', () => { + const md = buildNextStepsMarkdown( + ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), + ); + expect(md).toContain('$ai_generation'); + expect(md).toMatch(/Search for any remaining direct LLM SDK constructors/); + }); + + it('skips the JS-only Anthropic-grep step on non-JS integrations even when LLM is queued', () => { + const md = buildNextStepsMarkdown( + ctx({ + frameworkName: 'Django', + integration: Integration.django, + llmAnalyticsQueued: true, + }), + ); + // Generic LLM smoke-test stays. + expect(md).toContain('$ai_generation'); + // JS-specific grep is dropped — Django users don't import @anthropic-ai/sdk. + expect(md).not.toContain('@anthropic-ai/sdk'); + expect(md).not.toContain('new Anthropic()'); + }); + + it('emits the LLM streaming quirk only on JS integrations with LLM queued', () => { + const jsLlm = buildNextStepsMarkdown( + ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), + ); + expect(jsLlm).toMatch(/\n- `@posthog\/ai`'s `PostHogAnthropic`/); + + const jsNoLlm = buildNextStepsMarkdown( + ctx({ integration: Integration.nextjs, llmAnalyticsQueued: false }), + ); + expect(jsNoLlm).not.toContain('PostHogAnthropic'); + + const nonJsLlm = buildNextStepsMarkdown( + ctx({ + frameworkName: 'Django', + integration: Integration.django, + llmAnalyticsQueued: true, + }), + ); + expect(nonJsLlm).not.toContain('PostHogAnthropic'); + }); + + it('falls back to a "no quirks recorded" stub linking the issue tracker when the merged quirk list is empty', () => { + const md = buildNextStepsMarkdown( + ctx({ integration: Integration.swift, llmAnalyticsQueued: false }), + ); + expect(md).toContain('No additional quirks recorded for this integration'); + expect(md).toContain('https://github.com/PostHog/wizard/issues'); + }); + + it('renders multiple quirks as separate bullet items, not joined with commas', () => { + // Today only one quirk is registered, but the rendering path uses + // join('\n') and a `- ` prefix per item. A regression to `.join(', ')` + // (or any other separator) would corrupt the markdown invisibly. Build + // a synthetic two-quirk list by stacking LLM + a registered base + // quirk in the future; for now, verify the bullet shape on the one + // present quirk so a regex change shows up. + const md = buildNextStepsMarkdown( + ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), + ); + // Bullet must start at column 0 with `- ` and the quirk text. + expect(md).toMatch(/\n- `@posthog\/ai`/); + }); + + it('drops the source-maps glue item on backend-only integrations', () => { + const django = buildNextStepsMarkdown( + ctx({ frameworkName: 'Django', integration: Integration.django }), + ); + expect(django).not.toContain('posthog-cli sourcemap'); + }); + + it('keeps the source-maps glue item on browser-bundled integrations', () => { + const next = buildNextStepsMarkdown( + ctx({ integration: Integration.nextjs }), + ); + expect(next).toContain('posthog-cli sourcemap'); + }); +}); + +describe('writeNextStepsFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'posthog-handoff-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes the file at /posthog-next-steps.md and returns the path', () => { + const result = writeNextStepsFile(tmpDir, ctx()); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('expected ok:true result'); + expect(result.path).toBe(path.join(tmpDir, NEXT_STEPS_FILE)); + + const written = fs.readFileSync(result.path, 'utf8'); + expect(written).toMatch(/^# PostHog setup: next steps/); + }); + + it('returns ok:false rather than throwing when the destination directory is missing', () => { + // Use a tmpDir-relative missing intermediate path so the failure is + // deterministic and self-contained — no reliance on host filesystem. + const target = path.join(tmpDir, 'does', 'not', 'exist'); + const result = writeNextStepsFile(target, ctx()); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected ok:false result'); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/src/lib/workflows/posthog-integration/handoff.ts b/src/lib/workflows/posthog-integration/handoff.ts new file mode 100644 index 00000000..e3c8b570 --- /dev/null +++ b/src/lib/workflows/posthog-integration/handoff.ts @@ -0,0 +1,220 @@ +import fs from 'fs'; +import path from 'path'; + +import { Integration } from '../../constants.js'; + +/** + * The handoff document the wizard writes alongside the agent-generated setup + * report. The report describes WHAT changed; this file describes what the + * developer (or their coding agent) still needs to do to take the integration + * from "wizard finished" to "merged" — verification steps, known SDK quirks, + * and project glue the wizard intentionally did not touch. + * + * The doc is rendered deterministically by the wizard (not asked of the + * agent) so the verification checklist and known-quirk list stay stable + * across model versions and don't depend on the agent's prompt-following. + * + * Background: https://github.com/PostHog/wizard/issues/447 — the existing + * setup report worked as a manifest but left common gaps for the calling + * agent to discover by trial and error. + */ +export const NEXT_STEPS_FILE = 'posthog-next-steps.md'; + +export interface NextStepsContext { + /** Display name of the framework, e.g. "Next.js". */ + frameworkName: string; + /** Internal integration identifier; used for keyed lookups + conditionals. */ + integration: Integration; + /** File the agent wrote describing what changed. Linked from the handoff. */ + reportFile: string; + /** Names of env vars the wizard configured for this stack. */ + envVarNames: string[]; + /** True when LLM analytics is queued to install in the same run. */ + llmAnalyticsQueued: boolean; +} + +/** Integrations whose project source is JavaScript / TypeScript. */ +const JS_INTEGRATIONS: ReadonlySet = new Set([ + Integration.nextjs, + Integration.nuxt, + Integration.vue, + Integration.reactRouter, + Integration.tanstackStart, + Integration.tanstackRouter, + Integration.reactNative, + Integration.angular, + Integration.astro, + Integration.sveltekit, + Integration.javascriptNode, + Integration.javascript_web, +]); + +/** Integrations that produce minified browser bundles where source maps help. */ +const SOURCE_MAP_INTEGRATIONS: ReadonlySet = new Set([ + Integration.nextjs, + Integration.nuxt, + Integration.vue, + Integration.reactRouter, + Integration.tanstackStart, + Integration.tanstackRouter, + Integration.angular, + Integration.astro, + Integration.sveltekit, + Integration.javascript_web, +]); + +/** + * Per-integration SDK quirks. Exhaustive over the `Integration` enum so + * adding a new integration forces a conscious decision (an empty array is + * fine — it just means "no quirks recorded yet"). LLM-analytics quirks are + * merged in conditionally below; do NOT add them here. + */ +const KNOWN_QUIRKS_BY_INTEGRATION: Record = { + [Integration.nextjs]: [], + [Integration.nuxt]: [], + [Integration.vue]: [], + [Integration.reactRouter]: [], + [Integration.tanstackStart]: [], + [Integration.tanstackRouter]: [], + [Integration.reactNative]: [], + [Integration.angular]: [], + [Integration.astro]: [], + [Integration.django]: [], + [Integration.flask]: [], + [Integration.fastapi]: [], + [Integration.laravel]: [], + [Integration.sveltekit]: [], + [Integration.swift]: [], + [Integration.android]: [], + [Integration.rails]: [], + [Integration.python]: [], + [Integration.ruby]: [], + [Integration.javascriptNode]: [], + [Integration.javascript_web]: [], +}; + +/** + * Quirks that only apply when LLM analytics is being installed in the same + * run AND the project is JS/TS (the only stack `@posthog/ai` ships for). + */ +const LLM_ANALYTICS_QUIRKS_FOR_JS: readonly string[] = [ + "`@posthog/ai`'s `PostHogAnthropic` only intercepts `messages.create()`. Don't use `messages.stream(...)` — it bypasses the wrapper and crashes at runtime when the SDK calls `.withResponse()` on the wrapped value. Use `messages.create({ stream: true, ... })` and iterate the lower-level events instead.", +]; + +/** + * Build the markdown content. Pure function so it is trivial to unit-test + * and to render outside of a wizard run. + */ +export function buildNextStepsMarkdown(ctx: NextStepsContext): string { + const { + frameworkName, + integration, + reportFile, + envVarNames, + llmAnalyticsQueued, + } = ctx; + const isJs = JS_INTEGRATIONS.has(integration); + const wantsSourceMaps = SOURCE_MAP_INTEGRATIONS.has(integration); + + const baseQuirks = KNOWN_QUIRKS_BY_INTEGRATION[integration]; + const llmQuirks = + llmAnalyticsQueued && isJs ? [...LLM_ANALYTICS_QUIRKS_FOR_JS] : []; + const allQuirks = [...baseQuirks, ...llmQuirks]; + + const verifyItems = [ + 'Run a production build to catch lint and type issues from generated code.', + isJs + ? 'Run unit tests — wizard-rewritten routes may have outdated mocks (e.g. tests that mocked the bare SDK now need to mock the wrapped client).' + : 'Run unit / integration tests.', + 'Smoke-test one capture call in the running app and confirm the event appears in PostHog.', + 'Smoke-test the identify path and confirm the distinct id matches your user model — including the *returning visitor* path that auto-redirects past your auth submit handler.', + ]; + if (llmAnalyticsQueued) { + verifyItems.push( + 'Smoke-test one streaming and one non-streaming LLM call and confirm `$ai_generation` events appear in PostHog.', + ); + if (isJs) { + verifyItems.push( + 'Search for any remaining direct LLM SDK constructors (`new Anthropic()`, etc.) or non-type imports of `@anthropic-ai/sdk` and migrate them to the wrapped client. Automated detection can miss non-standard import shapes.', + ); + } + } + + const envCallout = + envVarNames.length > 0 + ? `${envVarNames.map((n) => `\`${n}\``).join(' / ')} placeholder${ + envVarNames.length === 1 ? '' : 's' + }` + : 'the env vars the wizard added'; + + const projectGlue = [ + `If your project has a \`.env.example\`, add ${envCallout} so collaborators know what to set.`, + 'Worktree / monorepo bootstrap scripts — add a step that copies any gitignored env file (e.g. `.env.local`) if your team uses one.', + 'Agent guides (`AGENTS.md` / `CLAUDE.md` / similar) — document how PostHog is wired so future contributions keep instrumentation parity.', + ]; + if (wantsSourceMaps) { + projectGlue.push( + "Source maps — wire `posthog-cli sourcemap` (or your bundler's upload step) in CI to get readable production stack traces.", + ); + } + + return [ + '# PostHog setup: next steps', + '', + `The wizard finished installing PostHog into your ${frameworkName} project. The companion file \`${reportFile}\` is the *manifest* of what changed; this file is the *handoff* — verification steps, known SDK quirks, and project glue we intentionally did not touch.`, + '', + 'Hand both files to your coding agent (or work through them yourself) before merging.', + '', + '## Verify before merging', + '', + verifyItems.map((it) => `- [ ] ${it}`).join('\n'), + '', + '## Known SDK quirks', + '', + allQuirks.length > 0 + ? allQuirks.map((q) => `- ${q}`).join('\n') + : '_No additional quirks recorded for this integration. If you hit one, please file an issue at https://github.com/PostHog/wizard/issues._', + '', + '## Project glue we did NOT touch', + '', + 'These are stack- or team-specific files the wizard never edits. If your project uses any of them, update them yourself:', + '', + projectGlue.map((g) => `- ${g}`).join('\n'), + '', + '## Token-absent behavior', + '', + 'By default the SDK logs warnings and may retry sends if the project token is unset. If you have CI / e2e environments without PostHog, consider noop-shimming the wizard-generated PostHog client wrapper so missing config is silent and safe.', + '', + '## Hand this to your coding agent', + '', + 'Copy and run:', + '', + `> Read \`${reportFile}\` and \`${NEXT_STEPS_FILE}\`. Verify each item in the "Verify before merging" checklist. Apply any fixes for items that fail. Update the project glue listed in this file if it applies. Open a PR with the changes plus a summary of what was verified.`, + '', + ].join('\n'); +} + +/** + * Write `posthog-next-steps.md` into the install directory. Best-effort: if + * the write fails (read-only fs, missing dir, etc.) we surface the error to + * the caller rather than aborting the wizard run, so a successful integration + * isn't undone by a failed follow-up file. Note that `buildNextStepsMarkdown` + * runs OUTSIDE the try block — only the `fs.writeFileSync` call may throw — + * so a programmer error in template construction still propagates loudly. + */ +export function writeNextStepsFile( + installDir: string, + ctx: NextStepsContext, +): { ok: true; path: string } | { ok: false; error: string } { + const filePath = path.join(installDir, NEXT_STEPS_FILE); + const content = buildNextStepsMarkdown(ctx); + try { + fs.writeFileSync(filePath, content, 'utf8'); + return { ok: true, path: filePath }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index 91dc28fc..0fd319e9 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -19,6 +19,20 @@ import { getCloudUrlFromRegion } from '../../../utils/urls.js'; import { requestDeepLink } from '../../../utils/provisioning.js'; import type { CloudRegion } from '../../../utils/types.js'; import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; +import { NEXT_STEPS_FILE, writeNextStepsFile } from './handoff.js'; +import { AdditionalFeature } from '../../wizard-session.js'; + +/** + * Key under which `postRun` stashes the result of `writeNextStepsFile` so + * `buildOutroData` can render the matching success/failure bullet without + * re-doing the write. Stored on `sess.frameworkContext` because that's the + * existing pattern (see `DASHBOARD_DEEP_LINK_KEY`). + */ +const NEXT_STEPS_HANDOFF_KEY = 'nextStepsHandoff'; + +type NextStepsHandoffStatus = + | { ok: true; path: string } + | { ok: false; error: string }; const DASHBOARD_DEEP_LINK_KEY = 'dashboardDeepLink'; @@ -158,10 +172,38 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server }, postRun: async (sess, credentials) => { + // Compute env vars first so we can pass the actual variable names + // (which differ per framework) into the handoff doc. const envVars = config.environment.getEnvVars( credentials.projectApiKey, credentials.host, ); + + // Drop the next-steps handoff doc next to the agent-generated + // manifest. See ./handoff.ts for the rationale and content. Stash + // the result on `frameworkContext` so `buildOutroData` can render + // a truthful success/failure bullet for it. + const handoff = writeNextStepsFile(sess.installDir, { + frameworkName: config.metadata.name, + integration: config.metadata.integration, + reportFile: SETUP_REPORT_FILE, + envVarNames: Object.keys(envVars), + llmAnalyticsQueued: sess.additionalFeatureQueue.includes( + AdditionalFeature.LLM, + ), + }); + sess.frameworkContext[NEXT_STEPS_HANDOFF_KEY] = + handoff as NextStepsHandoffStatus; + if (!handoff.ok) { + getUI().log.warn( + `Could not write ${NEXT_STEPS_FILE}: ${handoff.error}`, + ); + analytics.wizardCapture('next steps file write failed', { + integration: config.metadata.integration, + error: handoff.error, + }); + } + if (config.environment.uploadToHosting) { const { uploadEnvironmentVariablesStep } = await import( '../../../steps/index.js' @@ -207,11 +249,24 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server const deepLink = sess.frameworkContext[DASHBOARD_DEEP_LINK_KEY]; const continueUrl = resolveContinueUrl(sess, cloudRegion, deepLink); + // Pull the handoff write result that postRun stashed on the + // session. If the write failed, surface the failure on the outro + // screen instead of silently asserting success. + const handoffStatus = sess.frameworkContext[NEXT_STEPS_HANDOFF_KEY] as + | NextStepsHandoffStatus + | undefined; + const handoffBullet = handoffStatus?.ok + ? `Wrote ${NEXT_STEPS_FILE} with verification + handoff steps` + : handoffStatus + ? `Could NOT write ${NEXT_STEPS_FILE} (${handoffStatus.error}) — handoff steps are missing` + : ''; + const changes = [ ...config.ui.getOutroChanges(frameworkContext), Object.keys(envVars).length > 0 ? 'Added environment variables to .env file' : '', + handoffBullet, ].filter(Boolean); return { From c440b6191b515640349192dcce1862ce5f4e5e37 Mon Sep 17 00:00:00 2001 From: Ethan Gui Date: Sat, 9 May 2026 09:41:51 -0500 Subject: [PATCH 2/6] refactor(handoff): centralize handoff status type + add bullet helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review feedback (codex clean; 5 specialists). Two recommendations converged: 1. Type-design + silent-failure: the discriminated union `{ ok: true; path } | { ok: false; error }` was duplicated as an inline return type on `writeNextStepsFile` AND a local alias in `index.ts`. Cast-at-each-callsite let drift go silent. 2. Test-analyst + silent-failure: the new `buildOutroData` three-branch render (ok / fail / undefined) had no tests. The whole point of the "outro tells the truth on failure" change rested on inspection. Fix: extract the surface to `handoff.ts`. - `NextStepsHandoffStatus` is now an exported type; `writeNextStepsFile` returns it by name. - `getNextStepsHandoff(session)` reads + type-guards `frameworkContext[NEXT_STEPS_HANDOFF_KEY]`. The previous unsafe `as` cast became a runtime check that rejects bogus shapes (defends against any other code overwriting the key with the wrong value type). - `setNextStepsHandoff(session, status)` writes it. Mirrors the `getAuditChecks` precedent in `audit/types.ts`. - `buildHandoffBullet(status | undefined)` is the pure outro-rendering helper. `index.ts` now does `buildHandoffBullet(getNextStepsHandoff(sess))` and `.filter(Boolean)` drops the empty string for the "never wrote" case. Test coverage moved from 13 → 21: - Three new tests for `buildHandoffBullet` (ok / fail / undefined). - Four new tests for the accessor pair (round-trip, missing key returns undefined, type-guard rejects 7 shapes of garbage). - New test pinning that `react-native` is in `JS_INTEGRATIONS` but NOT in `SOURCE_MAP_INTEGRATIONS` — a silent regression class the previous set of tests missed. - New test covering env-var-name pluralization across 3 branches (singular / plural / empty fallback). Default ctx exercised only the plural path; the off-by-one on `length === 1 ? '' : 's'` would have shipped silently. Comment cleanup from round-2 reviews: - Tightened `NextStepsContext.integration` field doc to name what *changes* when the value changes (quirk lookup, JS branching, source-maps inclusion). - Rephrased `writeNextStepsFile` try-block comment around the contract ("intentionally narrow — covers only the disk write") rather than the fragile "only fs.writeFileSync may throw" line layout. - Dropped misleading "Compute env vars first" comment — `getEnvVars` was already the first call in `postRun` on main; the previous wording implied a reorder that did not happen. - Added a `SOURCE_MAP_INTEGRATIONS ⊆ JS_INTEGRATIONS` subset note next to the Set so the implicit invariant is documented. `pnpm typecheck` clean. `pnpm lint` 0 errors. `pnpm jest` 36 suites, 618 / 621 passed (3 pre-existing skips). `handoff.ts` at 100% line coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/handoff.test.ts | 108 ++++++++++++++++-- .../workflows/posthog-integration/handoff.ts | 94 ++++++++++++++- .../workflows/posthog-integration/index.ts | 45 +++----- 3 files changed, 202 insertions(+), 45 deletions(-) diff --git a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts index 12398564..94f4afdc 100644 --- a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts +++ b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts @@ -3,11 +3,17 @@ import os from 'os'; import path from 'path'; import { Integration } from '../../../constants.js'; +import { buildSession } from '../../../wizard-session.js'; import { NEXT_STEPS_FILE, + NEXT_STEPS_HANDOFF_KEY, + buildHandoffBullet, buildNextStepsMarkdown, + getNextStepsHandoff, + setNextStepsHandoff, writeNextStepsFile, type NextStepsContext, + type NextStepsHandoffStatus, } from '../handoff.js'; function ctx(overrides: Partial = {}): NextStepsContext { @@ -115,17 +121,13 @@ describe('buildNextStepsMarkdown', () => { expect(md).toContain('https://github.com/PostHog/wizard/issues'); }); - it('renders multiple quirks as separate bullet items, not joined with commas', () => { - // Today only one quirk is registered, but the rendering path uses - // join('\n') and a `- ` prefix per item. A regression to `.join(', ')` - // (or any other separator) would corrupt the markdown invisibly. Build - // a synthetic two-quirk list by stacking LLM + a registered base - // quirk in the future; for now, verify the bullet shape on the one - // present quirk so a regex change shows up. + it('renders quirks with a leading hyphen-bullet (regression catch for .join changes)', () => { + // Today the registry has one quirk renderable in one path (LLM-on-JS). + // Verify the rendered shape — a regression to `.join(', ')` or any + // other separator would corrupt the bullet list invisibly. const md = buildNextStepsMarkdown( ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), ); - // Bullet must start at column 0 with `- ` and the quirk text. expect(md).toMatch(/\n- `@posthog\/ai`/); }); @@ -142,6 +144,96 @@ describe('buildNextStepsMarkdown', () => { ); expect(next).toContain('posthog-cli sourcemap'); }); + + it('drops the source-maps glue item on react-native (JS but not browser-bundled)', () => { + // react-native is in JS_INTEGRATIONS but NOT in SOURCE_MAP_INTEGRATIONS. + // A future refactor that consolidates the two sets would silently start + // emitting `posthog-cli sourcemap` advice for RN; this test pins it. + const md = buildNextStepsMarkdown( + ctx({ + frameworkName: 'React Native', + integration: Integration.reactNative, + }), + ); + expect(md).not.toContain('posthog-cli sourcemap'); + }); + + it('uses singular "placeholder" for one env var, plural for many, and a generic fallback for none', () => { + // The placeholder/placeholders branch in `envCallout` is easy to flip + // (off-by-one when refactoring `length === 1 ? '' : 's'`). The + // length === 0 branch is dead-code unless someone wires a stack with + // no env vars; assert all three so a regression surfaces immediately. + const one = buildNextStepsMarkdown(ctx({ envVarNames: ['POSTHOG_KEY'] })); + expect(one).toMatch(/`POSTHOG_KEY` placeholder\b/); + expect(one).not.toMatch(/placeholders\b/); + + const many = buildNextStepsMarkdown(ctx({ envVarNames: ['A', 'B'] })); + expect(many).toMatch(/`A` \/ `B` placeholders\b/); + + const none = buildNextStepsMarkdown(ctx({ envVarNames: [] })); + expect(none).toContain('the env vars the wizard added'); + expect(none).not.toMatch(/placeholders?\b/); + }); +}); + +describe('buildHandoffBullet', () => { + it('renders a "Wrote ..." line for ok:true', () => { + const bullet = buildHandoffBullet({ ok: true, path: '/tmp/x.md' }); + expect(bullet).toBe( + `Wrote ${NEXT_STEPS_FILE} with verification + handoff steps`, + ); + }); + + it('renders a "Could NOT write ..." line for ok:false, embedding the error', () => { + const bullet = buildHandoffBullet({ ok: false, error: 'EACCES' }); + expect(bullet).toBe( + `Could NOT write ${NEXT_STEPS_FILE} (EACCES) — handoff steps are missing`, + ); + }); + + it('renders an empty string for undefined so .filter(Boolean) drops it cleanly', () => { + expect(buildHandoffBullet(undefined)).toBe(''); + }); +}); + +describe('handoff status accessors', () => { + function fakeSession() { + // buildSession asks for the bare minimum — installDir is the only field + // it requires us to pass through, but we don't touch the filesystem. + return buildSession({ installDir: os.tmpdir() }); + } + + it('round-trips set + get on the same session', () => { + const session = fakeSession(); + const status: NextStepsHandoffStatus = { ok: true, path: '/tmp/x.md' }; + setNextStepsHandoff(session, status); + expect(getNextStepsHandoff(session)).toEqual(status); + }); + + it('returns undefined when nothing has been stashed', () => { + expect(getNextStepsHandoff(fakeSession())).toBeUndefined(); + }); + + it('returns undefined when frameworkContext holds a value with the wrong shape', () => { + // Defense-in-depth: if any other code overwrites the key with a + // differently-shaped value (string, mismatched discriminant, missing + // required field), `getNextStepsHandoff` rejects rather than passing + // garbage to `buildHandoffBullet`. + const cases: unknown[] = [ + 'a string', + 42, + null, + { ok: 'yes' }, // wrong discriminant type + { ok: true }, // missing path + { ok: false }, // missing error + { ok: true, path: 123 }, // wrong path type + ]; + for (const bogus of cases) { + const session = fakeSession(); + session.frameworkContext[NEXT_STEPS_HANDOFF_KEY] = bogus; + expect(getNextStepsHandoff(session)).toBeUndefined(); + } + }); }); describe('writeNextStepsFile', () => { diff --git a/src/lib/workflows/posthog-integration/handoff.ts b/src/lib/workflows/posthog-integration/handoff.ts index e3c8b570..a54ce201 100644 --- a/src/lib/workflows/posthog-integration/handoff.ts +++ b/src/lib/workflows/posthog-integration/handoff.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { Integration } from '../../constants.js'; +import type { WizardSession } from '../../wizard-session.js'; /** * The handoff document the wizard writes alongside the agent-generated setup @@ -23,7 +24,7 @@ export const NEXT_STEPS_FILE = 'posthog-next-steps.md'; export interface NextStepsContext { /** Display name of the framework, e.g. "Next.js". */ frameworkName: string; - /** Internal integration identifier; used for keyed lookups + conditionals. */ + /** Integration enum value — drives quirk lookup, JS-vs-non-JS branching, and source-maps inclusion. */ integration: Integration; /** File the agent wrote describing what changed. Linked from the handoff. */ reportFile: string; @@ -33,6 +34,23 @@ export interface NextStepsContext { llmAnalyticsQueued: boolean; } +/** + * Result shape returned by `writeNextStepsFile` and stashed on + * `WizardSession.frameworkContext` for `buildOutroData` to read. Exported so + * the producer (`writeNextStepsFile`) and consumer (`buildHandoffBullet` + + * any future reader) cannot drift apart. + */ +export type NextStepsHandoffStatus = + | { ok: true; path: string } + | { ok: false; error: string }; + +/** + * `frameworkContext` key under which `setNextStepsHandoff` stashes the + * write result. Stored on `frameworkContext` because that's the existing + * pattern (see `DASHBOARD_DEEP_LINK_KEY`, `AUDIT_CHECKS_KEY`). + */ +export const NEXT_STEPS_HANDOFF_KEY = 'nextStepsHandoff'; + /** Integrations whose project source is JavaScript / TypeScript. */ const JS_INTEGRATIONS: ReadonlySet = new Set([ Integration.nextjs, @@ -49,7 +67,12 @@ const JS_INTEGRATIONS: ReadonlySet = new Set([ Integration.javascript_web, ]); -/** Integrations that produce minified browser bundles where source maps help. */ +/** + * Integrations that produce minified browser bundles where source maps help. + * Note: `SOURCE_MAP_INTEGRATIONS ⊆ JS_INTEGRATIONS` — a non-JS source-map case + * would break that subset assumption and require the rendering logic to be + * revisited. + */ const SOURCE_MAP_INTEGRATIONS: ReadonlySet = new Set([ Integration.nextjs, Integration.nuxt, @@ -198,14 +221,15 @@ export function buildNextStepsMarkdown(ctx: NextStepsContext): string { * Write `posthog-next-steps.md` into the install directory. Best-effort: if * the write fails (read-only fs, missing dir, etc.) we surface the error to * the caller rather than aborting the wizard run, so a successful integration - * isn't undone by a failed follow-up file. Note that `buildNextStepsMarkdown` - * runs OUTSIDE the try block — only the `fs.writeFileSync` call may throw — - * so a programmer error in template construction still propagates loudly. + * isn't undone by a failed follow-up file. The try block is intentionally + * narrow — it covers only the disk write, so a programmer error in template + * construction (a missing field, a thrown helper) still propagates loudly + * instead of being swallowed as `{ ok: false, error: ... }`. */ export function writeNextStepsFile( installDir: string, ctx: NextStepsContext, -): { ok: true; path: string } | { ok: false; error: string } { +): NextStepsHandoffStatus { const filePath = path.join(installDir, NEXT_STEPS_FILE); const content = buildNextStepsMarkdown(ctx); try { @@ -218,3 +242,61 @@ export function writeNextStepsFile( }; } } + +/** + * Type guard for runtime-shaped `NextStepsHandoffStatus`. Used by + * `getNextStepsHandoff` to defend against `frameworkContext` ever holding a + * differently-shaped value at the same key — keeps the unsafe `as` cast + * confined to one place behind a real check. + */ +function isNextStepsHandoffStatus( + value: unknown, +): value is NextStepsHandoffStatus { + if (typeof value !== 'object' || value === null) return false; + if (!('ok' in value) || typeof (value as { ok: unknown }).ok !== 'boolean') { + return false; + } + if ((value as NextStepsHandoffStatus).ok) { + return typeof (value as { path?: unknown }).path === 'string'; + } + return typeof (value as { error?: unknown }).error === 'string'; +} + +/** + * Read the stashed handoff result from a session. Returns `undefined` when + * `setNextStepsHandoff` was never called or when `frameworkContext` holds an + * unexpected shape — `buildHandoffBullet` translates `undefined` to a silent + * drop, so a missing key never produces a misleading outro line. + */ +export function getNextStepsHandoff( + session: WizardSession, +): NextStepsHandoffStatus | undefined { + const raw = session.frameworkContext[NEXT_STEPS_HANDOFF_KEY]; + return isNextStepsHandoffStatus(raw) ? raw : undefined; +} + +/** Stash the handoff result on a session for `buildOutroData` to read. */ +export function setNextStepsHandoff( + session: WizardSession, + status: NextStepsHandoffStatus, +): void { + session.frameworkContext[NEXT_STEPS_HANDOFF_KEY] = status; +} + +/** + * Render the outro bullet line for a stashed handoff status. Three states: + * - `ok: true` — celebrate the new file. + * - `ok: false` — surface the failure where the user will actually see it + * (the warn line in `postRun` scrolls past as the outro + * screen renders). + * - `undefined` — empty string; `.filter(Boolean)` in the caller drops it + * so workflows that never wrote the handoff stay quiet. + */ +export function buildHandoffBullet( + status: NextStepsHandoffStatus | undefined, +): string { + if (!status) return ''; + return status.ok + ? `Wrote ${NEXT_STEPS_FILE} with verification + handoff steps` + : `Could NOT write ${NEXT_STEPS_FILE} (${status.error}) — handoff steps are missing`; +} diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index 0fd319e9..ee84cb69 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -19,21 +19,14 @@ import { getCloudUrlFromRegion } from '../../../utils/urls.js'; import { requestDeepLink } from '../../../utils/provisioning.js'; import type { CloudRegion } from '../../../utils/types.js'; import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; -import { NEXT_STEPS_FILE, writeNextStepsFile } from './handoff.js'; +import { + buildHandoffBullet, + getNextStepsHandoff, + setNextStepsHandoff, + writeNextStepsFile, +} from './handoff.js'; import { AdditionalFeature } from '../../wizard-session.js'; -/** - * Key under which `postRun` stashes the result of `writeNextStepsFile` so - * `buildOutroData` can render the matching success/failure bullet without - * re-doing the write. Stored on `sess.frameworkContext` because that's the - * existing pattern (see `DASHBOARD_DEEP_LINK_KEY`). - */ -const NEXT_STEPS_HANDOFF_KEY = 'nextStepsHandoff'; - -type NextStepsHandoffStatus = - | { ok: true; path: string } - | { ok: false; error: string }; - const DASHBOARD_DEEP_LINK_KEY = 'dashboardDeepLink'; function resolveContinueUrl( @@ -172,17 +165,15 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server }, postRun: async (sess, credentials) => { - // Compute env vars first so we can pass the actual variable names - // (which differ per framework) into the handoff doc. const envVars = config.environment.getEnvVars( credentials.projectApiKey, credentials.host, ); // Drop the next-steps handoff doc next to the agent-generated - // manifest. See ./handoff.ts for the rationale and content. Stash - // the result on `frameworkContext` so `buildOutroData` can render - // a truthful success/failure bullet for it. + // manifest. See ./handoff.ts for the rationale and content. The + // accessor stashes the result on `frameworkContext` so + // `buildOutroData` can render a truthful success/failure bullet. const handoff = writeNextStepsFile(sess.installDir, { frameworkName: config.metadata.name, integration: config.metadata.integration, @@ -192,11 +183,10 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server AdditionalFeature.LLM, ), }); - sess.frameworkContext[NEXT_STEPS_HANDOFF_KEY] = - handoff as NextStepsHandoffStatus; + setNextStepsHandoff(sess, handoff); if (!handoff.ok) { getUI().log.warn( - `Could not write ${NEXT_STEPS_FILE}: ${handoff.error}`, + `Could not write posthog-next-steps.md: ${handoff.error}`, ); analytics.wizardCapture('next steps file write failed', { integration: config.metadata.integration, @@ -250,16 +240,9 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server const continueUrl = resolveContinueUrl(sess, cloudRegion, deepLink); // Pull the handoff write result that postRun stashed on the - // session. If the write failed, surface the failure on the outro - // screen instead of silently asserting success. - const handoffStatus = sess.frameworkContext[NEXT_STEPS_HANDOFF_KEY] as - | NextStepsHandoffStatus - | undefined; - const handoffBullet = handoffStatus?.ok - ? `Wrote ${NEXT_STEPS_FILE} with verification + handoff steps` - : handoffStatus - ? `Could NOT write ${NEXT_STEPS_FILE} (${handoffStatus.error}) — handoff steps are missing` - : ''; + // session and render the matching success/failure (or absent) + // bullet via the helper in ./handoff.ts. + const handoffBullet = buildHandoffBullet(getNextStepsHandoff(sess)); const changes = [ ...config.ui.getOutroChanges(frameworkContext), From 3c38606eb90bb743794cf7af0ba307cb9ee77ee6 Mon Sep 17 00:00:00 2001 From: Ethan Gui Date: Sat, 9 May 2026 09:46:57 -0500 Subject: [PATCH 3/6] fix(handoff): use NEXT_STEPS_FILE constant in postRun warn message Re-import the constant and template it into the warn line. The previous commit dropped NEXT_STEPS_FILE from the import block when the warn line was rewritten, leaving a hardcoded "posthog-next-steps.md" string. The outro bullet (rendered via buildHandoffBullet) and the failure warn line could have silently diverged on a future filename rename. Caught by round-2 code review (confidence 80, DRY/maintenance issue). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/workflows/posthog-integration/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index ee84cb69..1693be84 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -20,6 +20,7 @@ import { requestDeepLink } from '../../../utils/provisioning.js'; import type { CloudRegion } from '../../../utils/types.js'; import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; import { + NEXT_STEPS_FILE, buildHandoffBullet, getNextStepsHandoff, setNextStepsHandoff, @@ -186,7 +187,7 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server setNextStepsHandoff(sess, handoff); if (!handoff.ok) { getUI().log.warn( - `Could not write posthog-next-steps.md: ${handoff.error}`, + `Could not write ${NEXT_STEPS_FILE}: ${handoff.error}`, ); analytics.wizardCapture('next steps file write failed', { integration: config.metadata.integration, From 30c57fe9989d020a6489d12eabd7bef26dd8a327 Mon Sep 17 00:00:00 2001 From: Ethan Gui Date: Sat, 9 May 2026 10:17:34 -0500 Subject: [PATCH 4/6] refactor(handoff): drop wrapper-specific LLM content; surface agent prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end run against a fresh flashy clone revealed the LLM-content section assumed the @posthog/ai PostHogAnthropic wrapper path, but the wizard can also pick OpenTelemetry auto-instrumentation for the same JS framework + same package set (and did pick OTel in this run). My wrapper-specific guidance was therefore wrong half the time. Round-3 changes: - Drop LLM_ANALYTICS_QUIRKS_FOR_JS entirely. The PostHogAnthropic streaming quirk only applies to one of two installation strategies; baking it into the handoff was wrong for OTel runs. Strategy-specific guidance belongs in the agent-written setup report. - Drop the "grep for new Anthropic() constructors / @anthropic-ai/sdk imports" verify item — same reason. - Soften the "wizard-rewritten routes may have outdated mocks" wording to "wizard-rewritten or wizard-instrumented call sites may need updated mocks" — true under both strategies. - Soften the token-absent prose from "PostHog client wrapper" to "PostHog client setup" — the wizard might have written a wrapper or an OTel boot file, the noop-shim advice applies generically either way. - Add a pointer in the LLM smoke-test bullet: "(See posthog-setup-report.md for the specific LLM-analytics approach this run used.)" so the reader knows where strategy-specific details live. Surface the coding-agent prompt for easy copy/paste: - Extract `buildCodingAgentPrompt(ctx)` as a pure exported function. Now reusable from the TUI / CLI without scraping the rendered markdown. - Wrap the embedded prompt in a fenced code block instead of a blockquote. Triple-click selects cleanly in any editor / terminal; the previous `> ` blockquote pulled in the prefix on copy. Tests: 22 → 25 cases. New buildCodingAgentPrompt block (single-line contract, alternate reportFile name), new fenced-code-block embedding test. Replaced wrapper-specific tests with the strategy-agnostic counterpart and a regression test that no wrapper-specific advice sneaks back in. `pnpm typecheck` clean. `pnpm lint` 0 errors. `pnpm jest` 619 passed. e2e check against fresh flashy clone: all 16 content checks pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/handoff.test.ts | 114 +++++++++++------- .../workflows/posthog-integration/handoff.ts | 41 ++++--- 2 files changed, 90 insertions(+), 65 deletions(-) diff --git a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts index 94f4afdc..05b08f5f 100644 --- a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts +++ b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts @@ -7,6 +7,7 @@ import { buildSession } from '../../../wizard-session.js'; import { NEXT_STEPS_FILE, NEXT_STEPS_HANDOFF_KEY, + buildCodingAgentPrompt, buildHandoffBullet, buildNextStepsMarkdown, getNextStepsHandoff, @@ -62,73 +63,64 @@ describe('buildNextStepsMarkdown', () => { expect(md).not.toContain('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN'); }); - it('omits LLM smoke-test items when llmAnalyticsQueued is false', () => { + it('omits the LLM smoke-test bullet when llmAnalyticsQueued is false', () => { const md = buildNextStepsMarkdown(ctx({ llmAnalyticsQueued: false })); expect(md).not.toContain('$ai_generation'); - expect(md).not.toContain('@anthropic-ai/sdk'); - expect(md).not.toContain('messages.stream'); - }); - - it('includes LLM smoke-test items when llmAnalyticsQueued is true on a JS integration', () => { - const md = buildNextStepsMarkdown( - ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), - ); - expect(md).toContain('$ai_generation'); - expect(md).toMatch(/Search for any remaining direct LLM SDK constructors/); }); - it('skips the JS-only Anthropic-grep step on non-JS integrations even when LLM is queued', () => { - const md = buildNextStepsMarkdown( - ctx({ - frameworkName: 'Django', - integration: Integration.django, - llmAnalyticsQueued: true, - }), - ); - // Generic LLM smoke-test stays. - expect(md).toContain('$ai_generation'); - // JS-specific grep is dropped — Django users don't import @anthropic-ai/sdk. - expect(md).not.toContain('@anthropic-ai/sdk'); - expect(md).not.toContain('new Anthropic()'); - }); - - it('emits the LLM streaming quirk only on JS integrations with LLM queued', () => { + it('includes a strategy-agnostic LLM smoke-test bullet when llmAnalyticsQueued is true', () => { + // The wizard can pick between multiple LLM-analytics strategies for + // the same JS framework (PostHogAnthropic wrapper vs OTel + // auto-instrumentation). The handoff bullet must not assume a + // strategy — only the strategy-agnostic `$ai_generation` smoke-test + // appears, and it points the reader at the report for specifics. const jsLlm = buildNextStepsMarkdown( ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), ); - expect(jsLlm).toMatch(/\n- `@posthog\/ai`'s `PostHogAnthropic`/); - - const jsNoLlm = buildNextStepsMarkdown( - ctx({ integration: Integration.nextjs, llmAnalyticsQueued: false }), + expect(jsLlm).toContain('$ai_generation'); + expect(jsLlm).toMatch( + /See `posthog-setup-report\.md` for the specific LLM-analytics approach/, ); - expect(jsNoLlm).not.toContain('PostHogAnthropic'); - const nonJsLlm = buildNextStepsMarkdown( + const djangoLlm = buildNextStepsMarkdown( ctx({ frameworkName: 'Django', integration: Integration.django, llmAnalyticsQueued: true, }), ); - expect(nonJsLlm).not.toContain('PostHogAnthropic'); + expect(djangoLlm).toContain('$ai_generation'); }); - it('falls back to a "no quirks recorded" stub linking the issue tracker when the merged quirk list is empty', () => { + it('does not bake any strategy-specific LLM advice into the handoff', () => { + // Regression catch: do not re-introduce wrapper-vs-OTel-specific + // guidance here (it belongs in the agent-written setup report). const md = buildNextStepsMarkdown( - ctx({ integration: Integration.swift, llmAnalyticsQueued: false }), + ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), ); - expect(md).toContain('No additional quirks recorded for this integration'); - expect(md).toContain('https://github.com/PostHog/wizard/issues'); + expect(md).not.toContain('PostHogAnthropic'); + expect(md).not.toContain('messages.stream'); + expect(md).not.toContain('new Anthropic()'); + expect(md).not.toContain('@anthropic-ai/sdk'); }); - it('renders quirks with a leading hyphen-bullet (regression catch for .join changes)', () => { - // Today the registry has one quirk renderable in one path (LLM-on-JS). - // Verify the rendered shape — a regression to `.join(', ')` or any - // other separator would corrupt the bullet list invisibly. - const md = buildNextStepsMarkdown( - ctx({ integration: Integration.nextjs, llmAnalyticsQueued: true }), - ); - expect(md).toMatch(/\n- `@posthog\/ai`/); + it('renders the "no quirks recorded" stub on every integration today', () => { + // No quirks are registered yet; the stub should be the default + // render across every integration. Will need updating when the + // wizard team registers strategy-agnostic quirks. + for (const integration of [ + Integration.nextjs, + Integration.django, + Integration.swift, + ]) { + const md = buildNextStepsMarkdown( + ctx({ integration, llmAnalyticsQueued: true }), + ); + expect(md).toContain( + 'No additional quirks recorded for this integration', + ); + expect(md).toContain('https://github.com/PostHog/wizard/issues'); + } }); it('drops the source-maps glue item on backend-only integrations', () => { @@ -176,6 +168,36 @@ describe('buildNextStepsMarkdown', () => { }); }); +describe('buildCodingAgentPrompt', () => { + it('returns a single-paragraph prompt naming both files', () => { + const prompt = buildCodingAgentPrompt(ctx()); + expect(prompt).toContain('`posthog-setup-report.md`'); + expect(prompt).toContain('`posthog-next-steps.md`'); + // Single paragraph — no embedded newlines, so triple-click selection + // works in any editor / terminal. + expect(prompt).not.toMatch(/\n/); + }); + + it('respects an alternate reportFile name', () => { + const prompt = buildCodingAgentPrompt( + ctx({ reportFile: 'posthog-revenue-report.md' }), + ); + expect(prompt).toContain('`posthog-revenue-report.md`'); + expect(prompt).not.toContain('`posthog-setup-report.md`'); + }); +}); + +describe('coding-agent prompt embedding in the handoff doc', () => { + it('wraps the agent prompt in a fenced code block, not a blockquote', () => { + // Fenced code blocks select cleanly with triple-click; the previous + // blockquote (`> `) form pulled in the prefix on copy. + const md = buildNextStepsMarkdown(ctx()); + const prompt = buildCodingAgentPrompt(ctx()); + expect(md).toContain(`\`\`\`\n${prompt}\n\`\`\``); + expect(md).not.toMatch(/\n> Read `posthog-setup-report\.md`/); + }); +}); + describe('buildHandoffBullet', () => { it('renders a "Wrote ..." line for ok:true', () => { const bullet = buildHandoffBullet({ ok: true, path: '/tmp/x.md' }); diff --git a/src/lib/workflows/posthog-integration/handoff.ts b/src/lib/workflows/posthog-integration/handoff.ts index a54ce201..297ef2a3 100644 --- a/src/lib/workflows/posthog-integration/handoff.ts +++ b/src/lib/workflows/posthog-integration/handoff.ts @@ -117,12 +117,14 @@ const KNOWN_QUIRKS_BY_INTEGRATION: Record = { }; /** - * Quirks that only apply when LLM analytics is being installed in the same - * run AND the project is JS/TS (the only stack `@posthog/ai` ships for). + * The "hand this to your coding agent" prompt — the load-bearing piece of + * the handoff doc. Exported so the wizard's TUI / CLI can surface the same + * string for copy-paste, and so tests can assert it without scraping the + * rendered markdown. */ -const LLM_ANALYTICS_QUIRKS_FOR_JS: readonly string[] = [ - "`@posthog/ai`'s `PostHogAnthropic` only intercepts `messages.create()`. Don't use `messages.stream(...)` — it bypasses the wrapper and crashes at runtime when the SDK calls `.withResponse()` on the wrapped value. Use `messages.create({ stream: true, ... })` and iterate the lower-level events instead.", -]; +export function buildCodingAgentPrompt(ctx: NextStepsContext): string { + return `Read \`${ctx.reportFile}\` and \`${NEXT_STEPS_FILE}\`. Verify each item in the "Verify before merging" checklist. Apply any fixes for items that fail. Update the project glue listed in this file if it applies. Open a PR with the changes plus a summary of what was verified.`; +} /** * Build the markdown content. Pure function so it is trivial to unit-test @@ -139,28 +141,27 @@ export function buildNextStepsMarkdown(ctx: NextStepsContext): string { const isJs = JS_INTEGRATIONS.has(integration); const wantsSourceMaps = SOURCE_MAP_INTEGRATIONS.has(integration); - const baseQuirks = KNOWN_QUIRKS_BY_INTEGRATION[integration]; - const llmQuirks = - llmAnalyticsQueued && isJs ? [...LLM_ANALYTICS_QUIRKS_FOR_JS] : []; - const allQuirks = [...baseQuirks, ...llmQuirks]; + // No LLM quirks are baked in here. The wizard can pick between multiple + // LLM-analytics implementation strategies for the same JS framework + // (e.g. `@posthog/ai`'s `PostHogAnthropic` wrapper vs OpenTelemetry + // auto-instrumentation), and a quirk list that assumes a single strategy + // would be wrong half the time. Strategy-specific guidance lives in the + // agent-written setup report (`reportFile`), which describes what THIS + // run did. This handoff stays strategy-agnostic. + const allQuirks = KNOWN_QUIRKS_BY_INTEGRATION[integration]; const verifyItems = [ 'Run a production build to catch lint and type issues from generated code.', isJs - ? 'Run unit tests — wizard-rewritten routes may have outdated mocks (e.g. tests that mocked the bare SDK now need to mock the wrapped client).' + ? 'Run unit tests — wizard-rewritten or wizard-instrumented call sites may need updated mocks.' : 'Run unit / integration tests.', 'Smoke-test one capture call in the running app and confirm the event appears in PostHog.', 'Smoke-test the identify path and confirm the distinct id matches your user model — including the *returning visitor* path that auto-redirects past your auth submit handler.', ]; if (llmAnalyticsQueued) { verifyItems.push( - 'Smoke-test one streaming and one non-streaming LLM call and confirm `$ai_generation` events appear in PostHog.', + `Smoke-test one streaming and one non-streaming LLM call and confirm \`$ai_generation\` events appear in PostHog. (See \`${reportFile}\` for the specific LLM-analytics approach this run used.)`, ); - if (isJs) { - verifyItems.push( - 'Search for any remaining direct LLM SDK constructors (`new Anthropic()`, etc.) or non-type imports of `@anthropic-ai/sdk` and migrate them to the wrapped client. Automated detection can miss non-standard import shapes.', - ); - } } const envCallout = @@ -206,13 +207,15 @@ export function buildNextStepsMarkdown(ctx: NextStepsContext): string { '', '## Token-absent behavior', '', - 'By default the SDK logs warnings and may retry sends if the project token is unset. If you have CI / e2e environments without PostHog, consider noop-shimming the wizard-generated PostHog client wrapper so missing config is silent and safe.', + 'By default the SDK logs warnings and may retry sends if the project token is unset. If you have CI / e2e environments without PostHog, consider noop-shimming the wizard-generated PostHog client setup so missing config is silent and safe.', '', '## Hand this to your coding agent', '', - 'Copy and run:', + 'Copy the prompt below into your coding agent (single fenced block, triple-click to select):', '', - `> Read \`${reportFile}\` and \`${NEXT_STEPS_FILE}\`. Verify each item in the "Verify before merging" checklist. Apply any fixes for items that fail. Update the project glue listed in this file if it applies. Open a PR with the changes plus a summary of what was verified.`, + '```', + buildCodingAgentPrompt(ctx), + '```', '', ].join('\n'); } From 436c374995c797750b5a3fef16f152b0909ad302 Mon Sep 17 00:00:00 2001 From: Ethan Gui Date: Sat, 9 May 2026 10:29:29 -0500 Subject: [PATCH 5/6] refactor(handoff): drop circular agent-prompt section; surface in scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff doc embedded a "Hand this to your coding agent" section containing the literal text "Read posthog-setup-report.md AND posthog-next-steps.md" — a circular reference inside the very file the prompt instructs the agent to read. Every time the agent re-reads the file it re-tokenizes the same prompt block. Wasteful, weird. Move the prompt out of the doc: - buildNextStepsMarkdown drops the trailing section. The doc body is now purely the content the agent needs (verify / quirks / glue / token-absent). - buildCodingAgentPrompt remains exported as a pure function (sourced separately by the wizard's CLI). - buildCopyPasteBlock wraps the prompt with a ─-rule frame for terminal-scrollback visibility. Surface the prompt in the user's normal terminal scrollback (where they can triple-click to copy): - New OutroData.postExitMessage field — workflow-agnostic, any future workflow can opt in. - bin.ts captures `tui.store.session.outroData?.postExitMessage` before tui.unmount() and writes it to stdout AFTER the alternate screen tears down. The TUI's alternate-screen erases anything printed during the run; this is the workflow-agnostic way to surface persistent post-exit text. - posthog-integration's buildOutroData populates postExitMessage with buildCopyPasteBlock(buildCodingAgentPrompt(ctx)) — only when the handoff write succeeded (no point dangling the prompt if the file isn't there). Doc intro updated to be honest: "Work through the checklist below before merging. If you are handing this to a coding agent, the wizard printed a copy-paste-ready prompt at the end of its run." Tests: 23 → 25 cases. New `buildCopyPasteBlock` shape test, new "handoff doc does NOT embed the agent prompt" regression catch (so the circular section can't sneak back in). Replaced the in-doc-embedding test with a "points the reader at the wizard run output" test. `pnpm typecheck` clean. `pnpm lint` 0 errors. `pnpm jest` 620 passed. e2e check: all 16 content checks pass, including a new check that postExitMessage is populated with the wrapped prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin.ts | 9 ++++ src/lib/wizard-session.ts | 9 ++++ .../__tests__/handoff.test.ts | 52 +++++++++++++------ .../workflows/posthog-integration/handoff.ts | 39 +++++++++----- .../workflows/posthog-integration/index.ts | 25 ++++++++- 5 files changed, 103 insertions(+), 31 deletions(-) diff --git a/bin.ts b/bin.ts index f6d3a8a0..1aea3965 100644 --- a/bin.ts +++ b/bin.ts @@ -704,7 +704,16 @@ function runWizard( } catch (error) { analytics.captureException(error as Error); } + // Capture before unmount — store goes out of scope after. + const postExitMessage = tui.store.session.outroData?.postExitMessage; tui.unmount(); + // After tui.unmount() the alternate screen is gone, so anything we + // print here lands in the user's normal scrollback (where they can + // triple-click to copy). Workflows opt into this surface via + // `outroData.postExitMessage`. + if (postExitMessage) { + process.stdout.write(`\n${postExitMessage}\n`); + } process.exit(0); } catch (err) { if (runtimeEnv('DEBUG') || runtimeEnv('POSTHOG_WIZARD_DEBUG')) { diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 9544e9b0..bd56383b 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -90,6 +90,15 @@ export interface OutroData { continueUrl?: string; /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ reportFile?: string; + /** + * Optional block printed to stdout AFTER the TUI unmounts, so it lands in + * the user's terminal scrollback for triple-click copy. Use for things the + * user needs to grab persistently (e.g. a copy-paste-ready prompt for a + * coding agent). The TUI's alternate screen erases anything printed + * during the run; this field is the workflow-agnostic way to surface + * post-exit text. + */ + postExitMessage?: string; } export interface WizardSession { diff --git a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts index 05b08f5f..b7031d33 100644 --- a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts +++ b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts @@ -8,6 +8,7 @@ import { NEXT_STEPS_FILE, NEXT_STEPS_HANDOFF_KEY, buildCodingAgentPrompt, + buildCopyPasteBlock, buildHandoffBullet, buildNextStepsMarkdown, getNextStepsHandoff, @@ -32,7 +33,7 @@ function ctx(overrides: Partial = {}): NextStepsContext { } describe('buildNextStepsMarkdown', () => { - it('renders the headline, the manifest pointer, and the four required sections', () => { + it('renders the headline, the manifest pointer, and the required sections', () => { const md = buildNextStepsMarkdown(ctx()); expect(md).toMatch(/^# PostHog setup: next steps/); @@ -40,18 +41,12 @@ describe('buildNextStepsMarkdown', () => { expect(md).toContain('## Verify before merging'); expect(md).toContain('## Known SDK quirks'); expect(md).toContain('## Project glue we did NOT touch'); - expect(md).toContain('## Hand this to your coding agent'); + expect(md).toContain('## Token-absent behavior'); }); - it('embeds both filenames in the agent-handoff block on a single line', () => { - // The agent-handoff block is the load-bearing part of the file: a - // regression that drops it would silently defeat the whole purpose. - // Asserting both filenames appear on the same line catches a regression - // that just keeps the manifest pointer at the top of the file. + it('points the reader at the wizard run output for the agent prompt', () => { const md = buildNextStepsMarkdown(ctx()); - expect(md).toMatch( - /Read `posthog-setup-report\.md` and `posthog-next-steps\.md`/, - ); + expect(md).toMatch(/coding agent[\s\S]*wizard.*run/i); }); it('lists the configured env var names verbatim in the project glue section', () => { @@ -187,14 +182,37 @@ describe('buildCodingAgentPrompt', () => { }); }); -describe('coding-agent prompt embedding in the handoff doc', () => { - it('wraps the agent prompt in a fenced code block, not a blockquote', () => { - // Fenced code blocks select cleanly with triple-click; the previous - // blockquote (`> `) form pulled in the prefix on copy. +describe('handoff doc does NOT embed the agent prompt', () => { + it('omits the "Hand this to your coding agent" section entirely', () => { + // Embedding the prompt inside the file it instructs an agent to read + // is a circular reference: the agent re-tokenizes the same prompt + // every time it re-reads the file. The prompt lives in the wizard's + // terminal output instead. const md = buildNextStepsMarkdown(ctx()); - const prompt = buildCodingAgentPrompt(ctx()); - expect(md).toContain(`\`\`\`\n${prompt}\n\`\`\``); - expect(md).not.toMatch(/\n> Read `posthog-setup-report\.md`/); + expect(md).not.toContain('## Hand this to your coding agent'); + expect(md).not.toContain( + 'Read `posthog-setup-report.md` and `posthog-next-steps.md`', + ); + // Sanity: the prompt builder still works — it's just sourced from the + // wizard's CLI, not from a doc-embedded copy. + expect(buildCodingAgentPrompt(ctx())).toContain( + 'Read `posthog-setup-report.md` and `posthog-next-steps.md`', + ); + }); +}); + +describe('buildCopyPasteBlock', () => { + it('frames the prompt with rules for terminal-scrollback visibility', () => { + const block = buildCopyPasteBlock('PROMPT BODY'); + const lines = block.split('\n'); + // First and last lines are horizontal rules. + expect(lines[0]).toMatch(/^─+$/); + expect(lines[lines.length - 1]).toMatch(/^─+$/); + // Header line names the action. + expect(block).toContain('Copy this into your coding agent'); + // Body is on its own line, surrounded by blanks so triple-click on + // the prompt line selects just the prompt. + expect(block).toContain('\n\nPROMPT BODY\n\n'); }); }); diff --git a/src/lib/workflows/posthog-integration/handoff.ts b/src/lib/workflows/posthog-integration/handoff.ts index 297ef2a3..5e64c41a 100644 --- a/src/lib/workflows/posthog-integration/handoff.ts +++ b/src/lib/workflows/posthog-integration/handoff.ts @@ -117,15 +117,36 @@ const KNOWN_QUIRKS_BY_INTEGRATION: Record = { }; /** - * The "hand this to your coding agent" prompt — the load-bearing piece of - * the handoff doc. Exported so the wizard's TUI / CLI can surface the same - * string for copy-paste, and so tests can assert it without scraping the - * rendered markdown. + * The "hand this to your coding agent" prompt — the actionable string the + * user can paste into their agent so it reads both wizard-emitted files + * and finishes the integration. Deliberately NOT embedded in the handoff + * markdown (that would be a circular reference, and would burn tokens + * every time the agent re-reads the file). The wizard's CLI / TUI surfaces + * this string at run time instead. */ export function buildCodingAgentPrompt(ctx: NextStepsContext): string { return `Read \`${ctx.reportFile}\` and \`${NEXT_STEPS_FILE}\`. Verify each item in the "Verify before merging" checklist. Apply any fixes for items that fail. Update the project glue listed in this file if it applies. Open a PR with the changes plus a summary of what was verified.`; } +/** + * Wrap the agent prompt in a visually-distinct copy-paste block for terminal + * scrollback. The header + footer sit at column 0 with a thin rule, and the + * prompt body is left-flush so triple-click selects only the prompt itself + * (not the framing). + */ +export function buildCopyPasteBlock(prompt: string): string { + const rule = '─'.repeat(78); + return [ + rule, + 'Copy this into your coding agent (triple-click to select):', + rule, + '', + prompt, + '', + rule, + ].join('\n'); +} + /** * Build the markdown content. Pure function so it is trivial to unit-test * and to render outside of a wizard run. @@ -187,7 +208,7 @@ export function buildNextStepsMarkdown(ctx: NextStepsContext): string { '', `The wizard finished installing PostHog into your ${frameworkName} project. The companion file \`${reportFile}\` is the *manifest* of what changed; this file is the *handoff* — verification steps, known SDK quirks, and project glue we intentionally did not touch.`, '', - 'Hand both files to your coding agent (or work through them yourself) before merging.', + 'Work through the checklist below before merging. If you are handing this to a coding agent, the wizard printed a copy-paste-ready prompt at the end of its run.', '', '## Verify before merging', '', @@ -209,14 +230,6 @@ export function buildNextStepsMarkdown(ctx: NextStepsContext): string { '', 'By default the SDK logs warnings and may retry sends if the project token is unset. If you have CI / e2e environments without PostHog, consider noop-shimming the wizard-generated PostHog client setup so missing config is silent and safe.', '', - '## Hand this to your coding agent', - '', - 'Copy the prompt below into your coding agent (single fenced block, triple-click to select):', - '', - '```', - buildCodingAgentPrompt(ctx), - '```', - '', ].join('\n'); } diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index 1693be84..3ee2ea1b 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -21,6 +21,8 @@ import type { CloudRegion } from '../../../utils/types.js'; import { POSTHOG_INTEGRATION_WORKFLOW } from './steps.js'; import { NEXT_STEPS_FILE, + buildCodingAgentPrompt, + buildCopyPasteBlock, buildHandoffBullet, getNextStepsHandoff, setNextStepsHandoff, @@ -243,7 +245,8 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server // Pull the handoff write result that postRun stashed on the // session and render the matching success/failure (or absent) // bullet via the helper in ./handoff.ts. - const handoffBullet = buildHandoffBullet(getNextStepsHandoff(sess)); + const handoffStatus = getNextStepsHandoff(sess); + const handoffBullet = buildHandoffBullet(handoffStatus); const changes = [ ...config.ui.getOutroChanges(frameworkContext), @@ -253,6 +256,25 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server handoffBullet, ].filter(Boolean); + // If the handoff write succeeded, surface the coding-agent prompt + // in the user's terminal scrollback (after the TUI alternate + // screen tears down) so they can triple-click to copy. The doc + // intentionally does NOT embed this prompt — it would be a + // circular reference in something the agent reads. + const postExitMessage = handoffStatus?.ok + ? buildCopyPasteBlock( + buildCodingAgentPrompt({ + frameworkName: config.metadata.name, + integration: config.metadata.integration, + reportFile: SETUP_REPORT_FILE, + envVarNames: Object.keys(envVars), + llmAnalyticsQueued: sess.additionalFeatureQueue.includes( + AdditionalFeature.LLM, + ), + }), + ) + : undefined; + return { kind: OutroKind.Success as const, message: 'Successfully installed PostHog!', @@ -260,6 +282,7 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server changes, docsUrl: config.metadata.docsUrl, continueUrl, + postExitMessage, }; }, }; From 7ad5692265f2bc7e5d748afaa803650e1060c48e Mon Sep 17 00:00:00 2001 From: Ethan Gui Date: Sat, 9 May 2026 13:21:31 -0500 Subject: [PATCH 6/6] fix(handoff): use frameworkContext side channel for post-exit message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-run testing surfaced that the prior `OutroData.postExitMessage` field was silently lost. Diagnostic instrumentation in cleanup confirmed the cause: nanostores' `setKey` shallow-spreads the top-level session object, which means `frameworkContext` (a nested field) is shared by reference across atom replacements but `outroData` (a top-level field) is NOT. agent-runner.ts captures a session reference at the start of `runAgent`, then runs `session.outroData = config.buildOutroData(...)` near the end — by which time pushStatus / setOutroDismissed / etc. have replaced the atom session many times. The mutation goes to a stranded object the atom no longer references; ink-ui.outro reads the current atom view (undefined) and falls back to `{kind, message}` (no reportFile, no postExitMessage, no changes). This is a wizard-architectural bug — the same pattern silently drops `reportFile` from the exit-line "Check ./posthog-setup-report.md for details" suffix today. The team's existing partial workaround in ink-ui.outro covers `message` only. The right fix is wizard-level (agent-runner should push outroData via `store.setOutroData`, not direct mutation). For this PR I work around it via a workflow-agnostic side channel that doesn't need any agent-runner changes: - `src/lib/post-exit-message.ts` — `setPostExitMessage(session, text)` / `getPostExitMessage(session)`. Direct mutation on `frameworkContext`, which IS shared by reference across `setKey` shallow-spreads. Same pattern the existing `getNextStepsHandoff` / `setNextStepsHandoff` and `DASHBOARD_DEEP_LINK_KEY` accessors use, just generalized for any workflow. - `src/ui/tui/start-tui.ts` cleanup reads via `getPostExitMessage` and prints to scrollback after `releaseTerminal()`. Covers every exit path — explicit `tui.unmount()` from bin.ts AND any caller-driven `process.exit(N)` (e.g. `KeepSkillsScreen.tsx` exits the process directly when the user declines). - `src/lib/wizard-session.ts` — drops `OutroData.postExitMessage`. That field was a broken contract: the type advertised it, but the wizard's own session-mutation pattern silently dropped writes to it. Better to not advertise it at all than to give workflows a foot-gun. - `posthog-integration/index.ts` `postRun` calls `setPostExitMessage` on a successful handoff write. Side-by-side simplifications from the in-tree review pass: - `buildCodingAgentPrompt(reportFile: string)` instead of a 5-field context. The function read exactly one field; `NextStepsContext` was borrowed-shape coupling. - bin.ts comment updated to reference the actual mechanism (was still naming the dropped `outroData.postExitMessage` field). - start-tui.ts cleanup comment shrinks to a one-line pointer at post-exit-message.ts (was duplicating the rationale). - `POST_EXIT_MESSAGE_KEY` exported and used in the test (was a module-private const, with the test hardcoding the literal string — silent drift risk). - Inlined the redundant `handoffStatus` intermediate in buildOutroData. Tests +4: `post-exit-message.test.ts` covers round-trip, missing-key returns undefined, type-guard rejects non-string values, AND the "survives shallow setKey clones of the top-level session" regression catch — pins the actual bug we hit so a future "simplification" back to outroData fails CI with a comment explaining why. `pnpm typecheck` clean. `pnpm lint` 0 errors. `pnpm jest` 624 / 627 (3 pre-existing skips). e2e check (drives postRun + buildOutroData against a fresh flashy clone with mocked credentials) passes 16 / 16 content checks. Verified end-to-end with a real wizard run that exercises the agent loop (cost ~$5, ~12 min): the framed prompt block now appears in scrollback after `tui.unmount`. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin.ts | 14 ++---- src/lib/__tests__/post-exit-message.test.ts | 46 +++++++++++++++++++ src/lib/post-exit-message.ts | 32 +++++++++++++ src/lib/wizard-session.ts | 9 ---- .../__tests__/handoff.test.ts | 10 ++-- .../workflows/posthog-integration/handoff.ts | 14 +++--- .../workflows/posthog-integration/index.ts | 38 ++++----------- src/ui/tui/start-tui.ts | 11 +++++ 8 files changed, 114 insertions(+), 60 deletions(-) create mode 100644 src/lib/__tests__/post-exit-message.test.ts create mode 100644 src/lib/post-exit-message.ts diff --git a/bin.ts b/bin.ts index 1aea3965..b1afb370 100644 --- a/bin.ts +++ b/bin.ts @@ -704,16 +704,12 @@ function runWizard( } catch (error) { analytics.captureException(error as Error); } - // Capture before unmount — store goes out of scope after. - const postExitMessage = tui.store.session.outroData?.postExitMessage; + // tui.unmount() drains any post-exit message stashed via + // setPostExitMessage(...) to scrollback as part of its cleanup + // (see start-tui.ts + lib/post-exit-message.ts), so any + // copy-paste-ready prompt the workflow stashed lands in the + // user's terminal regardless of which screen exits the process. tui.unmount(); - // After tui.unmount() the alternate screen is gone, so anything we - // print here lands in the user's normal scrollback (where they can - // triple-click to copy). Workflows opt into this surface via - // `outroData.postExitMessage`. - if (postExitMessage) { - process.stdout.write(`\n${postExitMessage}\n`); - } process.exit(0); } catch (err) { if (runtimeEnv('DEBUG') || runtimeEnv('POSTHOG_WIZARD_DEBUG')) { diff --git a/src/lib/__tests__/post-exit-message.test.ts b/src/lib/__tests__/post-exit-message.test.ts new file mode 100644 index 00000000..6d09b5e7 --- /dev/null +++ b/src/lib/__tests__/post-exit-message.test.ts @@ -0,0 +1,46 @@ +import { buildSession } from '../wizard-session'; +import { + POST_EXIT_MESSAGE_KEY, + getPostExitMessage, + setPostExitMessage, +} from '../post-exit-message'; + +describe('post-exit-message accessors', () => { + it('round-trips set + get on the same session', () => { + const session = buildSession({}); + setPostExitMessage(session, 'hello terminal scrollback'); + expect(getPostExitMessage(session)).toBe('hello terminal scrollback'); + }); + + it('returns undefined when nothing has been stashed', () => { + expect(getPostExitMessage(buildSession({}))).toBeUndefined(); + }); + + it('returns undefined when frameworkContext holds a non-string at the key', () => { + // Defense-in-depth: the type guard rejects bogus shapes rather than + // returning garbage to the cleanup printer. + const session = buildSession({}); + session.frameworkContext[POST_EXIT_MESSAGE_KEY] = { not: 'a string' }; + expect(getPostExitMessage(session)).toBeUndefined(); + }); + + it('survives shallow setKey clones of the top-level session', () => { + // The whole point of using frameworkContext: agent-runner.ts mutates + // session.outroData on a stale reference (the atom replaces session + // via setKey during the run), and the mutation goes to a stranded + // object. frameworkContext is shared by reference across those + // setKey shallow-spreads, so a direct mutation on it survives a + // top-level replacement of the session — which is exactly what + // happens in production. Simulate that here. + const original = buildSession({}); + setPostExitMessage(original, 'lives in shared framework context'); + + // Simulate setKey: a NEW top-level session object that shallow-copies + // each top-level key from the original (so frameworkContext is the + // SAME reference as before). + const replaced = { ...original, lastStatus: 'something changed' }; + expect(getPostExitMessage(replaced)).toBe( + 'lives in shared framework context', + ); + }); +}); diff --git a/src/lib/post-exit-message.ts b/src/lib/post-exit-message.ts new file mode 100644 index 00000000..74750af8 --- /dev/null +++ b/src/lib/post-exit-message.ts @@ -0,0 +1,32 @@ +/** + * Stash text to print to the user's scrollback after the wizard exits. + * Read by `start-tui.ts`'s cleanup handler, AFTER `releaseTerminal()` — + * so the message survives any exit path (bin.ts unmount, screens that + * call `process.exit` directly, error paths). + * + * Why frameworkContext (not session.outroData): + * `agent-runner.ts` mutates `session.outroData` on a STALE session + * reference — by the time the mutation happens, `setKey` calls during + * the agent run have replaced the atom's top-level session, so the + * write goes to a stranded object. `frameworkContext` is the same + * reference across `setKey` shallow-spreads, so a direct mutation on + * it survives (until anyone calls `store.setFrameworkContext`, which + * clones it). Same pattern as + * `posthog-integration/handoff.ts`'s handoff-status accessor. + */ + +import type { WizardSession } from './wizard-session.js'; + +export const POST_EXIT_MESSAGE_KEY = 'pendingPostExitMessage'; + +export function setPostExitMessage( + session: WizardSession, + message: string, +): void { + session.frameworkContext[POST_EXIT_MESSAGE_KEY] = message; +} + +export function getPostExitMessage(session: WizardSession): string | undefined { + const v = session.frameworkContext?.[POST_EXIT_MESSAGE_KEY]; + return typeof v === 'string' ? v : undefined; +} diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index bd56383b..9544e9b0 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -90,15 +90,6 @@ export interface OutroData { continueUrl?: string; /** Report file the agent wrote (e.g. "posthog-setup-report.md") */ reportFile?: string; - /** - * Optional block printed to stdout AFTER the TUI unmounts, so it lands in - * the user's terminal scrollback for triple-click copy. Use for things the - * user needs to grab persistently (e.g. a copy-paste-ready prompt for a - * coding agent). The TUI's alternate screen erases anything printed - * during the run; this field is the workflow-agnostic way to surface - * post-exit text. - */ - postExitMessage?: string; } export interface WizardSession { diff --git a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts index b7031d33..bf47cbc1 100644 --- a/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts +++ b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts @@ -165,7 +165,7 @@ describe('buildNextStepsMarkdown', () => { describe('buildCodingAgentPrompt', () => { it('returns a single-paragraph prompt naming both files', () => { - const prompt = buildCodingAgentPrompt(ctx()); + const prompt = buildCodingAgentPrompt('posthog-setup-report.md'); expect(prompt).toContain('`posthog-setup-report.md`'); expect(prompt).toContain('`posthog-next-steps.md`'); // Single paragraph — no embedded newlines, so triple-click selection @@ -173,10 +173,8 @@ describe('buildCodingAgentPrompt', () => { expect(prompt).not.toMatch(/\n/); }); - it('respects an alternate reportFile name', () => { - const prompt = buildCodingAgentPrompt( - ctx({ reportFile: 'posthog-revenue-report.md' }), - ); + it('honors the supplied reportFile name', () => { + const prompt = buildCodingAgentPrompt('posthog-revenue-report.md'); expect(prompt).toContain('`posthog-revenue-report.md`'); expect(prompt).not.toContain('`posthog-setup-report.md`'); }); @@ -195,7 +193,7 @@ describe('handoff doc does NOT embed the agent prompt', () => { ); // Sanity: the prompt builder still works — it's just sourced from the // wizard's CLI, not from a doc-embedded copy. - expect(buildCodingAgentPrompt(ctx())).toContain( + expect(buildCodingAgentPrompt('posthog-setup-report.md')).toContain( 'Read `posthog-setup-report.md` and `posthog-next-steps.md`', ); }); diff --git a/src/lib/workflows/posthog-integration/handoff.ts b/src/lib/workflows/posthog-integration/handoff.ts index 5e64c41a..ac1c8a62 100644 --- a/src/lib/workflows/posthog-integration/handoff.ts +++ b/src/lib/workflows/posthog-integration/handoff.ts @@ -117,15 +117,13 @@ const KNOWN_QUIRKS_BY_INTEGRATION: Record = { }; /** - * The "hand this to your coding agent" prompt — the actionable string the - * user can paste into their agent so it reads both wizard-emitted files - * and finishes the integration. Deliberately NOT embedded in the handoff - * markdown (that would be a circular reference, and would burn tokens - * every time the agent re-reads the file). The wizard's CLI / TUI surfaces - * this string at run time instead. + * Returns the prompt the user pastes into their coding agent. Surfaced via + * the TUI's post-exit message (see ../../post-exit-message.ts), not + * embedded in the handoff doc — see the regression test in + * `__tests__/handoff.test.ts` for why. */ -export function buildCodingAgentPrompt(ctx: NextStepsContext): string { - return `Read \`${ctx.reportFile}\` and \`${NEXT_STEPS_FILE}\`. Verify each item in the "Verify before merging" checklist. Apply any fixes for items that fail. Update the project glue listed in this file if it applies. Open a PR with the changes plus a summary of what was verified.`; +export function buildCodingAgentPrompt(reportFile: string): string { + return `Read \`${reportFile}\` and \`${NEXT_STEPS_FILE}\`. Verify each item in the "Verify before merging" checklist. Apply any fixes for items that fail. Update the project glue listed in this file if it applies. Open a PR with the changes plus a summary of what was verified.`; } /** diff --git a/src/lib/workflows/posthog-integration/index.ts b/src/lib/workflows/posthog-integration/index.ts index 3ee2ea1b..e2ae8003 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -29,6 +29,7 @@ import { writeNextStepsFile, } from './handoff.js'; import { AdditionalFeature } from '../../wizard-session.js'; +import { setPostExitMessage } from '../../post-exit-message.js'; const DASHBOARD_DEEP_LINK_KEY = 'dashboardDeepLink'; @@ -187,7 +188,14 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server ), }); setNextStepsHandoff(sess, handoff); - if (!handoff.ok) { + if (handoff.ok) { + // Stash the framed agent prompt for the TUI's cleanup handler + // to print to scrollback (see lib/post-exit-message.ts). + setPostExitMessage( + sess, + buildCopyPasteBlock(buildCodingAgentPrompt(SETUP_REPORT_FILE)), + ); + } else { getUI().log.warn( `Could not write ${NEXT_STEPS_FILE}: ${handoff.error}`, ); @@ -242,39 +250,14 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server const deepLink = sess.frameworkContext[DASHBOARD_DEEP_LINK_KEY]; const continueUrl = resolveContinueUrl(sess, cloudRegion, deepLink); - // Pull the handoff write result that postRun stashed on the - // session and render the matching success/failure (or absent) - // bullet via the helper in ./handoff.ts. - const handoffStatus = getNextStepsHandoff(sess); - const handoffBullet = buildHandoffBullet(handoffStatus); - const changes = [ ...config.ui.getOutroChanges(frameworkContext), Object.keys(envVars).length > 0 ? 'Added environment variables to .env file' : '', - handoffBullet, + buildHandoffBullet(getNextStepsHandoff(sess)), ].filter(Boolean); - // If the handoff write succeeded, surface the coding-agent prompt - // in the user's terminal scrollback (after the TUI alternate - // screen tears down) so they can triple-click to copy. The doc - // intentionally does NOT embed this prompt — it would be a - // circular reference in something the agent reads. - const postExitMessage = handoffStatus?.ok - ? buildCopyPasteBlock( - buildCodingAgentPrompt({ - frameworkName: config.metadata.name, - integration: config.metadata.integration, - reportFile: SETUP_REPORT_FILE, - envVarNames: Object.keys(envVars), - llmAnalyticsQueued: sess.additionalFeatureQueue.includes( - AdditionalFeature.LLM, - ), - }), - ) - : undefined; - return { kind: OutroKind.Success as const, message: 'Successfully installed PostHog!', @@ -282,7 +265,6 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server changes, docsUrl: config.metadata.docsUrl, continueUrl, - postExitMessage, }; }, }; diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index eb1888a9..886f3de0 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -13,6 +13,7 @@ import { InkUI } from './ink-ui.js'; import { setUI } from '../index.js'; import { App } from './App.js'; import { OutroKind } from '../../lib/wizard-session.js'; +import { getPostExitMessage } from '../../lib/post-exit-message.js'; // ANSI escape sequences const RESET_ATTRS = '\x1b[0m'; @@ -75,6 +76,16 @@ export function startTUI( inkUnmount(); releaseTerminal(); process.stdout.write(getExitLine(store) + '\n'); + // Print any post-exit message a workflow stashed via + // `setPostExitMessage`. Inside cleanup, gated by `cleaned`, so it + // covers every exit path (explicit unmount from bin.ts and screens + // that call `process.exit` directly). See + // `lib/post-exit-message.ts` for why this reads frameworkContext + // and not outroData. + const postExit = getPostExitMessage(store.session); + if (postExit) { + process.stdout.write(`\n${postExit}\n`); + } }; process.on('exit', cleanup);