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:
Kacper
2026-01-10 22:36:50 +01:00
parent 05d96a7d6e
commit 6c412cd367
11 changed files with 602 additions and 220 deletions

View File

@@ -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);