diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index abb56833..a98377fb 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -35,6 +35,7 @@ import { createGetInitScriptHandler, createPutInitScriptHandler, createDeleteInitScriptHandler, + createRunInitScriptHandler, } from './routes/init-script.js'; export function createWorktreeRoutes(events: EventEmitter): Router { @@ -97,6 +98,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router { router.post('/init-script', validatePathParams('projectPath'), createGetInitScriptHandler()); router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); + router.post( + '/run-init-script', + validatePathParams('projectPath', 'worktreePath'), + createRunInitScriptHandler(events) + ); return router; } diff --git a/apps/server/src/routes/worktree/routes/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts index 89c32456..0389bce3 100644 --- a/apps/server/src/routes/worktree/routes/init-script.ts +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -1,9 +1,10 @@ /** - * Init Script routes - Read/write the worktree-init.sh file + * Init Script routes - Read/write/run the worktree-init.sh file * - * GET /init-script - Read the init script content + * POST /init-script - Read the init script content * PUT /init-script - Write content to the init script file * DELETE /init-script - Delete the init script file + * POST /run-init-script - Run the init script for a worktree */ import type { Request, Response } from 'express'; @@ -11,6 +12,8 @@ import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { getErrorMessage, logError } from '../common.js'; import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../../lib/events.js'; +import { forceRunInitScript } from '../../../services/init-script-service.js'; const logger = createLogger('InitScript'); @@ -160,3 +163,77 @@ export function createDeleteInitScriptHandler() { } }; } + +/** + * POST /run-init-script - Run (or re-run) the init script for a worktree + */ +export function createRunInitScriptHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath, branch } = req.body as { + projectPath: string; + worktreePath: string; + branch: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + if (!branch) { + res.status(400).json({ + success: false, + error: 'branch is required', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + // Check if script exists + try { + await secureFs.access(scriptPath); + } catch { + res.status(404).json({ + success: false, + error: 'No init script found. Create one in Settings > Worktrees.', + }); + return; + } + + logger.info(`Running init script for branch "${branch}" (forced)`); + + // Run the script asynchronously (non-blocking) + forceRunInitScript({ + projectPath, + worktreePath, + branch, + emitter: events, + }); + + // Return immediately - progress will be streamed via WebSocket events + res.json({ + success: true, + message: 'Init script started', + }); + } catch (error) { + logError(error, 'Run init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts index 25a36fcb..ac354bff 100644 --- a/apps/server/src/services/init-script-service.ts +++ b/apps/server/src/services/init-script-service.ts @@ -7,65 +7,15 @@ import { spawn } from 'child_process'; import path from 'path'; -import fs from 'fs'; import { createLogger } from '@automaker/utils'; +import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform'; +import { findCommand } from '../lib/cli-detection.js'; import type { EventEmitter } from '../lib/events.js'; -import { - readWorktreeMetadata, - writeWorktreeMetadata, -} from '../lib/worktree-metadata.js'; +import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js'; +import * as secureFs from '../lib/secure-fs.js'; const logger = createLogger('InitScript'); -/** Common Git Bash installation paths on Windows */ -const GIT_BASH_PATHS = [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'), - path.join(process.env.USERPROFILE || '', 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), -]; - -/** - * Find Git Bash executable on Windows - */ -function findGitBash(): string | null { - if (process.platform !== 'win32') { - return null; - } - - for (const bashPath of GIT_BASH_PATHS) { - if (fs.existsSync(bashPath)) { - return bashPath; - } - } - - return null; -} - -/** - * Get the shell command for running scripts - * Returns [shellPath, shellArgs] for cross-platform compatibility - */ -function getShellCommand(): { shell: string; args: string[] } | null { - if (process.platform === 'win32') { - const gitBash = findGitBash(); - if (!gitBash) { - return null; - } - return { shell: gitBash, args: [] }; - } - - // Unix-like systems: prefer bash, fall back to sh - if (fs.existsSync('/bin/bash')) { - return { shell: '/bin/bash', args: [] }; - } - if (fs.existsSync('/bin/sh')) { - return { shell: '/bin/sh', args: [] }; - } - - return null; -} - export interface InitScriptOptions { /** Absolute path to the project root */ projectPath: string; @@ -77,182 +27,307 @@ export interface InitScriptOptions { emitter: EventEmitter; } -/** - * Check if init script exists for a project - */ -export function getInitScriptPath(projectPath: string): string { - return path.join(projectPath, '.automaker', 'worktree-init.sh'); +interface ShellCommand { + shell: string; + args: string[]; } /** - * Check if the init script has already been run for a worktree + * Init Script Service + * + * Handles execution of worktree initialization scripts with cross-platform + * shell detection and proper streaming of output via WebSocket events. */ -export async function hasInitScriptRun( - projectPath: string, - branch: string -): Promise { - const metadata = await readWorktreeMetadata(projectPath, branch); - return metadata?.initScriptRan === true; -} +export class InitScriptService { + private cachedShellCommand: ShellCommand | null | undefined = undefined; -/** - * Run the worktree initialization script - * Non-blocking - returns immediately after spawning - */ -export async function runInitScript(options: InitScriptOptions): Promise { - const { projectPath, worktreePath, branch, emitter } = options; - - const scriptPath = getInitScriptPath(projectPath); - - // Check if script exists - if (!fs.existsSync(scriptPath)) { - logger.debug(`No init script found at ${scriptPath}`); - return; + /** + * Get the path to the init script for a project + */ + getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); } - // Check if already run - if (await hasInitScriptRun(projectPath, branch)) { - logger.info(`Init script already ran for branch "${branch}", skipping`); - return; - } - - // Get shell command - const shellCmd = getShellCommand(); - if (!shellCmd) { - const error = - process.platform === 'win32' - ? 'Git Bash not found. Please install Git for Windows to run init scripts.' - : 'No shell found (/bin/bash or /bin/sh)'; - logger.error(error); - - // Update metadata with error - await writeWorktreeMetadata(projectPath, branch, { - branch, - createdAt: new Date().toISOString(), - initScriptRan: true, - initScriptStatus: 'failed', - initScriptError: error, - }); - - emitter.emit('worktree:init-completed', { - projectPath, - worktreePath, - branch, - success: false, - error, - }); - return; - } - - logger.info(`Running init script for branch "${branch}" in ${worktreePath}`); - - // Update metadata to mark as running - const existingMetadata = await readWorktreeMetadata(projectPath, branch); - await writeWorktreeMetadata(projectPath, branch, { - branch, - createdAt: existingMetadata?.createdAt || new Date().toISOString(), - pr: existingMetadata?.pr, - initScriptRan: false, - initScriptStatus: 'running', - }); - - // Emit started event - emitter.emit('worktree:init-started', { - projectPath, - worktreePath, - branch, - }); - - // Spawn the script - const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { - cwd: worktreePath, - env: { - ...process.env, - // Provide useful env vars to the script - AUTOMAKER_PROJECT_PATH: projectPath, - AUTOMAKER_WORKTREE_PATH: worktreePath, - AUTOMAKER_BRANCH: branch, - // Force color output even though we're not a TTY - FORCE_COLOR: '1', - npm_config_color: 'always', - CLICOLOR_FORCE: '1', - // Git colors - GIT_TERMINAL_PROMPT: '0', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - // Stream stdout - child.stdout?.on('data', (data: Buffer) => { - const content = data.toString(); - emitter.emit('worktree:init-output', { - projectPath, - branch, - type: 'stdout', - content, - }); - }); - - // Stream stderr - child.stderr?.on('data', (data: Buffer) => { - const content = data.toString(); - emitter.emit('worktree:init-output', { - projectPath, - branch, - type: 'stderr', - content, - }); - }); - - // Handle completion - child.on('exit', async (code) => { - const success = code === 0; - const status = success ? 'success' : 'failed'; - - logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`); - - // Update metadata + /** + * Check if the init script has already been run for a worktree + */ + async hasInitScriptRun(projectPath: string, branch: string): Promise { const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.initScriptRan === true; + } + + /** + * Find the appropriate shell for running scripts + * Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH + */ + async findShellCommand(): Promise { + // Return cached result if available + if (this.cachedShellCommand !== undefined) { + return this.cachedShellCommand; + } + + if (process.platform === 'win32') { + // On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe) + // WSL bash may not be properly configured and causes ENOENT errors + + // First try known Git Bash installation paths + const gitBashPath = await findGitBashPath(); + if (gitBashPath) { + logger.debug(`Found Git Bash at: ${gitBashPath}`); + this.cachedShellCommand = { shell: gitBashPath, args: [] }; + return this.cachedShellCommand; + } + + // Fall back to finding bash in PATH, but skip WSL bash + const bashInPath = await findCommand(['bash']); + if (bashInPath && !bashInPath.toLowerCase().includes('system32')) { + logger.debug(`Found bash in PATH at: ${bashInPath}`); + this.cachedShellCommand = { shell: bashInPath, args: [] }; + return this.cachedShellCommand; + } + + logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.'); + this.cachedShellCommand = null; + return null; + } + + // Unix-like systems: use getShellPaths() and check existence + const shellPaths = getShellPaths(); + const posixShells = shellPaths.filter( + (p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh' + ); + + for (const shellPath of posixShells) { + try { + if (systemPathExists(shellPath)) { + this.cachedShellCommand = { shell: shellPath, args: [] }; + return this.cachedShellCommand; + } + } catch { + // Path not allowed or doesn't exist, continue + } + } + + // Ultimate fallback + if (systemPathExists('/bin/sh')) { + this.cachedShellCommand = { shell: '/bin/sh', args: [] }; + return this.cachedShellCommand; + } + + this.cachedShellCommand = null; + return null; + } + + /** + * Run the worktree initialization script + * Non-blocking - returns immediately after spawning + */ + async runInitScript(options: InitScriptOptions): Promise { + const { projectPath, worktreePath, branch, emitter } = options; + + const scriptPath = this.getInitScriptPath(projectPath); + + // Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY) + try { + await secureFs.access(scriptPath); + } catch { + logger.debug(`No init script found at ${scriptPath}`); + return; + } + + // Check if already run + if (await this.hasInitScriptRun(projectPath, branch)) { + logger.info(`Init script already ran for branch "${branch}", skipping`); + return; + } + + // Get shell command + const shellCmd = await this.findShellCommand(); + if (!shellCmd) { + const error = + process.platform === 'win32' + ? 'Git Bash not found. Please install Git for Windows to run init scripts.' + : 'No shell found (/bin/bash or /bin/sh)'; + logger.error(error); + + // Update metadata with error + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: new Date().toISOString(), + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error, + }); + + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error, + }); + return; + } + + logger.info(`Running init script for branch "${branch}" in ${worktreePath}`); + logger.debug(`Using shell: ${shellCmd.shell}`); + + // Update metadata to mark as running + const existingMetadata = await readWorktreeMetadata(projectPath, branch); await writeWorktreeMetadata(projectPath, branch, { branch, - createdAt: metadata?.createdAt || new Date().toISOString(), - pr: metadata?.pr, - initScriptRan: true, - initScriptStatus: status, - initScriptError: success ? undefined : `Exit code: ${code}`, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: false, + initScriptStatus: 'running', }); - // Emit completion event - emitter.emit('worktree:init-completed', { + // Emit started event + emitter.emit('worktree:init-started', { projectPath, worktreePath, branch, - success, - exitCode: code, }); - }); - child.on('error', async (error) => { - logger.error(`Init script error for branch "${branch}":`, error); + // Spawn the script + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: { + ...process.env, + // Provide useful env vars to the script + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + // Git colors + GIT_TERMINAL_PROMPT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); - // Update metadata + // Stream stdout + child.stdout?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stdout', + content, + }); + }); + + // Stream stderr + child.stderr?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stderr', + content, + }); + }); + + // Handle completion + child.on('exit', async (code) => { + const success = code === 0; + const status = success ? 'success' : 'failed'; + + logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: status, + initScriptError: success ? undefined : `Exit code: ${code}`, + }); + + // Emit completion event + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success, + exitCode: code, + }); + }); + + child.on('error', async (error) => { + logger.error(`Init script error for branch "${branch}":`, error); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error.message, + }); + + // Emit completion with error + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error: error.message, + }); + }); + } + + /** + * Force re-run the worktree initialization script + * Ignores the initScriptRan flag - useful for testing or re-setup + */ + async forceRunInitScript(options: InitScriptOptions): Promise { + const { projectPath, branch } = options; + + // Reset the initScriptRan flag so the script will run const metadata = await readWorktreeMetadata(projectPath, branch); - await writeWorktreeMetadata(projectPath, branch, { - branch, - createdAt: metadata?.createdAt || new Date().toISOString(), - pr: metadata?.pr, - initScriptRan: true, - initScriptStatus: 'failed', - initScriptError: error.message, - }); + if (metadata) { + await writeWorktreeMetadata(projectPath, branch, { + ...metadata, + initScriptRan: false, + initScriptStatus: undefined, + initScriptError: undefined, + }); + } - // Emit completion with error - emitter.emit('worktree:init-completed', { - projectPath, - worktreePath, - branch, - success: false, - error: error.message, - }); - }); + // Now run the script + await this.runInitScript(options); + } } + +// Singleton instance for convenience +let initScriptService: InitScriptService | null = null; + +/** + * Get the singleton InitScriptService instance + */ +export function getInitScriptService(): InitScriptService { + if (!initScriptService) { + initScriptService = new InitScriptService(); + } + return initScriptService; +} + +// Export convenience functions that use the singleton +export const getInitScriptPath = (projectPath: string) => + getInitScriptService().getInitScriptPath(projectPath); + +export const hasInitScriptRun = (projectPath: string, branch: string) => + getInitScriptService().hasInitScriptRun(projectPath, branch); + +export const runInitScript = (options: InitScriptOptions) => + getInitScriptService().runInitScript(options); + +export const forceRunInitScript = (options: InitScriptOptions) => + getInitScriptService().forceRunInitScript(options); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index c6542256..12f39188 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -21,6 +21,7 @@ import { MessageSquare, GitMerge, AlertCircle, + RefreshCw, } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; @@ -50,6 +51,8 @@ interface WorktreeActionsDropdownProps { onStartDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onRunInitScript: (worktree: WorktreeInfo) => void; + hasInitScript: boolean; } export function WorktreeActionsDropdown({ @@ -76,6 +79,8 @@ export function WorktreeActionsDropdown({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onRunInitScript, + hasInitScript, }: WorktreeActionsDropdownProps) { // Check if there's a PR associated with this worktree from stored metadata const hasPR = !!worktree.pr; @@ -204,6 +209,12 @@ export function WorktreeActionsDropdown({ Open in {defaultEditorName} + {!worktree.isMain && hasInitScript && ( + onRunInitScript(worktree)} className="text-xs"> + + Re-run Init Script + + )} {worktree.hasChanges && ( void; onStopDevServer: (worktree: WorktreeInfo) => void; onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onRunInitScript: (worktree: WorktreeInfo) => void; + hasInitScript: boolean; } export function WorktreeTab({ @@ -87,6 +89,8 @@ export function WorktreeTab({ onStartDevServer, onStopDevServer, onOpenDevServerUrl, + onRunInitScript, + hasInitScript, }: WorktreeTabProps) { let prBadge: JSX.Element | null = null; if (worktree.pr) { @@ -336,6 +340,8 @@ export function WorktreeTab({ onStartDevServer={onStartDevServer} onStopDevServer={onStopDevServer} onOpenDevServerUrl={onOpenDevServerUrl} + onRunInitScript={onRunInitScript} + hasInitScript={hasInitScript} /> ); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index b56f65e1..5b903116 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { Button } from '@/components/ui/button'; import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { cn, pathsEqual } from '@/lib/utils'; +import { toast } from 'sonner'; +import { getHttpApiClient } from '@/lib/http-api-client'; import type { WorktreePanelProps, WorktreeInfo } from './types'; import { useWorktrees, @@ -82,6 +84,28 @@ export function WorktreePanel({ features, }); + // Track whether init script exists for the project + const [hasInitScript, setHasInitScript] = useState(false); + + useEffect(() => { + if (!projectPath) { + setHasInitScript(false); + return; + } + + const checkInitScript = async () => { + try { + const api = getHttpApiClient(); + const result = await api.worktree.getInitScript(projectPath); + setHasInitScript(result.success && result.exists); + } catch { + setHasInitScript(false); + } + }; + + checkInitScript(); + }, [projectPath]); + // Periodic interval check (5 seconds) to detect branch changes on disk // Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders const intervalRef = useRef(null); @@ -116,6 +140,33 @@ export function WorktreePanel({ } }; + const handleRunInitScript = useCallback( + async (worktree: WorktreeInfo) => { + if (!projectPath) return; + + try { + const api = getHttpApiClient(); + const result = await api.worktree.runInitScript( + projectPath, + worktree.path, + worktree.branch + ); + + if (!result.success) { + toast.error('Failed to run init script', { + description: result.error, + }); + } + // Success feedback will come via WebSocket events (init-started, init-output, init-completed) + } catch (error) { + toast.error('Failed to run init script', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [projectPath] + ); + const mainWorktree = worktrees.find((w) => w.isMain); const nonMainWorktrees = worktrees.filter((w) => !w.isMain); @@ -166,6 +217,8 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onRunInitScript={handleRunInitScript} + hasInitScript={hasInitScript} /> )} @@ -221,6 +274,8 @@ export function WorktreePanel({ onStartDevServer={handleStartDevServer} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} + onRunInitScript={handleRunInitScript} + hasInitScript={hasInitScript} /> ); })} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 2b52a2ac..58db6776 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1716,6 +1716,47 @@ function createMockWorktreeAPI(): WorktreeAPI { }, }; }, + + getInitScript: async (projectPath: string) => { + console.log('[Mock] Getting init script:', { projectPath }); + return { + success: true, + exists: false, + content: '', + path: `${projectPath}/.automaker/worktree-init.sh`, + }; + }, + + setInitScript: async (projectPath: string, content: string) => { + console.log('[Mock] Setting init script:', { projectPath, content }); + return { + success: true, + path: `${projectPath}/.automaker/worktree-init.sh`, + }; + }, + + deleteInitScript: async (projectPath: string) => { + console.log('[Mock] Deleting init script:', { projectPath }); + return { + success: true, + }; + }, + + runInitScript: async (projectPath: string, worktreePath: string, branch: string) => { + console.log('[Mock] Running init script:', { projectPath, worktreePath, branch }); + return { + success: true, + message: 'Init script started (mock)', + }; + }, + + onInitScriptEvent: (callback) => { + console.log('[Mock] Subscribing to init script events'); + // Return unsubscribe function + return () => { + console.log('[Mock] Unsubscribing from init script events'); + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 7e81703c..f6f6e266 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -499,7 +499,10 @@ type EventType = | 'issue-validation:event' | 'backlog-plan:event' | 'ideation:stream' - | 'ideation:analysis'; + | 'ideation:analysis' + | 'worktree:init-started' + | 'worktree:init-output' + | 'worktree:init-completed'; type EventCallback = (payload: unknown) => void; @@ -825,13 +828,14 @@ export class HttpApiClient implements ElectronAPI { return response.json(); } - private async httpDelete(endpoint: string): Promise { + private async httpDelete(endpoint: string, body?: unknown): Promise { // Ensure API key is initialized before making request await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'DELETE', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth + body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401 || response.status === 403) { @@ -1609,12 +1613,13 @@ export class HttpApiClient implements ElectronAPI { getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), // Init script methods - getInitScript: (projectPath: string) => - this.post('/api/worktree/init-script', { projectPath }), + getInitScript: (projectPath: string) => this.post('/api/worktree/init-script', { projectPath }), setInitScript: (projectPath: string, content: string) => this.put('/api/worktree/init-script', { projectPath, content }), deleteInitScript: (projectPath: string) => - this.delete('/api/worktree/init-script', { projectPath }), + this.httpDelete('/api/worktree/init-script', { projectPath }), + runInitScript: (projectPath: string, worktreePath: string, branch: string) => + this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), onInitScriptEvent: ( callback: (event: { type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index fc64f375..ca31a8ad 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -976,6 +976,50 @@ export interface WorktreeAPI { }; error?: string; }>; + + // Get init script content for a project + getInitScript: (projectPath: string) => Promise<{ + success: boolean; + exists: boolean; + content: string; + path: string; + error?: string; + }>; + + // Set init script content for a project + setInitScript: ( + projectPath: string, + content: string + ) => Promise<{ + success: boolean; + path?: string; + error?: string; + }>; + + // Delete init script for a project + deleteInitScript: (projectPath: string) => Promise<{ + success: boolean; + error?: string; + }>; + + // Run (or re-run) init script for a worktree + runInitScript: ( + projectPath: string, + worktreePath: string, + branch: string + ) => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + + // Subscribe to init script events + onInitScriptEvent: ( + callback: (event: { + type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; + payload: unknown; + }) => void + ) => () => void; } export interface GitAPI { diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 7e4f2474..570311e0 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -96,6 +96,7 @@ export { getCodexCliPaths, getCodexConfigDir, getCodexAuthPath, + getGitBashPaths, getOpenCodeCliPaths, getOpenCodeConfigDir, getOpenCodeAuthPath, @@ -129,6 +130,7 @@ export { findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, + findGitBashPath, findOpenCodeCliPath, getOpenCodeAuthIndicators, type OpenCodeAuthIndicators, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index c1faee26..3f1a1f4b 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -232,6 +232,57 @@ export function getClaudeProjectsDir(): string { return path.join(getClaudeConfigDir(), 'projects'); } +/** + * Get common Git Bash installation paths on Windows + * Git Bash is needed for running shell scripts cross-platform + */ +export function getGitBashPaths(): string[] { + if (process.platform !== 'win32') { + return []; + } + + const homeDir = os.homedir(); + return [ + // Standard Git for Windows installations + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // User-local installations + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'), + // Scoop package manager + path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), + // Chocolatey + path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'lib', + 'git', + 'tools', + 'bin', + 'bash.exe' + ), + // winget typical location + path.join( + process.env.LOCALAPPDATA || '', + 'Microsoft', + 'WinGet', + 'Packages', + 'Git.Git_*', + 'bin', + 'bash.exe' + ), + // GitHub Desktop bundled Git + path.join( + process.env.LOCALAPPDATA || '', + 'GitHubDesktop', + 'app-*', + 'resources', + 'app', + 'git', + 'cmd', + 'bash.exe' + ), + ].filter(Boolean); +} + /** * Get common shell paths for shell detection * Includes both full paths and short names to match $SHELL or PATH entries @@ -550,6 +601,8 @@ function getAllAllowedSystemPaths(): string[] { getOpenCodeAuthPath(), // Shell paths ...getShellPaths(), + // Git Bash paths (for Windows cross-platform shell script execution) + ...getGitBashPaths(), // Node.js system paths ...getNodeSystemPaths(), getScoopNodePath(), @@ -883,6 +936,13 @@ export async function findCodexCliPath(): Promise { return findFirstExistingPath(getCodexCliPaths()); } +/** + * Find Git Bash on Windows and return its path + */ +export async function findGitBashPath(): Promise { + return findFirstExistingPath(getGitBashPaths()); +} + /** * Get Claude authentication status by checking various indicators */