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 = [
|
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(__dirname, '..', '..', '..', 'auto-claude'),
|
||||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||||
path.resolve(process.cwd(), '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 = [
|
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(__dirname, '..', '..', '..', 'auto-claude'),
|
||||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||||
path.resolve(process.cwd(), '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 { execSync, execFileSync, spawn } from 'child_process';
|
||||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||||
import type { IPCResult } from '../../../shared/types';
|
import type { IPCResult } from '../../../shared/types';
|
||||||
|
import { getAugmentedEnv, findExecutable } from '../../env-utils';
|
||||||
|
|
||||||
// Debug logging helper
|
// Debug logging helper
|
||||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
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
|
* 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 {
|
export function registerCheckGhCli(): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -107,15 +109,24 @@ export function registerCheckGhCli(): void {
|
|||||||
async (): Promise<IPCResult<{ installed: boolean; version?: string }>> => {
|
async (): Promise<IPCResult<{ installed: boolean; version?: string }>> => {
|
||||||
debugLog('checkGitHubCli handler called');
|
debugLog('checkGitHubCli handler called');
|
||||||
try {
|
try {
|
||||||
const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh';
|
// Use findExecutable to check common locations including Homebrew paths
|
||||||
debugLog(`Running command: ${checkCmd}`);
|
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' });
|
// Get version using augmented environment
|
||||||
debugLog('gh CLI found at:', whichResult.trim());
|
|
||||||
|
|
||||||
// Get version
|
|
||||||
debugLog('Getting gh version...');
|
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];
|
const version = versionOutput.trim().split('\n')[0];
|
||||||
debugLog('gh version:', version);
|
debugLog('gh version:', version);
|
||||||
|
|
||||||
@@ -136,16 +147,18 @@ export function registerCheckGhCli(): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is authenticated with gh CLI
|
* 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 {
|
export function registerCheckGhAuth(): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.GITHUB_CHECK_AUTH,
|
IPC_CHANNELS.GITHUB_CHECK_AUTH,
|
||||||
async (): Promise<IPCResult<{ authenticated: boolean; username?: string }>> => {
|
async (): Promise<IPCResult<{ authenticated: boolean; username?: string }>> => {
|
||||||
debugLog('checkGitHubAuth handler called');
|
debugLog('checkGitHubAuth handler called');
|
||||||
|
const env = getAugmentedEnv();
|
||||||
try {
|
try {
|
||||||
// Check auth status
|
// Check auth status
|
||||||
debugLog('Running: gh 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);
|
debugLog('Auth status output:', authStatus);
|
||||||
|
|
||||||
// Get username if authenticated
|
// Get username if authenticated
|
||||||
@@ -153,7 +166,8 @@ export function registerCheckGhAuth(): void {
|
|||||||
debugLog('Getting username via: gh api user --jq .login');
|
debugLog('Getting username via: gh api user --jq .login');
|
||||||
const username = execSync('gh api user --jq .login', {
|
const username = execSync('gh api user --jq .login', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: 'pipe'
|
stdio: 'pipe',
|
||||||
|
env
|
||||||
}).trim();
|
}).trim();
|
||||||
debugLog('Username:', username);
|
debugLog('Username:', username);
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,18 @@ import path from 'path';
|
|||||||
import type { Project } from '../../../shared/types';
|
import type { Project } from '../../../shared/types';
|
||||||
import { parseEnvFile } from '../utils';
|
import { parseEnvFile } from '../utils';
|
||||||
import type { GitHubConfig } from './types';
|
import type { GitHubConfig } from './types';
|
||||||
|
import { getAugmentedEnv } from '../../env-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get GitHub token from gh CLI if available
|
* 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 {
|
function getTokenFromGhCli(): string | null {
|
||||||
try {
|
try {
|
||||||
const token = execSync('gh auth token', {
|
const token = execSync('gh auth token', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: 'pipe'
|
stdio: 'pipe',
|
||||||
|
env: getAugmentedEnv()
|
||||||
}).trim();
|
}).trim();
|
||||||
return token || null;
|
return token || null;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -539,6 +539,10 @@ export function registerMemoryHandlers(): void {
|
|||||||
|
|
||||||
// Find the ollama_model_detector.py script
|
// Find the ollama_model_detector.py script
|
||||||
const possiblePaths = [
|
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(__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'),
|
||||||
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
|
// Development mode paths
|
||||||
if (is.dev) {
|
if (is.dev) {
|
||||||
// In dev, __dirname is typically auto-claude-ui/out/main
|
// In dev, __dirname is typically apps/frontend/out/main
|
||||||
// We need to go up to the project root to find auto-claude/
|
// We need to go up to find apps/backend
|
||||||
possiblePaths.push(
|
possiblePaths.push(
|
||||||
path.resolve(__dirname, '..', '..', '..', 'auto-claude'), // From out/main up 3 levels
|
path.resolve(__dirname, '..', '..', '..', 'backend'), // From out/main -> apps/backend
|
||||||
path.resolve(__dirname, '..', '..', 'auto-claude'), // From out/main up 2 levels
|
path.resolve(process.cwd(), 'apps', 'backend'), // From cwd (repo root)
|
||||||
path.resolve(process.cwd(), 'auto-claude'), // From cwd (project root)
|
// Legacy paths for backwards compatibility
|
||||||
path.resolve(process.cwd(), '..', 'auto-claude') // From cwd parent (if running from auto-claude-ui/)
|
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 {
|
} else {
|
||||||
// Production mode paths (packaged app)
|
// Production mode paths (packaged app)
|
||||||
@@ -37,6 +40,9 @@ const detectAutoBuildSourcePath = (): string | null => {
|
|||||||
// We check common locations relative to the app bundle
|
// We check common locations relative to the app bundle
|
||||||
const appPath = app.getAppPath();
|
const appPath = app.getAppPath();
|
||||||
possiblePaths.push(
|
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'), // Sibling to app
|
||||||
path.resolve(appPath, '..', '..', 'auto-claude'), // Up 2 from app
|
path.resolve(appPath, '..', '..', 'auto-claude'), // Up 2 from app
|
||||||
path.resolve(appPath, '..', '..', '..', 'auto-claude'), // Up 3 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
|
// 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'));
|
possiblePaths.push(path.resolve(process.cwd(), 'auto-claude'));
|
||||||
|
|
||||||
// Enable debug logging with DEBUG=1
|
// Enable debug logging with DEBUG=1
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ import { findTaskAndProject } from './shared';
|
|||||||
import { checkGitStatus } from '../../project-initializer';
|
import { checkGitStatus } from '../../project-initializer';
|
||||||
import { getClaudeProfileManager } from '../../claude-profile-manager';
|
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)
|
* 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)) {
|
if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) {
|
||||||
// Analyze subtask statuses to determine appropriate recovery status
|
// Analyze subtask statuses to determine appropriate recovery status
|
||||||
const allSubtasks: Array<{ status: string }> = [];
|
const { completedCount, totalCount, allCompleted } = checkSubtasksCompletion(plan);
|
||||||
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;
|
|
||||||
|
|
||||||
|
if (totalCount > 0) {
|
||||||
if (allCompleted) {
|
if (allCompleted) {
|
||||||
// All subtasks completed - should go to review (ai_review or human_review based on source)
|
// 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
|
// For recovery, human_review is safer as it requires manual verification
|
||||||
@@ -625,7 +636,30 @@ export function registerTaskExecutionHandlers(
|
|||||||
// Add recovery note
|
// Add recovery note
|
||||||
plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`;
|
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
|
// Keep completed subtasks as-is so run.py can resume from where it left off
|
||||||
if (plan.phases && Array.isArray(plan.phases)) {
|
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 }> }>) {
|
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)
|
// Reset in_progress subtasks to pending (they were interrupted)
|
||||||
// Keep completed subtasks as-is so run.py can resume
|
// Keep completed subtasks as-is so run.py can resume
|
||||||
if (subtask.status === 'in_progress') {
|
if (subtask.status === 'in_progress') {
|
||||||
|
const originalStatus = subtask.status;
|
||||||
subtask.status = 'pending';
|
subtask.status = 'pending';
|
||||||
// Clear execution data to maintain consistency
|
// Clear execution data to maintain consistency
|
||||||
delete subtask.actual_output;
|
delete subtask.actual_output;
|
||||||
delete subtask.started_at;
|
delete subtask.started_at;
|
||||||
delete subtask.completed_at;
|
delete subtask.completed_at;
|
||||||
|
console.log(`[Recovery] Reset stuck subtask: ${originalStatus} -> pending`);
|
||||||
}
|
}
|
||||||
// Also reset failed subtasks so they can be retried
|
// Also reset failed subtasks so they can be retried
|
||||||
if (subtask.status === 'failed') {
|
if (subtask.status === 'failed') {
|
||||||
@@ -647,6 +683,7 @@ export function registerTaskExecutionHandlers(
|
|||||||
delete subtask.actual_output;
|
delete subtask.actual_output;
|
||||||
delete subtask.started_at;
|
delete subtask.started_at;
|
||||||
delete subtask.completed_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 * as fs from 'fs';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { findPythonCommand, parsePythonCommand } from './python-detector';
|
import { findPythonCommand, parsePythonCommand } from './python-detector';
|
||||||
|
import { getMemoriesDir } from './config-paths';
|
||||||
import type { MemoryEpisode } from '../shared/types';
|
import type { MemoryEpisode } from '../shared/types';
|
||||||
|
|
||||||
interface MemoryServiceConfig {
|
interface MemoryServiceConfig {
|
||||||
@@ -82,24 +83,26 @@ interface StatusResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the default database path
|
* Get the default database path
|
||||||
|
* Uses XDG-compliant paths on Linux for AppImage/Flatpak/Snap support
|
||||||
*/
|
*/
|
||||||
export function getDefaultDbPath(): string {
|
export function getDefaultDbPath(): string {
|
||||||
return path.join(os.homedir(), '.auto-claude', 'memories');
|
return getMemoriesDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to the query_memory.py script
|
* Get the path to the query_memory.py script
|
||||||
*/
|
*/
|
||||||
function getQueryScriptPath(): string | null {
|
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 = [
|
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'),
|
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'),
|
path.resolve(app.getAppPath(), '..', 'auto-claude', 'query_memory.py'),
|
||||||
// Alternative: from app root
|
|
||||||
path.resolve(process.cwd(), 'auto-claude', 'query_memory.py'),
|
path.resolve(process.cwd(), 'auto-claude', 'query_memory.py'),
|
||||||
// If running from repo root
|
|
||||||
path.resolve(process.cwd(), '..', 'auto-claude', 'query_memory.py'),
|
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);
|
console.warn('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath);
|
||||||
|
|
||||||
// Get specs directory path
|
const allTasks: Task[] = [];
|
||||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
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[] = [];
|
const tasks: Task[] = [];
|
||||||
let specDirs: Dirent[] = [];
|
let specDirs: Dirent[] = [];
|
||||||
|
|
||||||
@@ -401,6 +457,8 @@ export class ProjectStore {
|
|||||||
metadata,
|
metadata,
|
||||||
stagedInMainProject,
|
stagedInMainProject,
|
||||||
stagedAt,
|
stagedAt,
|
||||||
|
location, // Add location metadata (main vs worktree)
|
||||||
|
specsPath: specPath, // Add full path to specs directory
|
||||||
createdAt: new Date(plan?.created_at || Date.now()),
|
createdAt: new Date(plan?.created_at || Date.now()),
|
||||||
updatedAt: new Date(plan?.updated_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;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,35 @@ export function getDefaultPythonCommand(): string {
|
|||||||
* @returns Tuple of [command, baseArgs] ready for use with spawn()
|
* @returns Tuple of [command, baseArgs] ready for use with spawn()
|
||||||
*/
|
*/
|
||||||
export function parsePythonCommand(pythonPath: string): [string, string[]] {
|
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 the path points to an actual file, use it directly (handles paths with spaces)
|
||||||
if (existsSync(pythonPath)) {
|
if (existsSync(cleanPath)) {
|
||||||
return [pythonPath, []];
|
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"
|
// 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 command = parts[0];
|
||||||
const baseArgs = parts.slice(1);
|
const baseArgs = parts.slice(1);
|
||||||
return [command, baseArgs];
|
return [command, baseArgs];
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export class TerminalNameGenerator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const possiblePaths = [
|
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(__dirname, '..', '..', '..', 'auto-claude'),
|
||||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||||
path.resolve(process.cwd(), 'auto-claude')
|
path.resolve(process.cwd(), 'auto-claude')
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export class TitleGenerator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const possiblePaths = [
|
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(__dirname, '..', '..', '..', 'auto-claude'),
|
||||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||||
path.resolve(process.cwd(), 'auto-claude')
|
path.resolve(process.cwd(), 'auto-claude')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
export const GITHUB_CONFIG = {
|
export const GITHUB_CONFIG = {
|
||||||
owner: 'AndyMik90',
|
owner: 'AndyMik90',
|
||||||
repo: 'Auto-Claude',
|
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;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import { createWriteStream } from 'fs';
|
import { createWriteStream } from 'fs';
|
||||||
import { TIMEOUTS } from './config';
|
import { TIMEOUTS, GITHUB_CONFIG } from './config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch JSON from a URL using https
|
* 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) {
|
if (response.statusCode !== 200) {
|
||||||
// Collect response body for error details (limit to 10KB)
|
// Collect response body for error details (limit to 10KB)
|
||||||
const maxErrorSize = 10 * 1024;
|
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) {
|
if (response.statusCode !== 200) {
|
||||||
file.close();
|
file.close();
|
||||||
// Collect response body for error details (limit to 10KB)
|
// Collect response body for error details (limit to 10KB)
|
||||||
|
|||||||
@@ -172,14 +172,23 @@ export async function downloadAndApplyUpdate(
|
|||||||
debugLog('[Update] Error:', errorMessage);
|
debugLog('[Update] Error:', errorMessage);
|
||||||
debugLog('[Update] ============================================');
|
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?.({
|
onProgress?.({
|
||||||
stage: 'error',
|
stage: 'error',
|
||||||
message: errorMessage
|
message: displayMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
error: displayMessage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function getEffectiveVersion(): string {
|
|||||||
} else {
|
} else {
|
||||||
// Development: check the actual source paths where updates are written
|
// Development: check the actual source paths where updates are written
|
||||||
const possibleSourcePaths = [
|
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(app.getAppPath(), '..', '..', 'auto-claude'),
|
path.join(app.getAppPath(), '..', '..', 'auto-claude'),
|
||||||
path.join(process.cwd(), '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 { Button } from '../../ui/button';
|
||||||
import { Textarea } from '../../ui/textarea';
|
import { Textarea } from '../../ui/textarea';
|
||||||
import type { Task } from '../../../../shared/types';
|
import type { Task } from '../../../../shared/types';
|
||||||
|
import { useTerminalHandler } from '../hooks/useTerminalHandler';
|
||||||
|
|
||||||
interface StagedSuccessMessageProps {
|
interface StagedSuccessMessageProps {
|
||||||
stagedSuccess: string;
|
stagedSuccess: string;
|
||||||
@@ -22,6 +23,7 @@ export function StagedSuccessMessage({
|
|||||||
}: StagedSuccessMessageProps) {
|
}: StagedSuccessMessageProps) {
|
||||||
const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || '');
|
const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || '');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { openTerminal, error: terminalError, isOpening } = useTerminalHandler();
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (!commitMessage) return;
|
if (!commitMessage) return;
|
||||||
@@ -93,20 +95,23 @@ export function StagedSuccessMessage({
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
{stagedProjectPath && (
|
{stagedProjectPath && (
|
||||||
<Button
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
window.electronAPI.createTerminal({
|
onClick={() => openTerminal(`project-${task.id}`, stagedProjectPath)}
|
||||||
id: `project-${task.id}`,
|
className="w-full"
|
||||||
cwd: stagedProjectPath
|
disabled={isOpening}
|
||||||
});
|
>
|
||||||
}}
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
className="w-full"
|
{isOpening ? 'Opening Terminal...' : 'Open Project in Terminal'}
|
||||||
>
|
</Button>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
{terminalError && (
|
||||||
Open Project in Terminal
|
<div className="mt-2 text-sm text-red-600">
|
||||||
</Button>
|
{terminalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Button } from '../../ui/button';
|
|||||||
import { Checkbox } from '../../ui/checkbox';
|
import { Checkbox } from '../../ui/checkbox';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||||
|
import { useTerminalHandler } from '../hooks/useTerminalHandler';
|
||||||
|
|
||||||
interface WorkspaceStatusProps {
|
interface WorkspaceStatusProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -55,6 +56,7 @@ export function WorkspaceStatus({
|
|||||||
onStageOnlyChange,
|
onStageOnlyChange,
|
||||||
onMerge
|
onMerge
|
||||||
}: WorkspaceStatusProps) {
|
}: WorkspaceStatusProps) {
|
||||||
|
const { openTerminal, error: terminalError, isOpening } = useTerminalHandler();
|
||||||
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
|
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
|
||||||
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
|
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
|
||||||
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
||||||
@@ -92,14 +94,10 @@ export function WorkspaceStatus({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => openTerminal(`open-${task.id}`, worktreeStatus.worktreePath!)}
|
||||||
window.electronAPI.createTerminal({
|
|
||||||
id: `open-${task.id}`,
|
|
||||||
cwd: worktreeStatus.worktreePath!
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="h-7 px-2"
|
className="h-7 px-2"
|
||||||
title="Open in terminal"
|
title="Open in terminal"
|
||||||
|
disabled={isOpening}
|
||||||
>
|
>
|
||||||
<Terminal className="h-3.5 w-3.5" />
|
<Terminal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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>
|
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.baseBranch || 'main'}</code>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Status/Warnings Section */}
|
{/* Status/Warnings Section */}
|
||||||
@@ -162,15 +174,16 @@ export function WorkspaceStatus({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.electronAPI.createTerminal({
|
const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || '';
|
||||||
id: `stash-${task.id}`,
|
if (mainProjectPath) {
|
||||||
cwd: worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || undefined
|
openTerminal(`stash-${task.id}`, mainProjectPath);
|
||||||
});
|
}
|
||||||
}}
|
}}
|
||||||
className="text-xs h-6 mt-2"
|
className="text-xs h-6 mt-2"
|
||||||
|
disabled={isOpening}
|
||||||
>
|
>
|
||||||
<Terminal className="h-3 w-3 mr-1" />
|
<Terminal className="h-3 w-3 mr-1" />
|
||||||
Open Terminal
|
{isOpening ? 'Opening...' : 'Open Terminal'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -244,6 +244,8 @@ export interface Task {
|
|||||||
releasedInVersion?: string; // Version in which this task was released
|
releasedInVersion?: string; // Version in which this task was released
|
||||||
stagedInMainProject?: boolean; // True if changes were staged to main project (worktree merged with --no-commit)
|
stagedInMainProject?: boolean; // True if changes were staged to main project (worktree merged with --no-commit)
|
||||||
stagedAt?: string; // ISO timestamp when changes were staged
|
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;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,12 @@ function main() {
|
|||||||
error('New version is the same as current version');
|
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...');
|
info('Updating package.json files...');
|
||||||
updatePackageJson(newVersion);
|
updatePackageJson(newVersion);
|
||||||
success('Updated package.json files');
|
success('Updated package.json files');
|
||||||
@@ -204,18 +209,18 @@ function main() {
|
|||||||
success('Updated README.md');
|
success('Updated README.md');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create git commit
|
// 6. Create git commit
|
||||||
info('Creating git commit...');
|
info('Creating git commit...');
|
||||||
exec('git add apps/frontend/package.json package.json apps/backend/__init__.py README.md');
|
exec('git add apps/frontend/package.json package.json apps/backend/__init__.py README.md');
|
||||||
exec(`git commit -m "chore: bump version to ${newVersion}"`);
|
exec(`git commit -m "chore: bump version to ${newVersion}"`);
|
||||||
success(`Created commit: "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...');
|
info('Creating git tag...');
|
||||||
exec(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
exec(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
||||||
success(`Created tag: v${newVersion}`);
|
success(`Created tag: v${newVersion}`);
|
||||||
|
|
||||||
// 7. Instructions
|
// 8. Instructions
|
||||||
log('\n📋 Next steps:', colors.yellow);
|
log('\n📋 Next steps:', colors.yellow);
|
||||||
log(` 1. Review the changes: git log -1`, colors.yellow);
|
log(` 1. Review the changes: git log -1`, colors.yellow);
|
||||||
log(` 2. Push the commit: git push origin <branch-name>`, 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