From bcef961dc1384cf84c4692f6561935a2a844c9f8 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:11:16 +1100 Subject: [PATCH] fix: find pi when the app is launched from Finder Finder does not inherit the login-shell PATH, so spawn("pi") threw ENOENT and crashed the main process. Look in Bun, Homebrew, and mise first, and report a missing binary in the window. Co-authored-by: Cursor --- src/main/index.ts | 14 +++- src/main/rpc.ts | 118 +++++++++++++++++++++++++++------- src/renderer/App.tsx | 4 ++ src/renderer/app.css | 4 ++ tests/unit/resolve-pi.test.ts | 51 +++++++++++++++ 5 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 tests/unit/resolve-pi.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 5296557..f6001e4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -96,12 +96,24 @@ ipcMain.handle( } const id = `s${++sessionSeq}`; - const session = new RpcSession(id, cwd, sessionFile); + let session: RpcSession; + try { + session = new RpcSession(id, cwd, sessionFile); + } catch (err) { + const message = err instanceof Error ? err.message : 'could not start pi'; + return { error: message }; + } session.onEvent = (sessionId, event) => sendToRenderer('pidex:session:event', { sessionId, event }); session.onExit = sessionId => sendToRenderer('pidex:session:exit', { sessionId }); sessions.set(id, session); const [state, messages] = await Promise.all([session.getState(), session.getMessages()]); + const launchError = session.launchError(); + if (launchError) { + sessions.delete(id); + session.dispose(); + return { error: launchError }; + } return { sessionId: id, state: state.success ? state.data : null, diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 849fa66..0e4deb8 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -1,12 +1,9 @@ import { spawn, type ChildProcess } from 'child_process'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; -const PI_CANDIDATES = [ - 'pi', - '/opt/homebrew/bin/pi', - '/usr/local/bin/pi', - '/usr/bin/pi', -]; +const PI_MISSING = 'Could not find the pi command. Install it, or set PIDEX_PI to the binary.'; type RpcEvent = Record & { type: string }; @@ -19,15 +16,55 @@ type RpcResponse = { error?: string; }; -function resolvePi(): string { - for (const candidate of PI_CANDIDATES) { - if (candidate.includes('/')) { - if (fs.existsSync(candidate)) return candidate; - } else { - return candidate; +function piBinNames(): string[] { + return process.platform === 'win32' ? ['pi.cmd', 'pi.exe', 'pi'] : ['pi']; +} + +/** Directories a Finder launch does not inherit from the login shell. */ +export function piPathDirs(home = os.homedir()): string[] { + return [ + path.join(home, '.bun', 'bin'), + path.join(home, '.local', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + path.join(home, '.local', 'share', 'mise', 'shims'), + ]; +} + +function looksAbsolute(file: string): boolean { + return path.isAbsolute(file); +} + +export function resolvePi( + env: NodeJS.ProcessEnv = process.env, + exists: (file: string) => boolean = fs.existsSync, + home = os.homedir(), +): string | null { + const override = env.PIDEX_PI?.trim(); + if (override && looksAbsolute(override) && exists(override)) return override; + + for (const dir of piPathDirs(home)) { + for (const name of piBinNames()) { + const candidate = path.join(dir, name); + if (exists(candidate)) return candidate; } } - return 'pi'; + + const pathEnv = env.PATH ?? ''; + for (const dir of pathEnv.split(path.delimiter)) { + if (!dir) continue; + for (const name of piBinNames()) { + const candidate = path.join(dir, name); + if (exists(candidate)) return candidate; + } + } + return null; +} + +function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const current = (env.PATH ?? '').split(path.delimiter).filter(Boolean); + const extra = piPathDirs().filter(dir => !current.includes(dir)); + return { ...env, PATH: [...extra, ...current].join(path.delimiter) }; } class RpcSession { @@ -36,6 +73,8 @@ class RpcSession { private proc: ChildProcess; private buffer = ''; private seq = 0; + private closed = false; + private failed: string | null = null; private pending = new Map void; timer: NodeJS.Timeout }>(); onEvent: (sessionId: string, event: RpcEvent) => void = () => {}; onExit: (sessionId: string, code: number | null) => void = () => {}; @@ -43,11 +82,13 @@ class RpcSession { constructor(id: string, cwd: string, sessionFile?: string) { this.id = id; this.cwd = cwd; + const bin = resolvePi(); + if (!bin) throw new Error(PI_MISSING); const args = ['--mode', 'rpc']; if (sessionFile) args.push('--session', sessionFile); - this.proc = spawn(resolvePi(), args, { + this.proc = spawn(bin, args, { cwd, - env: { ...process.env }, + env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'], }); @@ -55,16 +96,30 @@ class RpcSession { this.proc.stdout!.on('data', chunk => this.handleChunk(chunk)); this.proc.stderr!.setEncoding('utf8'); this.proc.stderr!.on('data', () => {}); + this.proc.stdin?.on('error', () => {}); + this.proc.on('error', err => { + const code = (err as NodeJS.ErrnoException).code; + this.failed = code === 'ENOENT' ? PI_MISSING : err.message; + this.close(null); + }); this.proc.on('exit', code => { - for (const [pid, p] of this.pending) { - clearTimeout(p.timer); - p.resolve({ type: 'response', command: '', success: false, id: pid, error: 'process exited' }); - } - this.pending.clear(); - this.onExit(this.id, code); + this.failed ??= 'process exited'; + this.close(code); }); } + private close(code: number | null) { + if (this.closed) return; + this.closed = true; + const error = this.failed ?? 'process exited'; + for (const [, pending] of this.pending) { + clearTimeout(pending.timer); + pending.resolve({ type: 'response', command: '', success: false, error }); + } + this.pending.clear(); + this.onExit(this.id, code); + } + private handleChunk(chunk: string) { this.buffer += chunk; let idx: number; @@ -96,6 +151,14 @@ class RpcSession { } command(cmd: Record): Promise { + if (this.failed || this.closed) { + return Promise.resolve({ + type: 'response', + command: String(cmd.type ?? ''), + success: false, + error: this.failed ?? 'process exited', + }); + } const id = `req-${++this.seq}`; return new Promise(resolve => { const timer = setTimeout(() => { @@ -103,10 +166,21 @@ class RpcSession { resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: 'timeout' }); }, 30000); this.pending.set(id, { resolve, timer }); - this.proc.stdin!.write(JSON.stringify({ id, ...cmd }) + '\n'); + try { + this.proc.stdin!.write(JSON.stringify({ id, ...cmd }) + '\n'); + } catch (err) { + clearTimeout(timer); + this.pending.delete(id); + const message = err instanceof Error ? err.message : 'could not write to pi'; + resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: message }); + } }); } + launchError(): string | null { + return this.closed ? this.failed : null; + } + getState() { return this.command({ type: 'get_state' }); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 460b1e7..59988a4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -56,6 +56,7 @@ function App() { const [activeId, setActiveId] = useState(null); const [draft, setDraft] = useState(''); const [opening, setOpening] = useState(false); + const [openError, setOpenError] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [fontReady, setFontReady] = useState(false); const timelineRef = useRef(null); @@ -179,12 +180,14 @@ function App() { return; } setOpening(true); + setOpenError(null); try { const result = await window.pidex.openSession({ cwd: ws.cwd, sessionFile: info?.file, }); if (result.error || !result.sessionId) { + setOpenError(result.error ?? 'Could not open the session.'); return; } const model = @@ -517,6 +520,7 @@ function App() {

Pick a session from the sidebar, or start a new one in the expanded workspace.

+ {openError ?

{openError}

: null} )} diff --git a/src/renderer/app.css b/src/renderer/app.css index 4868ad5..bc55f9e 100644 --- a/src/renderer/app.css +++ b/src/renderer/app.css @@ -349,6 +349,10 @@ font-size: 0.85rem; } +.empty-copy--error { + color: var(--error); +} + /* Main pane ---------------------------------------------------------------- */ .main-pane { diff --git a/tests/unit/resolve-pi.test.ts b/tests/unit/resolve-pi.test.ts new file mode 100644 index 0000000..2dc2b68 --- /dev/null +++ b/tests/unit/resolve-pi.test.ts @@ -0,0 +1,51 @@ +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { resolvePi } from '../../src/main/rpc'; + +const home = '/Users/someone'; + +function existsAmong(files: string[]) { + const set = new Set(files); + return (file: string) => set.has(file); +} + +describe('resolvePi', () => { + it('uses a Finder-invisible Bun or Homebrew install instead of the bare name', () => { + const bun = path.join(home, '.bun', 'bin', 'pi'); + const brew = '/opt/homebrew/bin/pi'; + const resolved = resolvePi({ PATH: '/usr/bin:/bin' }, existsAmong([bun, brew]), home); + expect(resolved).toBe(bun); + }); + + it('uses Homebrew when the Bun install is absent', () => { + const brew = '/opt/homebrew/bin/pi'; + const resolved = resolvePi({ PATH: '/usr/bin:/bin' }, existsAmong([brew]), home); + expect(resolved).toBe(brew); + }); + + it('honours an absolute PIDEX_PI override', () => { + const custom = '/opt/pi/bin/pi'; + const resolved = resolvePi({ PATH: '/usr/bin:/bin', PIDEX_PI: custom }, existsAmong([custom]), home); + expect(resolved).toBe(custom); + }); + + it('skips a missing PIDEX_PI and keeps searching', () => { + const brew = '/opt/homebrew/bin/pi'; + const resolved = resolvePi( + { PATH: '/usr/bin:/bin', PIDEX_PI: '/missing/pi' }, + existsAmong([brew]), + home, + ); + expect(resolved).toBe(brew); + }); + + it('falls back to PATH when no known install exists', () => { + const onPath = '/usr/local/custom/bin/pi'; + const resolved = resolvePi({ PATH: '/usr/local/custom/bin:/usr/bin' }, existsAmong([onPath]), home); + expect(resolved).toBe(onPath); + }); + + it('returns null when pi is not installed', () => { + expect(resolvePi({ PATH: '/usr/bin:/bin' }, () => false, home)).toBeNull(); + }); +});