diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index 199f61c9..b7f55dd6 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -6,6 +6,9 @@ import * as fs from "fs/promises"; import * as path from "path"; +/** Maximum length for sanitized branch names in filesystem paths */ +const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200; + export interface WorktreePRInfo { number: number; url: string; @@ -36,7 +39,7 @@ function sanitizeBranchName(branch: string): string { .replace(/^-|-$/g, ""); // Remove leading/trailing dashes // Truncate to safe length (leave room for path components) - safeBranch = safeBranch.substring(0, 200); + safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH); // Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index afe42e7a..a41e0123 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -14,9 +14,87 @@ import { import { FeatureLoader } from "../../services/feature-loader.js"; const logger = createLogger("Worktree"); -const execAsync = promisify(exec); +export const execAsync = promisify(exec); const featureLoader = new FeatureLoader(); +// ============================================================================ +// Constants +// ============================================================================ + +/** Maximum allowed length for git branch names */ +export const MAX_BRANCH_NAME_LENGTH = 250; + +// ============================================================================ +// Extended PATH configuration for Electron apps +// ============================================================================ + +const pathSeparator = process.platform === "win32" ? ";" : ":"; +const additionalPaths: string[] = []; + +if (process.platform === "win32") { + // Windows paths + if (process.env.LOCALAPPDATA) { + additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + } + if (process.env.PROGRAMFILES) { + additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + } + if (process.env["ProgramFiles(x86)"]) { + additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`); + } +} else { + // Unix/Mac paths + additionalPaths.push( + "/opt/homebrew/bin", // Homebrew on Apple Silicon + "/usr/local/bin", // Homebrew on Intel Mac, common Linux location + "/home/linuxbrew/.linuxbrew/bin", // Linuxbrew + `${process.env.HOME}/.local/bin`, // pipx, other user installs + ); +} + +const extendedPath = [ + process.env.PATH, + ...additionalPaths.filter(Boolean), +].filter(Boolean).join(pathSeparator); + +/** + * Environment variables with extended PATH for executing shell commands. + * Electron apps don't inherit the user's shell PATH, so we need to add + * common tool installation locations. + */ +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +// ============================================================================ +// Validation utilities +// ============================================================================ + +/** + * Validate branch name to prevent command injection. + * Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars. + * We also reject shell metacharacters for safety. + */ +export function isValidBranchName(name: string): boolean { + return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH; +} + +/** + * Check if gh CLI is available on the system + */ +export async function isGhCliAvailable(): Promise { + try { + const checkCommand = process.platform === "win32" + ? "where gh" + : "command -v gh"; + await execAsync(checkCommand, { env: execEnv }); + return true; + } catch { + return false; + } +} + export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = "chore: automaker initial commit"; diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index d2e22535..488fa3b5 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -3,67 +3,16 @@ */ import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import { + getErrorMessage, + logError, + execAsync, + execEnv, + isValidBranchName, + isGhCliAvailable, +} from "../common.js"; import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js"; -// Shell escaping utility to prevent command injection -function shellEscape(arg: string): string { - if (process.platform === "win32") { - // Windows CMD shell escaping - return `"${arg.replace(/"/g, '""')}"`; - } else { - // Unix shell escaping - return `'${arg.replace(/'/g, "'\\''")}'`; - } -} - -// Validate branch name to prevent command injection -function isValidBranchName(name: string): boolean { - // Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars - // Also reject shell metacharacters for safety - return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250; -} - -const execAsync = promisify(exec); - -// Extended PATH to include common tool installation locations -// This is needed because Electron apps don't inherit the user's shell PATH -const pathSeparator = process.platform === "win32" ? ";" : ":"; -const additionalPaths: string[] = []; - -if (process.platform === "win32") { - // Windows paths - if (process.env.LOCALAPPDATA) { - additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); - } - if (process.env.PROGRAMFILES) { - additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); - } - if (process.env["ProgramFiles(x86)"]) { - additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`); - } -} else { - // Unix/Mac paths - additionalPaths.push( - "/opt/homebrew/bin", // Homebrew on Apple Silicon - "/usr/local/bin", // Homebrew on Intel Mac, common Linux location - "/home/linuxbrew/.linuxbrew/bin", // Linuxbrew - `${process.env.HOME}/.local/bin`, // pipx, other user installs - ); -} - -const extendedPath = [ - process.env.PATH, - ...additionalPaths.filter(Boolean), -].filter(Boolean).join(pathSeparator); - -const execEnv = { - ...process.env, - PATH: extendedPath, -}; - export function createCreatePRHandler() { return async (req: Request, res: Response): Promise => { try { @@ -244,15 +193,7 @@ export function createCreatePRHandler() { } // Check if gh CLI is available (cross-platform) - try { - const checkCommand = process.platform === "win32" - ? "where gh" - : "command -v gh"; - await execAsync(checkCommand, { env: execEnv }); - ghCliAvailable = true; - } catch { - ghCliAvailable = false; - } + ghCliAvailable = await isGhCliAvailable(); // Construct browser URL for PR creation if (repoUrl) { diff --git a/apps/server/src/routes/worktree/routes/pr-info.ts b/apps/server/src/routes/worktree/routes/pr-info.ts index 179266be..779e81cb 100644 --- a/apps/server/src/routes/worktree/routes/pr-info.ts +++ b/apps/server/src/routes/worktree/routes/pr-info.ts @@ -3,53 +3,14 @@ */ import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; - -const execAsync = promisify(exec); - -// Extended PATH to include common tool installation locations -const pathSeparator = process.platform === "win32" ? ";" : ":"; -const additionalPaths: string[] = []; - -if (process.platform === "win32") { - if (process.env.LOCALAPPDATA) { - additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); - } - if (process.env.PROGRAMFILES) { - additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); - } - if (process.env["ProgramFiles(x86)"]) { - additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`); - } -} else { - additionalPaths.push( - "/opt/homebrew/bin", - "/usr/local/bin", - "/home/linuxbrew/.linuxbrew/bin", - `${process.env.HOME}/.local/bin`, - ); -} - -const extendedPath = [ - process.env.PATH, - ...additionalPaths.filter(Boolean), -].filter(Boolean).join(pathSeparator); - -const execEnv = { - ...process.env, - PATH: extendedPath, -}; - -/** - * Validate branch name to prevent command injection. - * Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars. - * We also reject shell metacharacters for safety. - */ -function isValidBranchName(name: string): boolean { - return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250; -} +import { + getErrorMessage, + logError, + execAsync, + execEnv, + isValidBranchName, + isGhCliAvailable, +} from "../common.js"; export interface PRComment { id: number; @@ -98,16 +59,7 @@ export function createPRInfoHandler() { } // Check if gh CLI is available - let ghCliAvailable = false; - try { - const checkCommand = process.platform === "win32" - ? "where gh" - : "command -v gh"; - await execAsync(checkCommand, { env: execEnv }); - ghCliAvailable = true; - } catch { - ghCliAvailable = false; - } + const ghCliAvailable = await isGhCliAvailable(); if (!ghCliAvailable) { res.json({ diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 89540726..b33c95d2 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -58,6 +58,9 @@ const EMPTY_WORKTREES: ReturnType< ReturnType["getWorktrees"] > = []; +/** Delay before starting a newly created feature to allow state to settle */ +const FEATURE_CREATION_SETTLE_DELAY_MS = 500; + export function BoardView() { const { currentProject, @@ -458,7 +461,7 @@ export function BoardView() { if (newFeature) { await handleStartImplementation(newFeature); } - }, 500); + }, FEATURE_CREATION_SETTLE_DELAY_MS); }, [handleAddFeature, handleStartImplementation, defaultSkipTests] ); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index dfb27104..8258e980 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -130,7 +130,6 @@ export function WorktreeTab({ } })(); - const prTitle = worktree.pr.title || `Pull Request #${worktree.pr.number}`; const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`; // Helper to get status icon color for the selected state