From 6ef9b22e6b96b33cfcce6b7ee79a90ceff5a5326 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:02:04 +0000 Subject: [PATCH 1/3] fix(tui): pin the ink/react pair and guard the render() crash The wizard crashes before any UI renders when an npx install resolves a react / react-reconciler pair the bundled Ink reconciler cannot drive. render() throws synchronously, the terminal stays in the alt screen, and the error is lost in the discarded buffer. Two changes: - Pin react, react-reconciler, and ink to exact versions. The published package ships no lockfile, so floating ranges let an install land on an untested pair. Exact pins in dependencies are the only lock npx honors. - Release the terminal when render() throws, and add a top-level fatal handler that leaves the alt screen and prints a readable line before exit, so the real error reaches the user. Generated-By: PostHog Desktop Task-Id: 9592150b-f1a6-4e7d-8768-d5e22e8fa96b --- bin.ts | 22 +++++++++++ package.json | 5 ++- pnpm-lock.yaml | 7 +++- src/ui/tui/__tests__/start-tui.test.ts | 55 ++++++++++++++++++++++++++ src/ui/tui/start-tui.ts | 17 ++++++-- 5 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 src/ui/tui/__tests__/start-tui.test.ts diff --git a/bin.ts b/bin.ts index f4bb1bd58..78949691a 100644 --- a/bin.ts +++ b/bin.ts @@ -69,6 +69,28 @@ import { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps'; import { skillCommand } from './src/commands/skill'; import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from './src/lib/agent/claude-settings'; +import { releaseTerminal } from './src/ui/tui/terminal'; + +// Last-resort net for any error no local handler caught. A TUI command may +// already own the alt screen, so leave it, print a readable line, and exit — +// otherwise the user is stranded on a blank screen with the real error lost in +// the discarded alt buffer. Node already terminates on these events; this only +// makes the exit clean. +function handleFatal(err: unknown): void { + releaseTerminal(); + const message = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console + console.error(`\n\x1b[1;91m✖ The PostHog wizard crashed: ${message}\x1b[0m\n`); + emitWizardError({ code: ErrorCodes.InternalUnhandled, message }); + process.exit(1); +} +// Skip under test: the CLI suites import bin.ts and mock process.exit to throw, +// so a global handler would turn every mocked exit into a worker crash. Prod +// builds inline NODE_ENV as 'production', so the net is always live for users. +if (process.env.NODE_ENV !== 'test') { + process.on('unhandledRejection', handleFatal); + process.on('uncaughtException', handleFatal); +} // Heal any .claude/settings backup a previous interrupted run left orphaned, // before anything else reads Claude settings — conflict detection, OAuth, and diff --git a/package.json b/package.json index 2772dd026..e173ad421 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "fflate": "^0.8.3", "fuse.js": "^7.5.0", "glob": "9.3.5", - "ink": "^6.8.0", + "ink": "6.8.0", "inquirer": "^6.2.0", "jiti": "^2.7.0", "jsonc-parser": "^3.3.1", @@ -55,7 +55,8 @@ "opn": "^5.4.0", "pi-mcp-adapter": "~2.15.0", "posthog-node": "^5.45.2", - "react": "^19.2.4", + "react": "19.2.4", + "react-reconciler": "0.33.0", "read-env": "^1.3.0", "recast": "^0.23.3", "semver": "^7.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0fab1552..435f0b1ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,7 +48,7 @@ importers: specifier: 9.3.5 version: 9.3.5 ink: - specifier: ^6.8.0 + specifier: 6.8.0 version: 6.8.0(@types/react@19.2.14)(react@19.2.4) inquirer: specifier: ^6.2.0 @@ -78,8 +78,11 @@ importers: specifier: ^5.45.2 version: 5.45.2 react: - specifier: ^19.2.4 + specifier: 19.2.4 version: 19.2.4 + react-reconciler: + specifier: 0.33.0 + version: 0.33.0(react@19.2.4) read-env: specifier: ^1.3.0 version: 1.3.0 diff --git a/src/ui/tui/__tests__/start-tui.test.ts b/src/ui/tui/__tests__/start-tui.test.ts new file mode 100644 index 000000000..d6121150c --- /dev/null +++ b/src/ui/tui/__tests__/start-tui.test.ts @@ -0,0 +1,55 @@ +import { startTUI } from '@ui/tui/start-tui'; +import { Program } from '@ui/tui/store'; + +// render() is the throw site: Ink's reconciler blows up on a react / +// react-reconciler version mismatch. Force that here. +const renderThrows = new Error( + "Cannot read properties of undefined (reading 'S')", +); +vi.mock('ink', async (importOriginal) => ({ + ...(await importOriginal()), + render: () => { + throw renderThrows; + }, +})); +vi.mock('../../../utils/analytics.js', () => ({ + analytics: { + wizardCapture: vi.fn(), + setTag: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock('../../../utils/debug.js', () => ({ + logToFile: vi.fn(), +})); + +const LEAVE_ALT_SCREEN = '\x1b[?1049l'; +const ENTER_ALT_SCREEN = '\x1b[?1049h'; + +describe('startTUI', () => { + it('leaves the alt screen before a render() crash propagates', () => { + const chunks: string[] = []; + const originalWrite = process.stdout.write; + process.stdout.write = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + expect(() => startTUI('1.0.0', Program.PostHogIntegration)).toThrow( + renderThrows, + ); + } finally { + process.stdout.write = originalWrite; + } + + const output = chunks.join(''); + // The alt screen was entered, then left — so the caller's error message + // lands in the restored main buffer instead of a discarded one. + expect(output).toContain(ENTER_ALT_SCREEN); + expect(output).toContain(LEAVE_ALT_SCREEN); + expect(output.lastIndexOf(LEAVE_ALT_SCREEN)).toBeGreaterThan( + output.indexOf(ENTER_ALT_SCREEN), + ); + }); +}); diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index f7b10a8ff..c1af9f58f 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -35,9 +35,20 @@ export function startTUI( const inkUI = new InkUI(store); setUI(inkUI); - const { unmount: inkUnmount, waitUntilExit } = render( - createElement(App, { store }), - ); + // render() throws synchronously when Ink's reconciler cannot drive the + // resolved React — the signature of a react / react-reconciler version + // mismatch. enterDarkTerminal() already switched to the alt screen, so leave + // it before the error propagates. Without this the caller prints its message + // into the alt buffer, which the terminal discards on exit, and the user is + // left on a blank screen with no message. + let rendered: ReturnType; + try { + rendered = render(createElement(App, { store })); + } catch (err) { + releaseTerminal(); + throw err; + } + const { unmount: inkUnmount, waitUntilExit } = rendered; analytics.setTag('program_id', program); // The launch marker — the first event of every TUI run, captured under From 203b0b1c4dc948904f0469b5e1443a7a41b66f6c Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:46:43 +0000 Subject: [PATCH 2/3] fix(tui): run registered cleanups on fatal exit handleFatal() exited via process.exit(1) without calling runCleanups(), so an uncaught exception / unhandled rejection skipped every registered cleanup. backupAndFixClaudeSettings() deletes the user's .claude/settings.json and registers a synchronous restore through registerCleanup; a fatal event during the agent phase therefore left the user with no settings file (composed runs whose backup lives under a sub-directory aren't healed by the startup recovery either). Run runCleanups() before exiting, mirroring wizardAbort() and the runner catch/signal paths. runCleanups() is synchronous and guards each fn, so it is safe to call from the fatal handler right before process.exit. Generated-By: PostHog Desktop Task-Id: b33e9b19-ae51-4345-a6e8-1cccbcbac73d --- bin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bin.ts b/bin.ts index 78949691a..49d6916c1 100644 --- a/bin.ts +++ b/bin.ts @@ -70,6 +70,7 @@ import { skillCommand } from './src/commands/skill'; import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from './src/lib/agent/claude-settings'; import { releaseTerminal } from './src/ui/tui/terminal'; +import { runCleanups } from './src/utils/wizard-abort'; // Last-resort net for any error no local handler caught. A TUI command may // already own the alt screen, so leave it, print a readable line, and exit — @@ -82,6 +83,11 @@ function handleFatal(err: unknown): void { // eslint-disable-next-line no-console console.error(`\n\x1b[1;91m✖ The PostHog wizard crashed: ${message}\x1b[0m\n`); emitWizardError({ code: ErrorCodes.InternalUnhandled, message }); + // Run registered cleanups before exiting, same as wizardAbort() and the + // runner catch/signal paths — a fatal event otherwise skips them, leaving + // state a cleanup owns unrestored (e.g. the backed-up .claude/settings.json + // that backupAndFixClaudeSettings deletes and registers a restore for). + runCleanups(); process.exit(1); } // Skip under test: the CLI suites import bin.ts and mock process.exit to throw, From 646936a964b577ef0d365f50747f9cf0934d50ad Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:49:19 +0000 Subject: [PATCH 3/3] fix(tui): keep the error stack on fatal exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering the uncaughtException / unhandledRejection handlers replaces Node's default handling, which prints the full stack to stderr. handleFatal kept only err.message, and emitWizardError records just code/message/detail with no stack field — so a crash outside the runner try left the stack nowhere. That hides the evidence for exactly the class of failure this net catches: the target TypeError "Cannot read properties of undefined (reading 'S')" names no module without its stack pointing at react-reconciler. Log the full error to the wizard log at the start of the handler (as run-wizard's fatal path already does via logToFile) and show the log path in the readable line. logToFile preserves an Error's stack, needs no init, and swallows its own write failures, so it is safe inside the handler. Generated-By: PostHog Desktop Task-Id: b33e9b19-ae51-4345-a6e8-1cccbcbac73d --- bin.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bin.ts b/bin.ts index 49d6916c1..b03b11ab9 100644 --- a/bin.ts +++ b/bin.ts @@ -71,6 +71,7 @@ import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from './src/lib/agent/claude-settings'; import { releaseTerminal } from './src/ui/tui/terminal'; import { runCleanups } from './src/utils/wizard-abort'; +import { logToFile, getLogFilePath } from './src/utils/debug'; // Last-resort net for any error no local handler caught. A TUI command may // already own the alt screen, so leave it, print a readable line, and exit — @@ -78,10 +79,19 @@ import { runCleanups } from './src/utils/wizard-abort'; // the discarded alt buffer. Node already terminates on these events; this only // makes the exit clean. function handleFatal(err: unknown): void { + // Preserve the full stack in the wizard log first, as the runner's fatal + // path does — the readable line below carries only err.message, and the + // crash this net exists to catch ("Cannot read properties of undefined + // (reading 'S')") names no module without its stack. logToFile needs no + // init and never throws, so it is safe this early and inside the handler. + logToFile('[bin] FATAL:', err); releaseTerminal(); const message = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console - console.error(`\n\x1b[1;91m✖ The PostHog wizard crashed: ${message}\x1b[0m\n`); + console.error( + `\n\x1b[1;91m✖ The PostHog wizard crashed: ${message}\x1b[0m\n` + + `Full logs: ${getLogFilePath()}\n`, + ); emitWizardError({ code: ErrorCodes.InternalUnhandled, message }); // Run registered cleanups before exiting, same as wizardAbort() and the // runner catch/signal paths — a fatal event otherwise skips them, leaving