diff --git a/bin.ts b/bin.ts index f4bb1bd5..b03b11ab 100644 --- a/bin.ts +++ b/bin.ts @@ -69,6 +69,44 @@ 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'; +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 — +// 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 { + // 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` + + `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 + // 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, +// 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 2772dd02..e173ad42 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 e0fab155..435f0b1b 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 00000000..d6121150 --- /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 f7b10a8f..c1af9f58 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