diff --git a/eslint.config.mjs b/eslint.config.mjs index bb1a72f74..58d5b8f27 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,8 +5,8 @@ import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; /** - * Custom ESLint plugin for zombie process prevention - * Enforces safe child_process patterns + * Custom ESLint plugin for safe Node.js patterns + * Enforces safe child_process and filesystem patterns */ const localPlugin = { rules: { @@ -41,6 +41,67 @@ const localPlugin = { }; }, }, + "no-sync-fs-methods": { + meta: { + type: "problem", + docs: { + description: "Prevent synchronous filesystem operations", + }, + messages: { + syncFsMethod: + "Do not use synchronous fs methods ({{method}}). Use async version instead: {{asyncMethod}}", + }, + }, + create(context) { + // Map of sync methods to their async equivalents + const syncMethods = { + statSync: "stat", + readFileSync: "readFile", + writeFileSync: "writeFile", + readdirSync: "readdir", + mkdirSync: "mkdir", + unlinkSync: "unlink", + rmdirSync: "rmdir", + existsSync: "access or stat", + accessSync: "access", + copyFileSync: "copyFile", + renameSync: "rename", + chmodSync: "chmod", + chownSync: "chown", + lstatSync: "lstat", + linkSync: "link", + symlinkSync: "symlink", + readlinkSync: "readlink", + realpathSync: "realpath", + truncateSync: "truncate", + fstatSync: "fstat", + appendFileSync: "appendFile", + }; + + return { + MemberExpression(node) { + // Only flag if it's a property access on 'fs' or imported fs methods + if ( + node.property && + node.property.type === "Identifier" && + syncMethods[node.property.name] && + node.object && + node.object.type === "Identifier" && + (node.object.name === "fs" || node.object.name === "fsPromises") + ) { + context.report({ + node, + messageId: "syncFsMethod", + data: { + method: node.property.name, + asyncMethod: syncMethods[node.property.name], + }, + }); + } + }, + }; + }, + }, }, }; @@ -178,8 +239,9 @@ export default defineConfig([ "react/react-in-jsx-scope": "off", "react/prop-types": "off", - // Zombie process prevention + // Safe Node.js patterns "local/no-unsafe-child-process": "error", + "local/no-sync-fs-methods": "error", // Allow console for this app (it's a dev tool) "no-console": "off", @@ -259,6 +321,26 @@ export default defineConfig([ "no-restricted-syntax": "off", }, }, + { + // Temporarily allow sync fs methods in files with existing usage + // TODO: Gradually migrate these to async operations + files: [ + "src/config.ts", + "src/debug/**/*.ts", + "src/git.ts", + "src/main.ts", + "src/services/gitService.ts", + "src/services/log.ts", + "src/services/streamManager.ts", + "src/services/tempDir.ts", + "src/services/tools/bash.ts", + "src/services/tools/bash.test.ts", + "src/services/tools/testHelpers.ts", + ], + rules: { + "local/no-sync-fs-methods": "off", + }, + }, { // Frontend architectural boundary - prevent services and tokenizer imports files: ["src/components/**", "src/contexts/**", "src/hooks/**", "src/App.tsx"], diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index ce2115f5d..a9d70d887 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -652,11 +652,11 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, (_event, workspacePath: string) => { + ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspacePath: string) => { try { if (process.platform === "darwin") { // macOS - try Ghostty first, fallback to Terminal.app - const terminal = this.findAvailableCommand(["ghostty", "terminal"]); + const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { const child = spawn("open", ["-a", "Ghostty", workspacePath], { detached: true, @@ -693,7 +693,7 @@ export class IpcMain { { cmd: "xterm", args: [], cwd: workspacePath }, ]; - const availableTerminal = terminals.find((t) => this.isCommandAvailable(t.cmd)); + const availableTerminal = await this.findAvailableTerminal(terminals); if (availableTerminal) { const child = spawn(availableTerminal.cmd, availableTerminal.args, { @@ -1036,9 +1036,31 @@ export class IpcMain { } /** - * Check if a command is available in the system PATH + * Check if a command is available in the system PATH or known locations */ - private isCommandAvailable(command: string): boolean { + private async isCommandAvailable(command: string): Promise { + // Special handling for ghostty on macOS - check common installation paths + if (command === "ghostty" && process.platform === "darwin") { + const ghosttyPaths = [ + "/opt/homebrew/bin/ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "/usr/local/bin/ghostty", + ]; + + for (const ghosttyPath of ghosttyPaths) { + try { + const stats = await fsPromises.stat(ghosttyPath); + // Check if it's a file and any executable bit is set (owner, group, or other) + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + return true; + } + } catch { + // Try next path + } + } + // If none of the known paths work, fall through to which check + } + try { const result = spawnSync("which", [command], { encoding: "utf8" }); return result.status === 0; @@ -1050,7 +1072,26 @@ export class IpcMain { /** * Find the first available command from a list of commands */ - private findAvailableCommand(commands: string[]): string | null { - return commands.find((cmd) => this.isCommandAvailable(cmd)) ?? null; + private async findAvailableCommand(commands: string[]): Promise { + for (const cmd of commands) { + if (await this.isCommandAvailable(cmd)) { + return cmd; + } + } + return null; + } + + /** + * Find the first available terminal from a list of terminal configurations + */ + private async findAvailableTerminal( + terminals: Array<{ cmd: string; args: string[]; cwd?: string }> + ): Promise<{ cmd: string; args: string[]; cwd?: string } | null> { + for (const terminal of terminals) { + if (await this.isCommandAvailable(terminal.cmd)) { + return terminal; + } + } + return null; } }