mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
feat: Add run init script functionality for worktrees
This commit introduces the ability to run initialization scripts for worktrees, enhancing the setup process. Key changes include: 1. **New API Endpoint**: Added a POST endpoint to run the init script for a specified worktree. 2. **Worktree Routes**: Updated worktree routes to include the new run init script handler. 3. **Init Script Service**: Enhanced the Init Script Service to support running scripts asynchronously and handling errors. 4. **UI Updates**: Added UI components to check for the existence of init scripts and trigger their execution, providing user feedback through toast notifications. 5. **Event Handling**: Implemented event handling for init script execution status, allowing real-time updates in the UI. This feature streamlines the workflow for users by automating the execution of setup scripts, improving overall project management.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<ShellCommand | null> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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({
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
{!worktree.isMain && hasInitScript && (
|
||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
Re-run Init Script
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
|
||||
@@ -46,6 +46,8 @@ interface WorktreeTabProps {
|
||||
onStartDevServer: (worktree: WorktreeInfo) => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,6 +274,8 @@ export function WorktreePanel({
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(endpoint: string): Promise<T> {
|
||||
private async httpDelete<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// 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';
|
||||
|
||||
44
apps/ui/src/types/electron.d.ts
vendored
44
apps/ui/src/types/electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null> {
|
||||
return findFirstExistingPath(getCodexCliPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Git Bash on Windows and return its path
|
||||
*/
|
||||
export async function findGitBashPath(): Promise<string | null> {
|
||||
return findFirstExistingPath(getGitBashPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude authentication status by checking various indicators
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user