Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 85 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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],
},
});
}
},
};
},
},
},
};

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down
55 changes: 48 additions & 7 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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<boolean> {
// 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;
Expand All @@ -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<string | null> {
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;
}
}