diff --git a/bin.ts b/bin.ts index f6d3a8a0..b1afb370 100644 --- a/bin.ts +++ b/bin.ts @@ -704,6 +704,11 @@ function runWizard( } catch (error) { analytics.captureException(error as Error); } + // 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(); process.exit(0); } catch (err) { 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/workflows/posthog-integration/__tests__/handoff.test.ts b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts new file mode 100644 index 00000000..bf47cbc1 --- /dev/null +++ b/src/lib/workflows/posthog-integration/__tests__/handoff.test.ts @@ -0,0 +1,309 @@ +import fs from 'fs'; +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, + buildCodingAgentPrompt, + buildCopyPasteBlock, + buildHandoffBullet, + buildNextStepsMarkdown, + getNextStepsHandoff, + setNextStepsHandoff, + writeNextStepsFile, + type NextStepsContext, + type NextStepsHandoffStatus, +} 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 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('## Token-absent behavior'); + }); + + it('points the reader at the wizard run output for the agent prompt', () => { + const md = buildNextStepsMarkdown(ctx()); + expect(md).toMatch(/coding agent[\s\S]*wizard.*run/i); + }); + + 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 the LLM smoke-test bullet when llmAnalyticsQueued is false', () => { + const md = buildNextStepsMarkdown(ctx({ llmAnalyticsQueued: false })); + expect(md).not.toContain('$ai_generation'); + }); + + 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).toContain('$ai_generation'); + expect(jsLlm).toMatch( + /See `posthog-setup-report\.md` for the specific LLM-analytics approach/, + ); + + const djangoLlm = buildNextStepsMarkdown( + ctx({ + frameworkName: 'Django', + integration: Integration.django, + llmAnalyticsQueued: true, + }), + ); + expect(djangoLlm).toContain('$ai_generation'); + }); + + 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.nextjs, llmAnalyticsQueued: true }), + ); + 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 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', () => { + 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'); + }); + + 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('buildCodingAgentPrompt', () => { + it('returns a single-paragraph prompt naming both files', () => { + 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 + // works in any editor / terminal. + expect(prompt).not.toMatch(/\n/); + }); + + 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`'); + }); +}); + +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()); + 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('posthog-setup-report.md')).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'); + }); +}); + +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', () => { + 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..ac1c8a62 --- /dev/null +++ b/src/lib/workflows/posthog-integration/handoff.ts @@ -0,0 +1,316 @@ +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 + * 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; + /** 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; + /** 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; +} + +/** + * 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, + 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. + * 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, + 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]: [], +}; + +/** + * 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(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.`; +} + +/** + * 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. + */ +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); + + // 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 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. (See \`${reportFile}\` for the specific LLM-analytics approach this run used.)`, + ); + } + + 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.`, + '', + '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', + '', + 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 setup so missing config is silent and safe.', + '', + ].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. 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, +): NextStepsHandoffStatus { + 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), + }; + } +} + +/** + * 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 91dc28fc..e2ae8003 100644 --- a/src/lib/workflows/posthog-integration/index.ts +++ b/src/lib/workflows/posthog-integration/index.ts @@ -19,6 +19,17 @@ 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, + buildCodingAgentPrompt, + buildCopyPasteBlock, + buildHandoffBullet, + getNextStepsHandoff, + setNextStepsHandoff, + writeNextStepsFile, +} from './handoff.js'; +import { AdditionalFeature } from '../../wizard-session.js'; +import { setPostExitMessage } from '../../post-exit-message.js'; const DASHBOARD_DEEP_LINK_KEY = 'dashboardDeepLink'; @@ -162,6 +173,38 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server credentials.projectApiKey, credentials.host, ); + + // Drop the next-steps handoff doc next to the agent-generated + // 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, + reportFile: SETUP_REPORT_FILE, + envVarNames: Object.keys(envVars), + llmAnalyticsQueued: sess.additionalFeatureQueue.includes( + AdditionalFeature.LLM, + ), + }); + setNextStepsHandoff(sess, handoff); + 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}`, + ); + 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' @@ -212,6 +255,7 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server Object.keys(envVars).length > 0 ? 'Added environment variables to .env file' : '', + buildHandoffBullet(getNextStepsHandoff(sess)), ].filter(Boolean); return { 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);