From dd04429482d5dd3909e222fcb4cdd44c079522fd Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Mon, 19 Jan 2026 22:15:39 +0500 Subject: [PATCH] Add pre-commit hooks with husky and lint staged --- .editorconfig | 24 +++ .gitignore | 6 +- .husky/pre-commit | 4 + .vscode/extensions.json | 7 + .vscode/settings.json | 47 ++++++ eslint.config.mjs | 4 + package-lock.json | 17 ++ package.json | 13 ++ .../runners/SshCommandRunner.ts | 145 +++++++++--------- 9 files changed, 194 insertions(+), 73 deletions(-) create mode 100644 .editorconfig create mode 100644 .husky/pre-commit create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4abbba14 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig helps maintain consistent coding styles across editors +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,tsx,js,jsx,mjs,cjs}] +indent_style = tab +indent_size = 2 + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore index 53598757..3fe49f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,11 @@ scratch/ Thumbs.db # IDE -.vscode/ +# .vscode/ is tracked for shared settings (settings.json, extensions.json) +# But ignore personal/local files +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json .idea/ *.swp *.swo diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..8505dcd3 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +# Run lint-staged for formatting and linting on staged files only +npx lint-staged + +# Everything else (tests, type checking) should run in CI diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..9c86b8bc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d7b1963a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,47 @@ +{ + // Format on save with Prettier + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + + // Use tabs (matches .prettierrc and .editorconfig) + "editor.tabSize": 2, + "editor.insertSpaces": false, + "editor.detectIndentation": false, + + // ESLint configuration + "eslint.enable": true, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + + // Don't let ESLint format - let Prettier handle it + "eslint.format.enable": false, + + // File-specific formatters + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + + // Recommended extensions + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + + // Files to exclude from search/watch + "files.exclude": { + "dist": true, + "release": true, + "node_modules": true + }, + + // TypeScript settings + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 536a96d0..f2a356e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,7 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import prettierConfig from 'eslint-config-prettier'; import globals from 'globals'; export default tseslint.config( @@ -28,6 +29,9 @@ export default tseslint.config( // TypeScript ESLint recommended rules ...tseslint.configs.recommended, + // Prettier config - disables ESLint rules that conflict with Prettier + prettierConfig, + // Main configuration for all TypeScript files { files: ['src/**/*.ts', 'src/**/*.tsx'], diff --git a/package-lock.json b/package-lock.json index 3f970a0d..02397c75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "electron-rebuild": "^3.2.9", "esbuild": "^0.24.2", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "globals": "^16.5.0", @@ -9103,6 +9104,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", diff --git a/package.json b/package.json index 37cbf6f3..fc30d758 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux", "start": "electron .", "clean": "rm -rf dist release node_modules/.vite", + "prepare": "husky", "postinstall": "electron-rebuild -f -w node-pty,better-sqlite3", "lint": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit", "lint:eslint": "eslint src/", @@ -281,10 +282,13 @@ "electron-rebuild": "^3.2.9", "esbuild": "^0.24.2", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "globals": "^16.5.0", + "husky": "^9.1.7", "jsdom": "^27.2.0", + "lint-staged": "^16.2.7", "lucide-react": "^0.303.0", "playwright": "^1.57.0", "postcss": "^8.4.33", @@ -300,5 +304,14 @@ }, "engines": { "node": ">=22.0.0" + }, + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "prettier --write", + "eslint --fix" + ], + "src/**/*.{json,css,md}": [ + "prettier --write" + ] } } diff --git a/src/main/process-manager/runners/SshCommandRunner.ts b/src/main/process-manager/runners/SshCommandRunner.ts index 98eba000..5baffed8 100644 --- a/src/main/process-manager/runners/SshCommandRunner.ts +++ b/src/main/process-manager/runners/SshCommandRunner.ts @@ -26,91 +26,92 @@ export class SshCommandRunner { sshConfig: SshRemoteConfig, shellEnvVars?: Record ): Promise { - return new Promise(async (resolve) => { - // Build SSH arguments - const sshArgs: string[] = []; + // Build SSH arguments + const sshArgs: string[] = []; - // Force disable TTY allocation - sshArgs.push('-T'); + // Force disable TTY allocation + sshArgs.push('-T'); - // Add identity file - if (sshConfig.useSshConfig) { - // Only specify identity file if explicitly provided (override SSH config) - if (sshConfig.privateKeyPath && sshConfig.privateKeyPath.trim()) { - sshArgs.push('-i', expandTilde(sshConfig.privateKeyPath)); - } - } else { - // Direct connection: require private key + // Add identity file + if (sshConfig.useSshConfig) { + // Only specify identity file if explicitly provided (override SSH config) + if (sshConfig.privateKeyPath && sshConfig.privateKeyPath.trim()) { sshArgs.push('-i', expandTilde(sshConfig.privateKeyPath)); } + } else { + // Direct connection: require private key + sshArgs.push('-i', expandTilde(sshConfig.privateKeyPath)); + } - // Default SSH options for non-interactive operation - const sshOptions: Record = { - BatchMode: 'yes', - StrictHostKeyChecking: 'accept-new', - ConnectTimeout: '10', - ClearAllForwardings: 'yes', - RequestTTY: 'no', - }; - for (const [key, value] of Object.entries(sshOptions)) { - sshArgs.push('-o', `${key}=${value}`); - } + // Default SSH options for non-interactive operation + const sshOptions: Record = { + BatchMode: 'yes', + StrictHostKeyChecking: 'accept-new', + ConnectTimeout: '10', + ClearAllForwardings: 'yes', + RequestTTY: 'no', + }; + for (const [key, value] of Object.entries(sshOptions)) { + sshArgs.push('-o', `${key}=${value}`); + } - // Port specification - if (!sshConfig.useSshConfig || sshConfig.port !== 22) { - sshArgs.push('-p', sshConfig.port.toString()); - } + // Port specification + if (!sshConfig.useSshConfig || sshConfig.port !== 22) { + sshArgs.push('-p', sshConfig.port.toString()); + } - // Build destination (user@host or just host for SSH config) - if (sshConfig.useSshConfig) { - if (sshConfig.username && sshConfig.username.trim()) { - sshArgs.push(`${sshConfig.username}@${sshConfig.host}`); - } else { - sshArgs.push(sshConfig.host); - } - } else { + // Build destination (user@host or just host for SSH config) + if (sshConfig.useSshConfig) { + if (sshConfig.username && sshConfig.username.trim()) { sshArgs.push(`${sshConfig.username}@${sshConfig.host}`); - } - - // Determine the working directory on the remote - const remoteCwd = cwd || '~'; - - // Merge environment variables: SSH config's remoteEnv + shell env vars - const mergedEnv: Record = { - ...(sshConfig.remoteEnv || {}), - ...(shellEnvVars || {}), - }; - - // Build the remote command with cd and env vars - const envExports = Object.entries(mergedEnv) - .filter(([key]) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) - .map(([key, value]) => `${key}='${value.replace(/'/g, "'\\''")}'`) - .join(' '); - - // Escape the user's command for the remote shell - const escapedCommand = shellEscapeForDoubleQuotes(command); - let remoteCommand: string; - if (envExports) { - remoteCommand = `cd '${remoteCwd.replace(/'/g, "'\\''")}' && ${envExports} $SHELL -lc "${escapedCommand}"`; } else { - remoteCommand = `cd '${remoteCwd.replace(/'/g, "'\\''")}' && $SHELL -lc "${escapedCommand}"`; + sshArgs.push(sshConfig.host); } + } else { + sshArgs.push(`${sshConfig.username}@${sshConfig.host}`); + } - // Wrap the entire thing for SSH - const wrappedForSsh = `$SHELL -c "${shellEscapeForDoubleQuotes(remoteCommand)}"`; - sshArgs.push(wrappedForSsh); + // Determine the working directory on the remote + const remoteCwd = cwd || '~'; - logger.info('[ProcessManager] runCommandViaSsh spawning', 'ProcessManager', { - sessionId, - sshHost: sshConfig.host, - remoteCwd, - command, - fullSshCommand: `ssh ${sshArgs.join(' ')}`, - }); + // Merge environment variables: SSH config's remoteEnv + shell env vars + const mergedEnv: Record = { + ...(sshConfig.remoteEnv || {}), + ...(shellEnvVars || {}), + }; - // Spawn the SSH process - const sshPath = await resolveSshPath(); - const expandedEnv = getExpandedEnv(); + // Build the remote command with cd and env vars + const envExports = Object.entries(mergedEnv) + .filter(([key]) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) + .map(([key, value]) => `${key}='${value.replace(/'/g, "'\\''")}'`) + .join(' '); + + // Escape the user's command for the remote shell + const escapedCommand = shellEscapeForDoubleQuotes(command); + let remoteCommand: string; + if (envExports) { + remoteCommand = `cd '${remoteCwd.replace(/'/g, "'\\''")}' && ${envExports} $SHELL -lc "${escapedCommand}"`; + } else { + remoteCommand = `cd '${remoteCwd.replace(/'/g, "'\\''")}' && $SHELL -lc "${escapedCommand}"`; + } + + // Wrap the entire thing for SSH + const wrappedForSsh = `$SHELL -c "${shellEscapeForDoubleQuotes(remoteCommand)}"`; + sshArgs.push(wrappedForSsh); + + logger.info('[ProcessManager] runCommandViaSsh spawning', 'ProcessManager', { + sessionId, + sshHost: sshConfig.host, + remoteCwd, + command, + fullSshCommand: `ssh ${sshArgs.join(' ')}`, + }); + + // Resolve SSH path before entering the Promise + const sshPath = await resolveSshPath(); + const expandedEnv = getExpandedEnv(); + + return new Promise((resolve) => { const childProcess = spawn(sshPath, sshArgs, { env: { ...expandedEnv,