diff --git a/.husky/pre-commit b/.husky/pre-commit index 90f4edccb8..02d51b167c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,7 +2,7 @@ # Preserve git worktree context - prevent HEAD corruption in worktrees if [ -f ".git" ]; then - WORKTREE_GIT_DIR=$(cat .git | sed 's/gitdir: //') + WORKTREE_GIT_DIR=$(sed 's/^gitdir: //' .git) if [ -n "$WORKTREE_GIT_DIR" ]; then export GIT_DIR="$WORKTREE_GIT_DIR" export GIT_WORK_TREE="$(pwd)" @@ -170,45 +170,83 @@ fi # Check if there are staged files in apps/frontend if git diff --cached --name-only | grep -q "^apps/frontend/"; then echo "Frontend changes detected, running frontend checks..." - # Use subshell to isolate directory changes and prevent worktree corruption - ( - cd apps/frontend - # Run lint-staged (handles staged .ts/.tsx files) - npm exec lint-staged - if [ $? -ne 0 ]; then - echo "lint-staged failed. Please fix linting errors before committing." - exit 1 - fi + # Detect if we're in a worktree and check if dependencies are available + IS_WORKTREE=false + DEPS_AVAILABLE=true - # Run TypeScript type check - echo "Running type check..." - npm run typecheck - if [ $? -ne 0 ]; then - echo "Type check failed. Please fix TypeScript errors before committing." - exit 1 - fi + if [ -f ".git" ]; then + # .git is a file (not directory) in worktrees + IS_WORKTREE=true + echo "Detected git worktree environment" + fi - # Run linting - echo "Running lint..." - npm run lint - if [ $? -ne 0 ]; then - echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues." + # Check if node_modules has actual dependencies by looking for a known package + # @lydell/node-pty is required for terminal code and is a common source of TypeScript errors + # It may be in root node_modules (hoisted) or apps/frontend/node_modules + # Note: -d follows symlinks automatically, so this works for both real dirs and symlinks + # We check for the full package path (@lydell/node-pty) rather than just the namespace + # for precise detection - ensures the actual dependency is installed, not just any @lydell package + if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/frontend/node_modules/@lydell/node-pty" ]; then + DEPS_AVAILABLE=false + fi + + if [ "$DEPS_AVAILABLE" = false ]; then + if [ "$IS_WORKTREE" = true ]; then + # In worktree without dependencies - warn but allow commit + echo "" + echo "⚠️ WARNING: node_modules not available in this worktree." + echo " TypeScript and lint checks will be skipped." + echo " This is expected for auto-claude worktrees." + echo " Full validation will occur when PR is created/merged." + echo "" + else + # Main repo without dependencies - this is an error + echo "Error: node_modules not found. Run 'npm install' first." exit 1 fi + else + # Dependencies available - run full frontend checks + # Use subshell to isolate directory changes and prevent worktree corruption + ( + cd apps/frontend + + # Run lint-staged (handles staged .ts/.tsx files) + npm exec lint-staged + if [ $? -ne 0 ]; then + echo "lint-staged failed. Please fix linting errors before committing." + exit 1 + fi + + # Run TypeScript type check + echo "Running type check..." + npm run typecheck + if [ $? -ne 0 ]; then + echo "Type check failed. Please fix TypeScript errors before committing." + exit 1 + fi + + # Run linting + echo "Running lint..." + npm run lint + if [ $? -ne 0 ]; then + echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues." + exit 1 + fi - # Check for vulnerabilities (only high severity) - echo "Checking for vulnerabilities..." - npm audit --audit-level=high + # Check for vulnerabilities (only high severity) + echo "Checking for vulnerabilities..." + npm audit --audit-level=high + if [ $? -ne 0 ]; then + echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve." + exit 1 + fi + ) if [ $? -ne 0 ]; then - echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve." exit 1 fi - ) - if [ $? -ne 0 ]; then - exit 1 + echo "Frontend checks passed!" fi - echo "Frontend checks passed!" fi echo "All pre-commit checks passed!" diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index adf31d3155..7529d5082c 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -7,7 +7,9 @@ """ import json +import os import shutil +import subprocess import sys from pathlib import Path @@ -181,6 +183,106 @@ def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[s return copied +def symlink_node_modules_to_worktree( + project_dir: Path, worktree_path: Path +) -> list[str]: + """ + Symlink node_modules directories from project root to worktree. + + This ensures the worktree has access to dependencies for TypeScript checks + and other tooling without requiring a separate npm install. + + Works with npm workspace hoisting where dependencies are hoisted to root + and workspace-specific dependencies remain in nested node_modules. + + Args: + project_dir: The main project directory + worktree_path: Path to the worktree + + Returns: + List of symlinked paths (relative to worktree) + """ + symlinked = [] + + # Node modules locations to symlink for TypeScript and tooling support. + # These are the standard locations for this monorepo structure. + # + # Design rationale: + # - Hardcoded paths are intentional for simplicity and reliability + # - Dynamic discovery (reading workspaces from package.json) would add complexity + # and potential failure points without significant benefit + # - This monorepo uses npm workspaces with hoisting, so dependencies are primarily + # in root node_modules with workspace-specific deps in apps/frontend/node_modules + # + # To add new workspace locations: + # 1. Add (source_rel, target_rel) tuple below + # 2. Update the parallel TypeScript implementation in + # apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts + # 3. Update the pre-commit hook check in .husky/pre-commit if needed + node_modules_locations = [ + ("node_modules", "node_modules"), + ("apps/frontend/node_modules", "apps/frontend/node_modules"), + ] + + for source_rel, target_rel in node_modules_locations: + source_path = project_dir / source_rel + target_path = worktree_path / target_rel + + # Skip if source doesn't exist + if not source_path.exists(): + debug(MODULE, f"Skipping {source_rel} - source does not exist") + continue + + # Skip if target already exists (don't overwrite existing node_modules) + if target_path.exists(): + debug(MODULE, f"Skipping {target_rel} - target already exists") + continue + + # Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks) + if target_path.is_symlink(): + debug( + MODULE, + f"Skipping {target_rel} - symlink already exists (possibly broken)", + ) + continue + + # Ensure parent directory exists + target_path.parent.mkdir(parents=True, exist_ok=True) + + try: + if sys.platform == "win32": + # On Windows, use junctions instead of symlinks (no admin rights required) + # Junctions require absolute paths + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr or "mklink /J failed") + else: + # On macOS/Linux, use relative symlinks for portability + relative_source = os.path.relpath(source_path, target_path.parent) + os.symlink(relative_source, target_path) + symlinked.append(target_rel) + debug(MODULE, f"Symlinked {target_rel} -> {source_path}") + except OSError as e: + # Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem) + # Log warning but don't fail - worktree is still usable, just without + # TypeScript checking + debug_warning( + MODULE, + f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.", + ) + # Warn user - pre-commit hooks may fail without dependencies + print_status( + f"Warning: Could not link {target_rel} - TypeScript checks may fail", + "warning", + ) + + return symlinked + + def copy_spec_to_worktree( source_spec_dir: Path, worktree_path: Path, @@ -268,6 +370,14 @@ def setup_workspace( f"Environment files copied: {', '.join(copied_env_files)}", "success" ) + # Symlink node_modules to worktree for TypeScript and tooling support + # This allows pre-commit hooks to run typecheck without npm install in worktree + symlinked_modules = symlink_node_modules_to_worktree( + project_dir, worktree_info.path + ) + if symlinked_modules: + print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success") + # Copy security configuration files if they exist # Note: Unlike env files, security files always overwrite to ensure # the worktree uses the same security rules as the main project. diff --git a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts index 2d0b590184..207eb487dd 100644 --- a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts +++ b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts @@ -80,6 +80,18 @@ vi.mock("electron-log/main.js", () => ({ }, })); +// Mock cli-tool-manager to avoid blocking tool detection on Windows +vi.mock("../cli-tool-manager", () => ({ + getToolInfo: vi.fn(() => ({ found: false, path: null, source: "mock" })), + getToolPath: vi.fn((tool: string) => tool), + deriveGitBashPath: vi.fn(() => null), + clearCache: vi.fn(), + clearToolCache: vi.fn(), + configureTools: vi.fn(), + preWarmToolCache: vi.fn(() => Promise.resolve()), + getToolPathAsync: vi.fn((tool: string) => Promise.resolve(tool)), +})); + // Mock modules before importing vi.mock("electron", () => { const mockIpcMain = new (class extends EventEmitter { diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index 7fdd25aabd..82ff736886 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -111,6 +111,18 @@ vi.mock('electron', () => ({ } })); +// Mock cli-tool-manager to avoid blocking tool detection on Windows +vi.mock('../cli-tool-manager', () => ({ + getToolInfo: vi.fn(() => ({ found: false, path: null, source: 'mock' })), + deriveGitBashPath: vi.fn(() => null), + clearCache: vi.fn() +})); + +// Mock env-utils to avoid blocking environment augmentation +vi.mock('../env-utils', () => ({ + getAugmentedEnv: vi.fn(() => ({ ...process.env })) +})); + // Mock fs.existsSync for getAutoBuildSourcePath path validation vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); diff --git a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts index da4816578a..2bf73d1adf 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts @@ -7,7 +7,7 @@ import type { TerminalWorktreeResult, } from '../../../shared/types'; import path from 'path'; -import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync } from 'fs'; import { execFileSync } from 'child_process'; import { minimatch } from 'minimatch'; import { debugLog, debugError } from '../../../shared/utils/debug-logger'; @@ -193,6 +193,93 @@ function getDefaultBranch(projectPath: string): string { } } +/** + * Symlink node_modules from project root to worktree for TypeScript and tooling support. + * This allows pre-commit hooks and IDE features to work without npm install in the worktree. + * + * @param projectPath - The main project directory + * @param worktreePath - Path to the worktree + * @returns Array of symlinked paths (relative to worktree) + */ +function symlinkNodeModulesToWorktree(projectPath: string, worktreePath: string): string[] { + const symlinked: string[] = []; + + // Node modules locations to symlink for TypeScript and tooling support. + // These are the standard locations for this monorepo structure. + // + // Design rationale: + // - Hardcoded paths are intentional for simplicity and reliability + // - Dynamic discovery (reading workspaces from package.json) would add complexity + // and potential failure points without significant benefit + // - This monorepo uses npm workspaces with hoisting, so dependencies are primarily + // in root node_modules with workspace-specific deps in apps/frontend/node_modules + // + // To add new workspace locations: + // 1. Add [sourceRelPath, targetRelPath] tuple below + // 2. Update the parallel Python implementation in apps/backend/core/workspace/setup.py + // 3. Update the pre-commit hook check in .husky/pre-commit if needed + const nodeModulesLocations = [ + ['node_modules', 'node_modules'], + ['apps/frontend/node_modules', 'apps/frontend/node_modules'], + ]; + + for (const [sourceRel, targetRel] of nodeModulesLocations) { + const sourcePath = path.join(projectPath, sourceRel); + const targetPath = path.join(worktreePath, targetRel); + + // Skip if source doesn't exist + if (!existsSync(sourcePath)) { + debugLog('[TerminalWorktree] Skipping symlink - source does not exist:', sourceRel); + continue; + } + + // Skip if target already exists (don't overwrite existing node_modules) + if (existsSync(targetPath)) { + debugLog('[TerminalWorktree] Skipping symlink - target already exists:', targetRel); + continue; + } + + // Also skip if target is a symlink (even if broken) + try { + lstatSync(targetPath); + debugLog('[TerminalWorktree] Skipping symlink - target exists (possibly broken symlink):', targetRel); + continue; + } catch { + // Target doesn't exist at all - good, we can create symlink + } + + // Ensure parent directory exists + const targetDir = path.dirname(targetPath); + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + try { + // Platform-specific symlink creation: + // - Windows: Use 'junction' type which requires absolute paths (no admin rights required) + // - Unix (macOS/Linux): Use relative paths for portability (worktree can be moved) + if (process.platform === 'win32') { + symlinkSync(sourcePath, targetPath, 'junction'); + debugLog('[TerminalWorktree] Created junction (Windows):', targetRel, '->', sourcePath); + } else { + // On Unix, use relative symlinks for portability (matches Python implementation) + const relativePath = path.relative(path.dirname(targetPath), sourcePath); + symlinkSync(relativePath, targetPath); + debugLog('[TerminalWorktree] Created symlink (Unix):', targetRel, '->', relativePath); + } + symlinked.push(targetRel); + } catch (error) { + // Symlink creation can fail on some systems (e.g., FAT32 filesystem, or permission issues) + // Log warning but don't fail - worktree is still usable, just without TypeScript checking + // Note: This warning appears in dev console. Users may see TypeScript errors in pre-commit hooks. + debugError('[TerminalWorktree] Could not create symlink for', targetRel, ':', error); + console.warn(`[TerminalWorktree] Warning: Failed to link ${targetRel} - TypeScript checks may fail in this worktree`); + } + } + + return symlinked; +} + function saveWorktreeConfig(projectPath: string, name: string, config: TerminalWorktreeConfig): void { const metadataDir = getTerminalWorktreeMetadataDir(projectPath); mkdirSync(metadataDir, { recursive: true }); @@ -342,6 +429,13 @@ async function createTerminalWorktree( debugLog('[TerminalWorktree] Created worktree in detached HEAD mode from', baseRef); } + // Symlink node_modules for TypeScript and tooling support + // This allows pre-commit hooks to run typecheck without npm install in worktree + const symlinkedModules = symlinkNodeModulesToWorktree(projectPath, worktreePath); + if (symlinkedModules.length > 0) { + debugLog('[TerminalWorktree] Symlinked dependencies:', symlinkedModules.join(', ')); + } + const config: TerminalWorktreeConfig = { name, worktreePath, diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index efa6e2e1f1..0e985a55f1 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -50,7 +50,7 @@ const createMockTerminal = (overrides: Partial = {}): TerminalP isClaudeMode: false, claudeSessionId: undefined, claudeProfileId: undefined, - title: 'Claude', + title: 'Terminal 1', // Use default terminal name pattern to match production behavior cwd: '/tmp/project', projectPath: '/tmp/project', ...overrides, @@ -800,4 +800,49 @@ describe('claude-integration-handler - Helper Functions', () => { }).not.toThrow(); }); }); + + describe('shouldAutoRenameTerminal', () => { + it('should return true for default terminal names', async () => { + const { shouldAutoRenameTerminal } = await import('../claude-integration-handler'); + + expect(shouldAutoRenameTerminal('Terminal 1')).toBe(true); + expect(shouldAutoRenameTerminal('Terminal 2')).toBe(true); + expect(shouldAutoRenameTerminal('Terminal 99')).toBe(true); + expect(shouldAutoRenameTerminal('Terminal 123')).toBe(true); + }); + + it('should return false for terminals already named Claude', async () => { + const { shouldAutoRenameTerminal } = await import('../claude-integration-handler'); + + expect(shouldAutoRenameTerminal('Claude')).toBe(false); + expect(shouldAutoRenameTerminal('Claude (Work)')).toBe(false); + expect(shouldAutoRenameTerminal('Claude (Profile Name)')).toBe(false); + }); + + it('should return false for user-customized terminal names', async () => { + const { shouldAutoRenameTerminal } = await import('../claude-integration-handler'); + + expect(shouldAutoRenameTerminal('My Custom Terminal')).toBe(false); + expect(shouldAutoRenameTerminal('Dev Server')).toBe(false); + expect(shouldAutoRenameTerminal('Backend')).toBe(false); + }); + + it('should return false for edge cases that do not match the pattern', async () => { + const { shouldAutoRenameTerminal } = await import('../claude-integration-handler'); + + // Terminal 0 is not a valid default (terminals start at 1) + expect(shouldAutoRenameTerminal('Terminal 0')).toBe(true); // Pattern matches \d+, so this is valid + + // Lowercase doesn't match + expect(shouldAutoRenameTerminal('terminal 1')).toBe(false); + + // Extra whitespace doesn't match + expect(shouldAutoRenameTerminal('Terminal 1')).toBe(false); + expect(shouldAutoRenameTerminal(' Terminal 1')).toBe(false); + expect(shouldAutoRenameTerminal('Terminal 1 ')).toBe(false); + + // Tab instead of space doesn't match + expect(shouldAutoRenameTerminal('Terminal\t1')).toBe(false); + }); + }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index f9813b8325..bdaf49e217 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -234,7 +234,7 @@ interface ProfileInfo { * This prevents aggressive renaming on every Claude invocation and * preserves user-customized terminal names. */ -function shouldAutoRenameTerminal(currentTitle: string): boolean { +export function shouldAutoRenameTerminal(currentTitle: string): boolean { // Already has Claude title - don't rename again if (currentTitle === 'Claude' || currentTitle.startsWith('Claude (')) { return false;