fix/PRs from old main setup to apps structure (#185)
* fix(core): add task persistence, terminal handling, and HTTP 300 fixes Consolidated bug fixes from PRs #168, #170, #171: - Task persistence (#168): Scan worktrees for tasks on app restart to prevent loss of in-progress work and wasted API credits. Tasks in .worktrees/*/specs are now loaded and deduplicated with main. - Terminal buttons (#170): Fix "Open Terminal" buttons silently failing on macOS by properly awaiting createTerminal() Promise. Added useTerminalHandler hook with loading states and error display. - HTTP 300 errors (#171): Handle branch/tag name collisions that cause update failures. Added validation script to prevent conflicts before releases and user-friendly error messages with manual download links. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(platform): add path resolution, spaces handling, and XDG support This commit consolidates multiple bug fixes from community PRs: - PR #187: Path resolution fix - Update path detection to find apps/backend instead of legacy auto-claude directory after v2.7.2 restructure - PR #182/#155: Python path spaces fix - Improve parsePythonCommand() to handle quoted paths and paths containing spaces without splitting - PR #161: Ollama detection fix - Add new apps structure paths for ollama_model_detector.py script discovery - PR #160: AppImage support - Add XDG Base Directory compliant paths for Linux sandboxed environments (AppImage, Flatpak, Snap). New files: - config-paths.ts: XDG path utilities - fs-utils.ts: Filesystem utilities with fallback support - PR #159: gh CLI PATH fix - Add getAugmentedEnv() utility to include common binary locations (Homebrew, snap, local) in PATH for child processes. Fixes gh CLI not found when app launched from Finder/Dock. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit/Cursor review comments on PR #185 Fixes from code review: - http-client.ts: Use GITHUB_CONFIG instead of hardcoded owner in HTTP 300 error message - validate-release.js: Fix substring matching bug in branch detection that could cause false positives (e.g., v2.7 matching v2.7.2) - bump-version.js: Remove unnecessary try-catch wrapper (exec() already exits on failure) - execution-handlers.ts: Capture original subtask status before mutation for accurate logging - fs-utils.ts: Add error handling to safeWriteFile with proper logging Dismissed as trivial/not applicable: - config-paths.ts: Exhaustive switch check (over-engineering) - env-utils.ts: PATH priority documentation (existing comments sufficient) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address additional CodeRabbit review comments (round 2) Fixes from second round of code review: - fs-utils.ts: Wrap test file cleanup in try-catch for Windows file locking - fs-utils.ts: Add error handling to safeReadFile for consistency with safeWriteFile - http-client.ts: Use GITHUB_CONFIG in fetchJson (missed in first round) - validate-release.js: Exclude symbolic refs (origin/HEAD -> origin/main) from branch check - python-detector.ts: Return cleanPath instead of pythonPath for empty input edge case Dismissed as trivial/not applicable: - execution-handlers.ts: Redundant checkSubtasksCompletion call (micro-optimization) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, string[]> = {
|
||||
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<string, string> {
|
||||
const env = { ...process.env } as Record<string, string>;
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<IPCResult<{ installed: boolean; version?: string }>> => {
|
||||
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<IPCResult<{ authenticated: boolean; username?: string }>> => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> | 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string, Task>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<T>(url: string): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<string | null>(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 };
|
||||
}
|
||||
+19
-14
@@ -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({
|
||||
</ol>
|
||||
</div>
|
||||
{stagedProjectPath && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.electronAPI.createTerminal({
|
||||
id: `project-${task.id}`,
|
||||
cwd: stagedProjectPath
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Project in Terminal
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTerminal(`project-${task.id}`, stagedProjectPath)}
|
||||
className="w-full"
|
||||
disabled={isOpening}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{isOpening ? 'Opening Terminal...' : 'Open Project in Terminal'}
|
||||
</Button>
|
||||
{terminalError && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
{terminalError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.electronAPI.createTerminal({
|
||||
id: `open-${task.id}`,
|
||||
cwd: worktreeStatus.worktreePath!
|
||||
});
|
||||
}}
|
||||
onClick={() => openTerminal(`open-${task.id}`, worktreeStatus.worktreePath!)}
|
||||
className="h-7 px-2"
|
||||
title="Open in terminal"
|
||||
disabled={isOpening}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -135,6 +133,20 @@ export function WorkspaceStatus({
|
||||
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.baseBranch || 'main'}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worktree path display */}
|
||||
{worktreeStatus.worktreePath && (
|
||||
<div className="mt-2 text-xs text-muted-foreground font-mono">
|
||||
📁 {worktreeStatus.worktreePath}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal error display */}
|
||||
{terminalError && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
{terminalError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
<Terminal className="h-3 w-3 mr-1" />
|
||||
Open Terminal
|
||||
{isOpening ? 'Opening...' : 'Open Terminal'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <branch-name>`, colors.yellow);
|
||||
|
||||
@@ -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 <version>
|
||||
* 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 <version>');
|
||||
console.error('Example: node validate-release.js v2.7.2');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
validateRelease(version);
|
||||
Reference in New Issue
Block a user