Add pre-commit hooks with husky and lint staged

This commit is contained in:
Raza Rauf
2026-01-19 22:15:39 +05:00
parent 2702a7c63a
commit dd04429482
9 changed files with 194 additions and 73 deletions

24
.editorconfig Normal file
View File

@@ -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

6
.gitignore vendored
View File

@@ -33,7 +33,11 @@ scratch/
Thumbs.db Thumbs.db
# IDE # 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/ .idea/
*.swp *.swp
*.swo *.swo

4
.husky/pre-commit Normal file
View File

@@ -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

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig"
]
}

47
.vscode/settings.json vendored Normal file
View File

@@ -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
}

View File

@@ -3,6 +3,7 @@ import eslint from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react'; import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks'; import reactHooksPlugin from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier';
import globals from 'globals'; import globals from 'globals';
export default tseslint.config( export default tseslint.config(
@@ -28,6 +29,9 @@ export default tseslint.config(
// TypeScript ESLint recommended rules // TypeScript ESLint recommended rules
...tseslint.configs.recommended, ...tseslint.configs.recommended,
// Prettier config - disables ESLint rules that conflict with Prettier
prettierConfig,
// Main configuration for all TypeScript files // Main configuration for all TypeScript files
{ {
files: ['src/**/*.ts', 'src/**/*.tsx'], files: ['src/**/*.ts', 'src/**/*.tsx'],

17
package-lock.json generated
View File

@@ -87,6 +87,7 @@
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0", "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": { "node_modules/eslint-plugin-react": {
"version": "7.37.5", "version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",

View File

@@ -35,6 +35,7 @@
"package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux", "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux",
"start": "electron .", "start": "electron .",
"clean": "rm -rf dist release node_modules/.vite", "clean": "rm -rf dist release node_modules/.vite",
"prepare": "husky",
"postinstall": "electron-rebuild -f -w node-pty,better-sqlite3", "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": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit",
"lint:eslint": "eslint src/", "lint:eslint": "eslint src/",
@@ -281,10 +282,13 @@
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",
"esbuild": "^0.24.2", "esbuild": "^0.24.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"husky": "^9.1.7",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
"lint-staged": "^16.2.7",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"postcss": "^8.4.33", "postcss": "^8.4.33",
@@ -300,5 +304,14 @@
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"prettier --write",
"eslint --fix"
],
"src/**/*.{json,css,md}": [
"prettier --write"
]
} }
} }

View File

@@ -26,91 +26,92 @@ export class SshCommandRunner {
sshConfig: SshRemoteConfig, sshConfig: SshRemoteConfig,
shellEnvVars?: Record<string, string> shellEnvVars?: Record<string, string>
): Promise<CommandResult> { ): Promise<CommandResult> {
return new Promise(async (resolve) => { // Build SSH arguments
// Build SSH arguments const sshArgs: string[] = [];
const sshArgs: string[] = [];
// Force disable TTY allocation // Force disable TTY allocation
sshArgs.push('-T'); sshArgs.push('-T');
// Add identity file // Add identity file
if (sshConfig.useSshConfig) { if (sshConfig.useSshConfig) {
// Only specify identity file if explicitly provided (override SSH config) // Only specify identity file if explicitly provided (override SSH config)
if (sshConfig.privateKeyPath && sshConfig.privateKeyPath.trim()) { if (sshConfig.privateKeyPath && sshConfig.privateKeyPath.trim()) {
sshArgs.push('-i', expandTilde(sshConfig.privateKeyPath));
}
} else {
// Direct connection: require private key
sshArgs.push('-i', expandTilde(sshConfig.privateKeyPath)); 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 // Default SSH options for non-interactive operation
const sshOptions: Record<string, string> = { const sshOptions: Record<string, string> = {
BatchMode: 'yes', BatchMode: 'yes',
StrictHostKeyChecking: 'accept-new', StrictHostKeyChecking: 'accept-new',
ConnectTimeout: '10', ConnectTimeout: '10',
ClearAllForwardings: 'yes', ClearAllForwardings: 'yes',
RequestTTY: 'no', RequestTTY: 'no',
}; };
for (const [key, value] of Object.entries(sshOptions)) { for (const [key, value] of Object.entries(sshOptions)) {
sshArgs.push('-o', `${key}=${value}`); sshArgs.push('-o', `${key}=${value}`);
} }
// Port specification // Port specification
if (!sshConfig.useSshConfig || sshConfig.port !== 22) { if (!sshConfig.useSshConfig || sshConfig.port !== 22) {
sshArgs.push('-p', sshConfig.port.toString()); sshArgs.push('-p', sshConfig.port.toString());
} }
// Build destination (user@host or just host for SSH config) // Build destination (user@host or just host for SSH config)
if (sshConfig.useSshConfig) { if (sshConfig.useSshConfig) {
if (sshConfig.username && sshConfig.username.trim()) { if (sshConfig.username && sshConfig.username.trim()) {
sshArgs.push(`${sshConfig.username}@${sshConfig.host}`);
} else {
sshArgs.push(sshConfig.host);
}
} else {
sshArgs.push(`${sshConfig.username}@${sshConfig.host}`); 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<string, string> = {
...(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 { } 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 // Determine the working directory on the remote
const wrappedForSsh = `$SHELL -c "${shellEscapeForDoubleQuotes(remoteCommand)}"`; const remoteCwd = cwd || '~';
sshArgs.push(wrappedForSsh);
logger.info('[ProcessManager] runCommandViaSsh spawning', 'ProcessManager', { // Merge environment variables: SSH config's remoteEnv + shell env vars
sessionId, const mergedEnv: Record<string, string> = {
sshHost: sshConfig.host, ...(sshConfig.remoteEnv || {}),
remoteCwd, ...(shellEnvVars || {}),
command, };
fullSshCommand: `ssh ${sshArgs.join(' ')}`,
});
// Spawn the SSH process // Build the remote command with cd and env vars
const sshPath = await resolveSshPath(); const envExports = Object.entries(mergedEnv)
const expandedEnv = getExpandedEnv(); .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, { const childProcess = spawn(sshPath, sshArgs, {
env: { env: {
...expandedEnv, ...expandedEnv,