diff --git a/apps/frontend/src/main/changelog/changelog-service.ts b/apps/frontend/src/main/changelog/changelog-service.ts index 1ea95740..5c0fbd64 100644 --- a/apps/frontend/src/main/changelog/changelog-service.ts +++ b/apps/frontend/src/main/changelog/changelog-service.ts @@ -146,6 +146,11 @@ export class ChangelogService extends EventEmitter { } const possiblePaths = [ + // New apps structure: from out/main -> apps/backend + path.resolve(__dirname, '..', '..', '..', 'backend'), + path.resolve(app.getAppPath(), '..', 'backend'), + path.resolve(process.cwd(), 'apps', 'backend'), + // Legacy paths for backwards compatibility path.resolve(__dirname, '..', '..', '..', 'auto-claude'), path.resolve(app.getAppPath(), '..', 'auto-claude'), path.resolve(process.cwd(), 'auto-claude') diff --git a/apps/frontend/src/main/config-paths.ts b/apps/frontend/src/main/config-paths.ts new file mode 100644 index 00000000..bf17dbf3 --- /dev/null +++ b/apps/frontend/src/main/config-paths.ts @@ -0,0 +1,126 @@ +/** + * Configuration Paths Module + * + * Provides XDG Base Directory Specification compliant paths for storing + * application configuration and data. This is essential for AppImage, + * Flatpak, and Snap installations where the application runs in a + * sandboxed or immutable filesystem environment. + * + * XDG Base Directory Specification: + * - $XDG_CONFIG_HOME: User configuration (default: ~/.config) + * - $XDG_DATA_HOME: User data (default: ~/.local/share) + * - $XDG_CACHE_HOME: User cache (default: ~/.cache) + * + * @see https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + */ + +import * as path from 'path'; +import * as os from 'os'; + +const APP_NAME = 'auto-claude'; + +/** + * Get the XDG config home directory + * Uses $XDG_CONFIG_HOME if set, otherwise defaults to ~/.config + */ +export function getXdgConfigHome(): string { + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); +} + +/** + * Get the XDG data home directory + * Uses $XDG_DATA_HOME if set, otherwise defaults to ~/.local/share + */ +export function getXdgDataHome(): string { + return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); +} + +/** + * Get the XDG cache home directory + * Uses $XDG_CACHE_HOME if set, otherwise defaults to ~/.cache + */ +export function getXdgCacheHome(): string { + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); +} + +/** + * Get the application config directory + * Returns the XDG-compliant path for storing configuration files + */ +export function getAppConfigDir(): string { + return path.join(getXdgConfigHome(), APP_NAME); +} + +/** + * Get the application data directory + * Returns the XDG-compliant path for storing application data + */ +export function getAppDataDir(): string { + return path.join(getXdgDataHome(), APP_NAME); +} + +/** + * Get the application cache directory + * Returns the XDG-compliant path for storing cache files + */ +export function getAppCacheDir(): string { + return path.join(getXdgCacheHome(), APP_NAME); +} + +/** + * Get the memories storage directory + * This is where graph databases are stored (previously ~/.auto-claude/memories) + */ +export function getMemoriesDir(): string { + // For compatibility, we still support the legacy path + const legacyPath = path.join(os.homedir(), '.auto-claude', 'memories'); + + // On Linux with XDG variables set (AppImage, Flatpak, Snap), use XDG path + if (process.platform === 'linux' && (process.env.XDG_DATA_HOME || process.env.APPIMAGE || process.env.SNAP || process.env.FLATPAK_ID)) { + return path.join(getXdgDataHome(), APP_NAME, 'memories'); + } + + // Default to legacy path for backwards compatibility + return legacyPath; +} + +/** + * Get the graphs storage directory (alias for memories) + */ +export function getGraphsDir(): string { + return getMemoriesDir(); +} + +/** + * Check if running in an immutable filesystem environment + * (AppImage, Flatpak, Snap, etc.) + */ +export function isImmutableEnvironment(): boolean { + return !!( + process.env.APPIMAGE || + process.env.SNAP || + process.env.FLATPAK_ID + ); +} + +/** + * Get environment-appropriate path for a given type + * Handles the differences between regular installs and sandboxed environments + * + * @param type - The type of path needed: 'config', 'data', 'cache', 'memories' + * @returns The appropriate path for the current environment + */ +export function getAppPath(type: 'config' | 'data' | 'cache' | 'memories'): string { + switch (type) { + case 'config': + return getAppConfigDir(); + case 'data': + return getAppDataDir(); + case 'cache': + return getAppCacheDir(); + case 'memories': + return getMemoriesDir(); + default: + return getAppDataDir(); + } +} diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts new file mode 100644 index 00000000..7aeaca2b --- /dev/null +++ b/apps/frontend/src/main/env-utils.ts @@ -0,0 +1,133 @@ +/** + * Environment Utilities Module + * + * Provides utilities for managing environment variables for child processes. + * Particularly important for macOS where GUI apps don't inherit the full + * shell environment, causing issues with tools installed via Homebrew. + * + * Common issue: `gh` CLI installed via Homebrew is in /opt/homebrew/bin + * which isn't in PATH when the Electron app launches from Finder/Dock. + */ + +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Common binary directories that should be in PATH + * These are locations where commonly used tools are installed + */ +const COMMON_BIN_PATHS: Record = { + darwin: [ + '/opt/homebrew/bin', // Apple Silicon Homebrew + '/usr/local/bin', // Intel Homebrew / system + '/opt/homebrew/sbin', // Apple Silicon Homebrew sbin + '/usr/local/sbin', // Intel Homebrew sbin + ], + linux: [ + '/usr/local/bin', + '/snap/bin', // Snap packages + '~/.local/bin', // User-local binaries + ], + win32: [ + // Windows usually handles PATH better, but we can add common locations + 'C:\\Program Files\\Git\\cmd', + 'C:\\Program Files\\GitHub CLI', + ], +}; + +/** + * Get augmented environment with additional PATH entries + * + * This ensures that tools installed in common locations (like Homebrew) + * are available to child processes even when the app is launched from + * Finder/Dock which doesn't inherit the full shell environment. + * + * @param additionalPaths - Optional array of additional paths to include + * @returns Environment object with augmented PATH + */ +export function getAugmentedEnv(additionalPaths?: string[]): Record { + const env = { ...process.env } as Record; + const platform = process.platform as 'darwin' | 'linux' | 'win32'; + const pathSeparator = platform === 'win32' ? ';' : ':'; + + // Get platform-specific paths + const platformPaths = COMMON_BIN_PATHS[platform] || []; + + // Expand home directory in paths + const homeDir = os.homedir(); + const expandedPaths = platformPaths.map(p => + p.startsWith('~') ? p.replace('~', homeDir) : p + ); + + // Collect paths to add (only if they exist and aren't already in PATH) + const currentPath = env.PATH || ''; + const currentPathSet = new Set(currentPath.split(pathSeparator)); + + const pathsToAdd: string[] = []; + + // Add platform-specific paths + for (const p of expandedPaths) { + if (!currentPathSet.has(p) && fs.existsSync(p)) { + pathsToAdd.push(p); + } + } + + // Add user-requested additional paths + if (additionalPaths) { + for (const p of additionalPaths) { + const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p; + if (!currentPathSet.has(expanded) && fs.existsSync(expanded)) { + pathsToAdd.push(expanded); + } + } + } + + // Prepend new paths to PATH (prepend so they take priority) + if (pathsToAdd.length > 0) { + env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator); + } + + return env; +} + +/** + * Find the full path to an executable + * + * Searches PATH (including augmented paths) for the given command. + * Useful for finding tools like `gh`, `git`, `node`, etc. + * + * @param command - The command name to find (e.g., 'gh', 'git') + * @returns The full path to the executable, or null if not found + */ +export function findExecutable(command: string): string | null { + const env = getAugmentedEnv(); + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const pathDirs = (env.PATH || '').split(pathSeparator); + + // On Windows, also check with common extensions + const extensions = process.platform === 'win32' + ? ['', '.exe', '.cmd', '.bat', '.ps1'] + : ['']; + + for (const dir of pathDirs) { + for (const ext of extensions) { + const fullPath = path.join(dir, command + ext); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + } + + return null; +} + +/** + * Check if a command is available (in PATH or common locations) + * + * @param command - The command name to check + * @returns true if the command is available + */ +export function isCommandAvailable(command: string): boolean { + return findExecutable(command) !== null; +} diff --git a/apps/frontend/src/main/fs-utils.ts b/apps/frontend/src/main/fs-utils.ts new file mode 100644 index 00000000..27c249a4 --- /dev/null +++ b/apps/frontend/src/main/fs-utils.ts @@ -0,0 +1,139 @@ +/** + * Filesystem Utilities Module + * + * Provides utility functions for filesystem operations with + * proper support for XDG Base Directory paths and sandboxed + * environments (AppImage, Flatpak, Snap). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getAppPath, isImmutableEnvironment, getMemoriesDir } from './config-paths'; + +/** + * Ensure a directory exists, creating it if necessary + * + * @param dirPath - The path to the directory + * @returns true if directory exists or was created, false on error + */ +export function ensureDir(dirPath: string): boolean { + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + return true; + } catch (error) { + console.error(`[fs-utils] Failed to create directory ${dirPath}:`, error); + return false; + } +} + +/** + * Ensure the application data directories exist + * Creates config, data, cache, and memories directories + */ +export function ensureAppDirectories(): void { + const dirs = [ + getAppPath('config'), + getAppPath('data'), + getAppPath('cache'), + getMemoriesDir(), + ]; + + for (const dir of dirs) { + ensureDir(dir); + } +} + +/** + * Get a writable path for a file + * If the original path is not writable, falls back to XDG data directory + * + * @param originalPath - The preferred path for the file + * @param filename - The filename (used for fallback path) + * @returns A writable path for the file + */ +export function getWritablePath(originalPath: string, filename: string): string { + // Check if we can write to the original path + const dir = path.dirname(originalPath); + + try { + if (fs.existsSync(dir)) { + // Try to write a test file + const testFile = path.join(dir, `.write-test-${Date.now()}`); + fs.writeFileSync(testFile, ''); + // Cleanup test file - ignore errors (e.g., file locked on Windows) + try { fs.unlinkSync(testFile); } catch { /* ignore cleanup failure */ } + return originalPath; + } else { + // Try to create the directory + fs.mkdirSync(dir, { recursive: true }); + return originalPath; + } + } catch { + // Fall back to XDG data directory + if (isImmutableEnvironment()) { + const fallbackDir = getAppPath('data'); + ensureDir(fallbackDir); + console.warn(`[fs-utils] Falling back to XDG path for ${filename}: ${fallbackDir}`); + return path.join(fallbackDir, filename); + } + // Non-immutable environment - just return original and let caller handle error + return originalPath; + } +} + +/** + * Safe write file that handles immutable filesystems + * Falls back to XDG paths if the target is not writable + * + * @param filePath - The target file path + * @param content - The content to write + * @returns The actual path where the file was written + * @throws Error if write fails (with context about the attempted path) + */ +export function safeWriteFile(filePath: string, content: string): string { + const filename = path.basename(filePath); + const writablePath = getWritablePath(filePath, filename); + + try { + fs.writeFileSync(writablePath, content, 'utf-8'); + return writablePath; + } catch (error) { + console.error(`[fs-utils] Failed to write file ${writablePath}:`, error); + throw error; + } +} + +/** + * Read a file, checking both original and XDG fallback locations + * + * @param originalPath - The expected file path + * @returns The file content or null if not found or on error + */ +export function safeReadFile(originalPath: string): string | null { + // Try original path first + try { + if (fs.existsSync(originalPath)) { + return fs.readFileSync(originalPath, 'utf-8'); + } + } catch (error) { + console.error(`[fs-utils] Failed to read file ${originalPath}:`, error); + // Fall through to try XDG fallback + } + + // Try XDG fallback path + if (isImmutableEnvironment()) { + const filename = path.basename(originalPath); + const fallbackPath = path.join(getAppPath('data'), filename); + try { + if (fs.existsSync(fallbackPath)) { + return fs.readFileSync(fallbackPath, 'utf-8'); + } + } catch (error) { + console.error(`[fs-utils] Failed to read fallback file ${fallbackPath}:`, error); + } + } + + return null; +} diff --git a/apps/frontend/src/main/insights/config.ts b/apps/frontend/src/main/insights/config.ts index 576e5ffc..c615b63a 100644 --- a/apps/frontend/src/main/insights/config.ts +++ b/apps/frontend/src/main/insights/config.ts @@ -41,6 +41,11 @@ export class InsightsConfig { } const possiblePaths = [ + // New apps structure: from out/main -> apps/backend + path.resolve(__dirname, '..', '..', '..', 'backend'), + path.resolve(app.getAppPath(), '..', 'backend'), + path.resolve(process.cwd(), 'apps', 'backend'), + // Legacy paths for backwards compatibility path.resolve(__dirname, '..', '..', '..', 'auto-claude'), path.resolve(app.getAppPath(), '..', 'auto-claude'), path.resolve(process.cwd(), 'auto-claude') diff --git a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts index b42def94..23616588 100644 --- a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts @@ -7,6 +7,7 @@ import { ipcMain, shell } from 'electron'; import { execSync, execFileSync, spawn } from 'child_process'; import { IPC_CHANNELS } from '../../../shared/constants'; import type { IPCResult } from '../../../shared/types'; +import { getAugmentedEnv, findExecutable } from '../../env-utils'; // Debug logging helper const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; @@ -100,6 +101,7 @@ function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo { /** * Check if gh CLI is installed + * Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS) */ export function registerCheckGhCli(): void { ipcMain.handle( @@ -107,15 +109,24 @@ export function registerCheckGhCli(): void { async (): Promise> => { debugLog('checkGitHubCli handler called'); try { - const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh'; - debugLog(`Running command: ${checkCmd}`); + // Use findExecutable to check common locations including Homebrew paths + const ghPath = findExecutable('gh'); + if (!ghPath) { + debugLog('gh CLI not found in PATH or common locations'); + return { + success: true, + data: { installed: false } + }; + } + debugLog('gh CLI found at:', ghPath); - const whichResult = execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' }); - debugLog('gh CLI found at:', whichResult.trim()); - - // Get version + // Get version using augmented environment debugLog('Getting gh version...'); - const versionOutput = execSync('gh --version', { encoding: 'utf-8', stdio: 'pipe' }); + const versionOutput = execSync('gh --version', { + encoding: 'utf-8', + stdio: 'pipe', + env: getAugmentedEnv() + }); const version = versionOutput.trim().split('\n')[0]; debugLog('gh version:', version); @@ -136,16 +147,18 @@ export function registerCheckGhCli(): void { /** * Check if user is authenticated with gh CLI + * Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS) */ export function registerCheckGhAuth(): void { ipcMain.handle( IPC_CHANNELS.GITHUB_CHECK_AUTH, async (): Promise> => { debugLog('checkGitHubAuth handler called'); + const env = getAugmentedEnv(); try { // Check auth status debugLog('Running: gh auth status'); - const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' }); + const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe', env }); debugLog('Auth status output:', authStatus); // Get username if authenticated @@ -153,7 +166,8 @@ export function registerCheckGhAuth(): void { debugLog('Getting username via: gh api user --jq .login'); const username = execSync('gh api user --jq .login', { encoding: 'utf-8', - stdio: 'pipe' + stdio: 'pipe', + env }).trim(); debugLog('Username:', username); diff --git a/apps/frontend/src/main/ipc-handlers/github/utils.ts b/apps/frontend/src/main/ipc-handlers/github/utils.ts index 0fb4461e..1885d435 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils.ts @@ -8,15 +8,18 @@ import path from 'path'; import type { Project } from '../../../shared/types'; import { parseEnvFile } from '../utils'; import type { GitHubConfig } from './types'; +import { getAugmentedEnv } from '../../env-utils'; /** * Get GitHub token from gh CLI if available + * Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS) */ function getTokenFromGhCli(): string | null { try { const token = execSync('gh auth token', { encoding: 'utf-8', - stdio: 'pipe' + stdio: 'pipe', + env: getAugmentedEnv() }).trim(); return token || null; } catch { diff --git a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts index 489f0537..9450391d 100644 --- a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts @@ -539,6 +539,10 @@ export function registerMemoryHandlers(): void { // Find the ollama_model_detector.py script const possiblePaths = [ + // New apps structure + path.resolve(__dirname, '..', '..', '..', '..', 'backend', 'ollama_model_detector.py'), + path.resolve(process.cwd(), 'apps', 'backend', 'ollama_model_detector.py'), + // Legacy paths for backwards compatibility path.resolve(__dirname, '..', '..', '..', 'auto-claude', 'ollama_model_detector.py'), path.resolve(process.cwd(), 'auto-claude', 'ollama_model_detector.py'), path.resolve(process.cwd(), '..', 'auto-claude', 'ollama_model_detector.py'), diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 83876fbe..1624b9d6 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -23,13 +23,16 @@ const detectAutoBuildSourcePath = (): string | null => { // Development mode paths if (is.dev) { - // In dev, __dirname is typically auto-claude-ui/out/main - // We need to go up to the project root to find auto-claude/ + // In dev, __dirname is typically apps/frontend/out/main + // We need to go up to find apps/backend possiblePaths.push( - path.resolve(__dirname, '..', '..', '..', 'auto-claude'), // From out/main up 3 levels - path.resolve(__dirname, '..', '..', 'auto-claude'), // From out/main up 2 levels - path.resolve(process.cwd(), 'auto-claude'), // From cwd (project root) - path.resolve(process.cwd(), '..', 'auto-claude') // From cwd parent (if running from auto-claude-ui/) + path.resolve(__dirname, '..', '..', '..', 'backend'), // From out/main -> apps/backend + path.resolve(process.cwd(), 'apps', 'backend'), // From cwd (repo root) + // Legacy paths for backwards compatibility + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), // Legacy: from out/main up 3 levels + path.resolve(__dirname, '..', '..', 'auto-claude'), // Legacy: from out/main up 2 levels + path.resolve(process.cwd(), 'auto-claude'), // Legacy: from cwd (project root) + path.resolve(process.cwd(), '..', 'auto-claude') // Legacy: from cwd parent ); } else { // Production mode paths (packaged app) @@ -37,6 +40,9 @@ const detectAutoBuildSourcePath = (): string | null => { // We check common locations relative to the app bundle const appPath = app.getAppPath(); possiblePaths.push( + path.resolve(appPath, '..', 'backend'), // Sibling to app (new structure) + path.resolve(appPath, '..', '..', 'backend'), // Up 2 from app + // Legacy paths for backwards compatibility path.resolve(appPath, '..', 'auto-claude'), // Sibling to app path.resolve(appPath, '..', '..', 'auto-claude'), // Up 2 from app path.resolve(appPath, '..', '..', '..', 'auto-claude'), // Up 3 from app @@ -46,6 +52,7 @@ const detectAutoBuildSourcePath = (): string | null => { } // Add process.cwd() as last resort on all platforms + possiblePaths.push(path.resolve(process.cwd(), 'apps', 'backend')); possiblePaths.push(path.resolve(process.cwd(), 'auto-claude')); // Enable debug logging with DEBUG=1 diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 9f304c4b..547ea3db 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -10,6 +10,25 @@ import { findTaskAndProject } from './shared'; import { checkGitStatus } from '../../project-initializer'; import { getClaudeProfileManager } from '../../claude-profile-manager'; +/** + * Helper function to check subtask completion status + */ +function checkSubtasksCompletion(plan: Record | null): { + allSubtasks: Array<{ status: string }>; + completedCount: number; + totalCount: number; + allCompleted: boolean; +} { + const allSubtasks = (plan?.phases as Array<{ subtasks?: Array<{ status: string }> }> | undefined)?.flatMap(phase => + phase.subtasks || [] + ) || []; + const completedCount = allSubtasks.filter(s => s.status === 'completed').length; + const totalCount = allSubtasks.length; + const allCompleted = totalCount > 0 && completedCount === totalCount; + + return { allSubtasks, completedCount, totalCount, allCompleted }; +} + /** * Register task execution handlers (start, stop, review, status management, recovery) */ @@ -589,17 +608,9 @@ export function registerTaskExecutionHandlers( if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) { // Analyze subtask statuses to determine appropriate recovery status - const allSubtasks: Array<{ status: string }> = []; - for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string }> }>) { - if (phase.subtasks && Array.isArray(phase.subtasks)) { - allSubtasks.push(...phase.subtasks); - } - } - - if (allSubtasks.length > 0) { - const completedCount = allSubtasks.filter(s => s.status === 'completed').length; - const allCompleted = completedCount === allSubtasks.length; + const { completedCount, totalCount, allCompleted } = checkSubtasksCompletion(plan); + if (totalCount > 0) { if (allCompleted) { // All subtasks completed - should go to review (ai_review or human_review based on source) // For recovery, human_review is safer as it requires manual verification @@ -625,7 +636,30 @@ export function registerTaskExecutionHandlers( // Add recovery note plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`; - // Reset in_progress and failed subtask statuses to 'pending' so they can be retried + // Check if task is actually stuck or just completed and waiting for merge + const { allCompleted } = checkSubtasksCompletion(plan); + + if (allCompleted) { + console.log('[Recovery] Task is fully complete (all subtasks done), setting to human_review without restart'); + // Don't reset any subtasks - task is done! + // Just update status in plan file (project store reads from file, no separate update needed) + plan.status = 'human_review'; + plan.planStatus = 'review'; + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + + return { + success: true, + data: { + taskId, + recovered: true, + newStatus: 'human_review', + message: 'Task is complete and ready for review', + autoRestarted: false + } + }; + } + + // Task is not complete - reset only stuck subtasks for retry // Keep completed subtasks as-is so run.py can resume from where it left off if (plan.phases && Array.isArray(plan.phases)) { for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string; actual_output?: string; started_at?: string; completed_at?: string }> }>) { @@ -634,11 +668,13 @@ export function registerTaskExecutionHandlers( // Reset in_progress subtasks to pending (they were interrupted) // Keep completed subtasks as-is so run.py can resume if (subtask.status === 'in_progress') { + const originalStatus = subtask.status; subtask.status = 'pending'; // Clear execution data to maintain consistency delete subtask.actual_output; delete subtask.started_at; delete subtask.completed_at; + console.log(`[Recovery] Reset stuck subtask: ${originalStatus} -> pending`); } // Also reset failed subtasks so they can be retried if (subtask.status === 'failed') { @@ -647,6 +683,7 @@ export function registerTaskExecutionHandlers( delete subtask.actual_output; delete subtask.started_at; delete subtask.completed_at; + console.log(`[Recovery] Reset failed subtask for retry`); } } } diff --git a/apps/frontend/src/main/memory-service.ts b/apps/frontend/src/main/memory-service.ts index d3f59ea6..70cea47e 100644 --- a/apps/frontend/src/main/memory-service.ts +++ b/apps/frontend/src/main/memory-service.ts @@ -13,6 +13,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { app } from 'electron'; import { findPythonCommand, parsePythonCommand } from './python-detector'; +import { getMemoriesDir } from './config-paths'; import type { MemoryEpisode } from '../shared/types'; interface MemoryServiceConfig { @@ -82,24 +83,26 @@ interface StatusResult { /** * Get the default database path + * Uses XDG-compliant paths on Linux for AppImage/Flatpak/Snap support */ export function getDefaultDbPath(): string { - return path.join(os.homedir(), '.auto-claude', 'memories'); + return getMemoriesDir(); } /** * Get the path to the query_memory.py script */ function getQueryScriptPath(): string | null { - // Look for the script in auto-claude directory (sibling to auto-claude-ui) + // Look for the script in backend directory (new apps structure) const possiblePaths = [ - // Dev mode: from dist/main -> ../../auto-claude + // New apps structure: from dist/main -> apps/backend + path.resolve(__dirname, '..', '..', '..', 'backend', 'query_memory.py'), + path.resolve(app.getAppPath(), '..', 'backend', 'query_memory.py'), + path.resolve(process.cwd(), 'apps', 'backend', 'query_memory.py'), + // Legacy paths for backwards compatibility path.resolve(__dirname, '..', '..', '..', 'auto-claude', 'query_memory.py'), - // Packaged app: from app.getAppPath() (handles asar and resources correctly) path.resolve(app.getAppPath(), '..', 'auto-claude', 'query_memory.py'), - // Alternative: from app root path.resolve(process.cwd(), 'auto-claude', 'query_memory.py'), - // If running from repo root path.resolve(process.cwd(), '..', 'auto-claude', 'query_memory.py'), ]; diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index 05aec1b4..1f0dc9c7 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -245,12 +245,68 @@ export class ProjectStore { } console.warn('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath); - // Get specs directory path + const allTasks: Task[] = []; const specsBaseDir = getSpecsDir(project.autoBuildPath); - const specsDir = path.join(project.path, specsBaseDir); - console.warn('[ProjectStore] specsDir:', specsDir, 'exists:', existsSync(specsDir)); - if (!existsSync(specsDir)) return []; + // 1. Scan main project specs directory + const mainSpecsDir = path.join(project.path, specsBaseDir); + console.warn('[ProjectStore] Main specsDir:', mainSpecsDir, 'exists:', existsSync(mainSpecsDir)); + if (existsSync(mainSpecsDir)) { + const mainTasks = this.loadTasksFromSpecsDir(mainSpecsDir, project.path, 'main', projectId, specsBaseDir); + allTasks.push(...mainTasks); + console.warn('[ProjectStore] Loaded', mainTasks.length, 'tasks from main project'); + } + + // 2. Scan worktree specs directories + const worktreesDir = path.join(project.path, '.worktrees'); + if (existsSync(worktreesDir)) { + try { + const worktrees = readdirSync(worktreesDir, { withFileTypes: true }); + for (const worktree of worktrees) { + if (!worktree.isDirectory()) continue; + + const worktreeSpecsDir = path.join(worktreesDir, worktree.name, specsBaseDir); + if (existsSync(worktreeSpecsDir)) { + const worktreeTasks = this.loadTasksFromSpecsDir( + worktreeSpecsDir, + path.join(worktreesDir, worktree.name), + 'worktree', + projectId, + specsBaseDir + ); + allTasks.push(...worktreeTasks); + console.warn('[ProjectStore] Loaded', worktreeTasks.length, 'tasks from worktree:', worktree.name); + } + } + } catch (error) { + console.error('[ProjectStore] Error scanning worktrees:', error); + } + } + + // 3. Deduplicate tasks by ID (prefer worktree version if exists in both) + const taskMap = new Map(); + for (const task of allTasks) { + const existing = taskMap.get(task.id); + if (!existing || task.location === 'worktree') { + taskMap.set(task.id, task); + } + } + + const tasks = Array.from(taskMap.values()); + console.warn('[ProjectStore] Returning', tasks.length, 'unique tasks (after deduplication)'); + return tasks; + } + + /** + * Load tasks from a specs directory (helper method for main project and worktrees) + */ + private loadTasksFromSpecsDir( + specsDir: string, + basePath: string, + location: 'main' | 'worktree', + projectId: string, + specsBaseDir: string + ): Task[] { const tasks: Task[] = []; let specDirs: Dirent[] = []; @@ -401,6 +457,8 @@ export class ProjectStore { metadata, stagedInMainProject, stagedAt, + location, // Add location metadata (main vs worktree) + specsPath: specPath, // Add full path to specs directory createdAt: new Date(plan?.created_at || Date.now()), updatedAt: new Date(plan?.updated_at || Date.now()) }); @@ -410,7 +468,6 @@ export class ProjectStore { } } - console.warn('[ProjectStore] Returning', tasks.length, 'tasks out of', specDirs.filter(d => d.isDirectory() && d.name !== '.gitkeep').length, 'spec directories'); return tasks; } diff --git a/apps/frontend/src/main/python-detector.ts b/apps/frontend/src/main/python-detector.ts index c157b35b..8f6834a7 100644 --- a/apps/frontend/src/main/python-detector.ts +++ b/apps/frontend/src/main/python-detector.ts @@ -55,13 +55,35 @@ export function getDefaultPythonCommand(): string { * @returns Tuple of [command, baseArgs] ready for use with spawn() */ export function parsePythonCommand(pythonPath: string): [string, string[]] { + // Remove any surrounding quotes first + let cleanPath = pythonPath.trim(); + if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) || + (cleanPath.startsWith("'") && cleanPath.endsWith("'"))) { + cleanPath = cleanPath.slice(1, -1); + } + // If the path points to an actual file, use it directly (handles paths with spaces) - if (existsSync(pythonPath)) { - return [pythonPath, []]; + if (existsSync(cleanPath)) { + return [cleanPath, []]; + } + + // Check if it's a path (contains path separators but not just at the start) + // Paths with spaces should be treated as a single command, not split + const hasPathSeparators = cleanPath.includes('/') || cleanPath.includes('\\'); + const isLikelyPath = hasPathSeparators && !cleanPath.startsWith('-'); + + if (isLikelyPath) { + // This looks like a file path, don't split it + // Even if the file doesn't exist (yet), treat the whole thing as the command + return [cleanPath, []]; } // Otherwise, split on spaces for commands like "py -3" - const parts = pythonPath.split(' '); + const parts = cleanPath.split(' ').filter(p => p.length > 0); + if (parts.length === 0) { + // Return empty string for empty input, not the original uncleaned path + return [cleanPath, []]; + } const command = parts[0]; const baseArgs = parts.slice(1); return [command, baseArgs]; diff --git a/apps/frontend/src/main/terminal-name-generator.ts b/apps/frontend/src/main/terminal-name-generator.ts index fd7a69cc..a2b68c78 100644 --- a/apps/frontend/src/main/terminal-name-generator.ts +++ b/apps/frontend/src/main/terminal-name-generator.ts @@ -47,6 +47,11 @@ export class TerminalNameGenerator extends EventEmitter { } const possiblePaths = [ + // New apps structure: from out/main -> apps/backend + path.resolve(__dirname, '..', '..', '..', 'backend'), + path.resolve(app.getAppPath(), '..', 'backend'), + path.resolve(process.cwd(), 'apps', 'backend'), + // Legacy paths for backwards compatibility path.resolve(__dirname, '..', '..', '..', 'auto-claude'), path.resolve(app.getAppPath(), '..', 'auto-claude'), path.resolve(process.cwd(), 'auto-claude') diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index 359e5364..ca048ccb 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -51,6 +51,11 @@ export class TitleGenerator extends EventEmitter { } const possiblePaths = [ + // New apps structure: from out/main -> apps/backend + path.resolve(__dirname, '..', '..', '..', 'backend'), + path.resolve(app.getAppPath(), '..', 'backend'), + path.resolve(process.cwd(), 'apps', 'backend'), + // Legacy paths for backwards compatibility path.resolve(__dirname, '..', '..', '..', 'auto-claude'), path.resolve(app.getAppPath(), '..', 'auto-claude'), path.resolve(process.cwd(), 'auto-claude') diff --git a/apps/frontend/src/main/updater/config.ts b/apps/frontend/src/main/updater/config.ts index d29664c7..982042a6 100644 --- a/apps/frontend/src/main/updater/config.ts +++ b/apps/frontend/src/main/updater/config.ts @@ -8,7 +8,7 @@ export const GITHUB_CONFIG = { owner: 'AndyMik90', repo: 'Auto-Claude', - autoBuildPath: 'auto-claude' // Path within repo where auto-claude lives + autoBuildPath: 'apps/backend' // Path within repo where auto-claude backend lives } as const; /** diff --git a/apps/frontend/src/main/updater/http-client.ts b/apps/frontend/src/main/updater/http-client.ts index 9b047e9e..ada5f5d4 100644 --- a/apps/frontend/src/main/updater/http-client.ts +++ b/apps/frontend/src/main/updater/http-client.ts @@ -4,7 +4,7 @@ import https from 'https'; import { createWriteStream } from 'fs'; -import { TIMEOUTS } from './config'; +import { TIMEOUTS, GITHUB_CONFIG } from './config'; /** * Fetch JSON from a URL using https @@ -26,6 +26,26 @@ export function fetchJson(url: string): Promise { } } + // Handle HTTP 300 Multiple Choices (branch/tag name collision) + if (response.statusCode === 300) { + let data = ''; + response.on('data', chunk => data += chunk); + response.on('end', () => { + console.error('[HTTP] Multiple choices for resource:', { + url, + statusCode: 300, + response: data + }); + reject(new Error( + `Multiple resources found for ${url}. ` + + `This usually means a branch and tag have the same name. ` + + `Please report this issue at https://github.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/issues` + )); + }); + response.on('error', reject); + return; + } + if (response.statusCode !== 200) { // Collect response body for error details (limit to 10KB) const maxErrorSize = 10 * 1024; @@ -93,6 +113,28 @@ export function downloadFile( } } + // Handle HTTP 300 Multiple Choices (branch/tag name collision) + if (response.statusCode === 300) { + file.close(); + let data = ''; + response.on('data', chunk => data += chunk); + response.on('end', () => { + console.error('[HTTP] Multiple choices for resource:', { + url, + statusCode: 300, + response: data + }); + reject(new Error( + `Multiple resources found for ${url}. ` + + `This usually means a branch and tag have the same name. ` + + `Please download the latest version manually from: ` + + `https://github.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest` + )); + }); + response.on('error', reject); + return; + } + if (response.statusCode !== 200) { file.close(); // Collect response body for error details (limit to 10KB) diff --git a/apps/frontend/src/main/updater/update-installer.ts b/apps/frontend/src/main/updater/update-installer.ts index 0869c03b..a4e2d350 100644 --- a/apps/frontend/src/main/updater/update-installer.ts +++ b/apps/frontend/src/main/updater/update-installer.ts @@ -172,14 +172,23 @@ export async function downloadAndApplyUpdate( debugLog('[Update] Error:', errorMessage); debugLog('[Update] ============================================'); + // Provide user-friendly error message for HTTP 300 errors + let displayMessage = errorMessage; + if (errorMessage.includes('Multiple resources found')) { + displayMessage = + `Update failed due to repository configuration issue (HTTP 300). ` + + `Please download the latest version manually from: ` + + `https://github.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`; + } + onProgress?.({ stage: 'error', - message: errorMessage + message: displayMessage }); return { success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: displayMessage }; } } diff --git a/apps/frontend/src/main/updater/version-manager.ts b/apps/frontend/src/main/updater/version-manager.ts index 73952bb1..2a9ea0a8 100644 --- a/apps/frontend/src/main/updater/version-manager.ts +++ b/apps/frontend/src/main/updater/version-manager.ts @@ -36,6 +36,10 @@ export function getEffectiveVersion(): string { } else { // Development: check the actual source paths where updates are written const possibleSourcePaths = [ + // New apps structure + path.join(app.getAppPath(), '..', 'backend'), + path.join(process.cwd(), 'apps', 'backend'), + // Legacy paths for backwards compatibility path.join(app.getAppPath(), '..', 'auto-claude'), path.join(app.getAppPath(), '..', '..', 'auto-claude'), path.join(process.cwd(), 'auto-claude'), diff --git a/apps/frontend/src/renderer/components/task-detail/hooks/useTerminalHandler.ts b/apps/frontend/src/renderer/components/task-detail/hooks/useTerminalHandler.ts new file mode 100644 index 00000000..15afd77a --- /dev/null +++ b/apps/frontend/src/renderer/components/task-detail/hooks/useTerminalHandler.ts @@ -0,0 +1,32 @@ +import { useState } from 'react'; + +/** + * Hook for handling terminal creation with proper error handling and loading states. + * Fixes silent failures when terminal buttons are clicked. + */ +export function useTerminalHandler() { + const [error, setError] = useState(null); + const [isOpening, setIsOpening] = useState(false); + + const openTerminal = async (id: string, cwd: string) => { + setIsOpening(true); + setError(null); + + try { + const result = await window.electronAPI.createTerminal({ id, cwd }); + + if (!result.success) { + setError(result.error || 'Failed to open terminal'); + console.error('[Terminal] Failed to open:', result.error); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + setError(`Failed to open terminal: ${errorMsg}`); + console.error('[Terminal] Exception:', err); + } finally { + setIsOpening(false); + } + }; + + return { openTerminal, error, isOpening }; +} diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx index 95d3e5f3..0302b99b 100644 --- a/apps/frontend/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx +++ b/apps/frontend/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx @@ -3,6 +3,7 @@ import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react'; import { Button } from '../../ui/button'; import { Textarea } from '../../ui/textarea'; import type { Task } from '../../../../shared/types'; +import { useTerminalHandler } from '../hooks/useTerminalHandler'; interface StagedSuccessMessageProps { stagedSuccess: string; @@ -22,6 +23,7 @@ export function StagedSuccessMessage({ }: StagedSuccessMessageProps) { const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || ''); const [copied, setCopied] = useState(false); + const { openTerminal, error: terminalError, isOpening } = useTerminalHandler(); const handleCopy = async () => { if (!commitMessage) return; @@ -93,20 +95,23 @@ export function StagedSuccessMessage({ {stagedProjectPath && ( - + <> + + {terminalError && ( +
+ {terminalError} +
+ )} + )} ); diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx index 15a3c2ba..d21ec622 100644 --- a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx +++ b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx @@ -18,6 +18,7 @@ import { Button } from '../../ui/button'; import { Checkbox } from '../../ui/checkbox'; import { cn } from '../../../lib/utils'; import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types'; +import { useTerminalHandler } from '../hooks/useTerminalHandler'; interface WorkspaceStatusProps { task: Task; @@ -55,6 +56,7 @@ export function WorkspaceStatus({ onStageOnlyChange, onMerge }: WorkspaceStatusProps) { + const { openTerminal, error: terminalError, isOpening } = useTerminalHandler(); const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts; const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges; const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0; @@ -92,14 +94,10 @@ export function WorkspaceStatus({ @@ -135,6 +133,20 @@ export function WorkspaceStatus({ {worktreeStatus.baseBranch || 'main'} )} + + {/* Worktree path display */} + {worktreeStatus.worktreePath && ( +
+ šŸ“ {worktreeStatus.worktreePath} +
+ )} + + {/* Terminal error display */} + {terminalError && ( +
+ {terminalError} +
+ )} {/* Status/Warnings Section */} @@ -162,15 +174,16 @@ export function WorkspaceStatus({ variant="outline" size="sm" onClick={() => { - window.electronAPI.createTerminal({ - id: `stash-${task.id}`, - cwd: worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || undefined - }); + const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || ''; + if (mainProjectPath) { + openTerminal(`stash-${task.id}`, mainProjectPath); + } }} className="text-xs h-6 mt-2" + disabled={isOpening} > - Open Terminal + {isOpening ? 'Opening...' : 'Open Terminal'} diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index 276078ae..f8f2024a 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -244,6 +244,8 @@ export interface Task { releasedInVersion?: string; // Version in which this task was released stagedInMainProject?: boolean; // True if changes were staged to main project (worktree merged with --no-commit) stagedAt?: string; // ISO timestamp when changes were staged + location?: 'main' | 'worktree'; // Where task was loaded from (main project or worktree) + specsPath?: string; // Full path to specs directory for this task createdAt: Date; updatedAt: Date; } diff --git a/scripts/bump-version.js b/scripts/bump-version.js index ae373c62..a355c4d2 100644 --- a/scripts/bump-version.js +++ b/scripts/bump-version.js @@ -189,7 +189,12 @@ function main() { error('New version is the same as current version'); } - // 4. Update all version files + // 4. Validate release (check for branch/tag conflicts) + info('Validating release...'); + exec(`node ${path.join(__dirname, 'validate-release.js')} v${newVersion}`); + success('Release validation passed'); + + // 5. Update all version files info('Updating package.json files...'); updatePackageJson(newVersion); success('Updated package.json files'); @@ -204,18 +209,18 @@ function main() { success('Updated README.md'); } - // 5. Create git commit + // 6. Create git commit info('Creating git commit...'); exec('git add apps/frontend/package.json package.json apps/backend/__init__.py README.md'); exec(`git commit -m "chore: bump version to ${newVersion}"`); success(`Created commit: "chore: bump version to ${newVersion}"`); - // 6. Create git tag + // 7. Create git tag info('Creating git tag...'); exec(`git tag -a v${newVersion} -m "Release v${newVersion}"`); success(`Created tag: v${newVersion}`); - // 7. Instructions + // 8. Instructions log('\nšŸ“‹ Next steps:', colors.yellow); log(` 1. Review the changes: git log -1`, colors.yellow); log(` 2. Push the commit: git push origin `, colors.yellow); diff --git a/scripts/validate-release.js b/scripts/validate-release.js new file mode 100644 index 00000000..d23bef46 --- /dev/null +++ b/scripts/validate-release.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +/** + * Validate Release Script + * + * Prevents HTTP 300 errors by ensuring no branch/tag name conflicts. + * Run before creating a new release to check if the version is safe. + * + * Usage: node scripts/validate-release.js + * Example: node scripts/validate-release.js v2.7.2 + */ + +const { execSync } = require('child_process'); + +function validateRelease(version) { + console.log(`Validating release: ${version}...`); + + // Check if version tag already exists + try { + const tags = execSync('git tag -l').toString().split('\n').filter(Boolean); + if (tags.includes(version)) { + console.error(`\u274C Tag ${version} already exists!`); + console.error(' Cannot create duplicate tag.'); + process.exit(1); + } + } catch (error) { + console.error('Failed to check git tags:', error.message); + process.exit(1); + } + + // Check if branch with same name exists (locally) + try { + const branches = execSync('git branch') + .toString() + .split('\n') + .map(b => b.trim().replace(/^\*\s*/, '')) + .filter(Boolean); + if (branches.includes(version)) { + console.error(`\u274C Local branch "${version}" already exists!`); + console.error(' This will cause HTTP 300 errors during updates.'); + console.error(` Please delete the branch: git branch -D ${version}`); + process.exit(1); + } + } catch (error) { + console.error('Failed to check local branches:', error.message); + process.exit(1); + } + + // Check if branch with same name exists (remotely) + try { + const remoteBranches = execSync('git branch -r') + .toString() + .split('\n') + .map(b => b.trim()) + .filter(b => b && !b.includes(' -> ')); // Exclude symbolic refs like origin/HEAD -> origin/main + if (remoteBranches.includes(`origin/${version}`) || remoteBranches.includes(`fork/${version}`)) { + console.error(`\u274C Remote branch "${version}" already exists!`); + console.error(' This will cause HTTP 300 errors during updates.'); + console.error(` Please delete the remote branch: git push origin --delete ${version}`); + process.exit(1); + } + } catch (error) { + // Ignore errors from remote check (might not have remotes configured) + console.warn('\u26A0\uFE0F Could not check remote branches:', error.message); + } + + console.log(`\u2705 Version ${version} is safe to release`); + console.log(' No conflicting branches or tags found.'); +} + +// Main execution +const version = process.argv[2]; +if (!version) { + console.error('Usage: node validate-release.js '); + console.error('Example: node validate-release.js v2.7.2'); + process.exit(1); +} + +validateRelease(version);