diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index af7aadef..7853fbd2 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -9,45 +9,59 @@ * - Chat: Full tool access for interactive coding * * Uses model-resolver for consistent model handling across the application. + * + * SECURITY: All factory functions validate the working directory (cwd) against + * ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized + * security check that applies to ALL AI model invocations, regardless of provider. */ -import type { Options } from "@anthropic-ai/claude-agent-sdk"; -import { resolveModelString } from "@automaker/model-resolver"; -import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from "@automaker/types"; +import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import path from 'path'; +import { resolveModelString } from '@automaker/model-resolver'; +import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types'; +import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; + +/** + * Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY. + * This is the centralized security check for ALL AI model invocations. + * + * @param cwd - The working directory to validate + * @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY + * + * This function is called by all create*Options() factory functions to ensure + * that AI models can only operate within allowed directories. This applies to: + * - All current models (Claude, future models) + * - All invocation types (chat, auto-mode, spec generation, etc.) + */ +export function validateWorkingDirectory(cwd: string): void { + const resolvedCwd = path.resolve(cwd); + + if (!isPathAllowed(resolvedCwd)) { + const allowedRoot = getAllowedRootDirectory(); + throw new PathNotAllowedError( + `Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` + + (allowedRoot + ? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` + : 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.') + ); + } +} /** * Tool presets for different use cases */ export const TOOL_PRESETS = { /** Read-only tools for analysis */ - readOnly: ["Read", "Glob", "Grep"] as const, + readOnly: ['Read', 'Glob', 'Grep'] as const, /** Tools for spec generation that needs to read the codebase */ - specGeneration: ["Read", "Glob", "Grep"] as const, + specGeneration: ['Read', 'Glob', 'Grep'] as const, /** Full tool access for feature implementation */ - fullAccess: [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ] as const, + fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const, /** Tools for chat/interactive mode */ - chat: [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ] as const, + chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const, } as const; /** @@ -78,7 +92,7 @@ export const MAX_TURNS = { * - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations */ export function getModelForUseCase( - useCase: "spec" | "features" | "suggestions" | "chat" | "auto" | "default", + useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default', explicitModel?: string ): string { // Explicit model takes precedence @@ -102,12 +116,12 @@ export function getModelForUseCase( } const defaultModels: Record = { - spec: CLAUDE_MODEL_MAP["haiku"], // used to generate app specs - features: CLAUDE_MODEL_MAP["haiku"], // used to generate features from app specs - suggestions: CLAUDE_MODEL_MAP["haiku"], // used for suggestions - chat: CLAUDE_MODEL_MAP["haiku"], // used for chat - auto: CLAUDE_MODEL_MAP["opus"], // used to implement kanban cards - default: CLAUDE_MODEL_MAP["opus"], + spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs + features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs + suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions + chat: CLAUDE_MODEL_MAP['haiku'], // used for chat + auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards + default: CLAUDE_MODEL_MAP['opus'], }; return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude); @@ -118,7 +132,7 @@ export function getModelForUseCase( */ function getBaseOptions(): Partial { return { - permissionMode: "acceptEdits", + permissionMode: 'acceptEdits', }; } @@ -143,7 +157,7 @@ export interface CreateSdkOptionsConfig { /** Optional output format for structured outputs */ outputFormat?: { - type: "json_schema"; + type: 'json_schema'; schema: Record; }; } @@ -156,16 +170,17 @@ export interface CreateSdkOptionsConfig { * - Extended turns for thorough exploration * - Opus model by default (can be overridden) */ -export function createSpecGenerationOptions( - config: CreateSdkOptionsConfig -): Options { +export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + return { ...getBaseOptions(), // Override permissionMode - spec generation only needs read-only tools // Using "acceptEdits" can cause Claude to write files to unexpected locations // See: https://github.com/AutoMaker-Org/automaker/issues/149 - permissionMode: "default", - model: getModelForUseCase("spec", config.model), + permissionMode: 'default', + model: getModelForUseCase('spec', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.specGeneration], @@ -183,14 +198,15 @@ export function createSpecGenerationOptions( * - Quick turns since it's mostly JSON generation * - Sonnet model by default for speed */ -export function createFeatureGenerationOptions( - config: CreateSdkOptionsConfig -): Options { +export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + return { ...getBaseOptions(), // Override permissionMode - feature generation only needs read-only tools - permissionMode: "default", - model: getModelForUseCase("features", config.model), + permissionMode: 'default', + model: getModelForUseCase('features', config.model), maxTurns: MAX_TURNS.quick, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], @@ -207,12 +223,13 @@ export function createFeatureGenerationOptions( * - Standard turns to allow thorough codebase exploration and structured output generation * - Opus model by default for thorough analysis */ -export function createSuggestionsOptions( - config: CreateSdkOptionsConfig -): Options { +export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + return { ...getBaseOptions(), - model: getModelForUseCase("suggestions", config.model), + model: getModelForUseCase('suggestions', config.model), maxTurns: MAX_TURNS.extended, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], @@ -232,12 +249,15 @@ export function createSuggestionsOptions( * - Sandbox enabled for bash safety */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + // Model priority: explicit model > session model > chat default const effectiveModel = config.model || config.sessionModel; return { ...getBaseOptions(), - model: getModelForUseCase("chat", effectiveModel), + model: getModelForUseCase('chat', effectiveModel), maxTurns: MAX_TURNS.standard, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.chat], @@ -260,9 +280,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { * - Sandbox enabled for bash safety */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + return { ...getBaseOptions(), - model: getModelForUseCase("auto", config.model), + model: getModelForUseCase('auto', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.fullAccess], @@ -287,14 +310,15 @@ export function createCustomOptions( sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; } ): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + return { ...getBaseOptions(), - model: getModelForUseCase("default", config.model), + model: getModelForUseCase('default', config.model), maxTurns: config.maxTurns ?? MAX_TURNS.maximum, cwd: config.cwd, - allowedTools: config.allowedTools - ? [...config.allowedTools] - : [...TOOL_PRESETS.readOnly], + allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly], ...(config.sandbox && { sandbox: config.sandbox }), ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index b7f55dd6..edeadc5b 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -3,8 +3,8 @@ * Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json */ -import * as fs from "fs/promises"; -import * as path from "path"; +import * as secureFs from './secure-fs.js'; +import * as path from 'path'; /** Maximum length for sanitized branch names in filesystem paths */ const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200; @@ -32,11 +32,11 @@ function sanitizeBranchName(branch: string): string { // - Windows invalid chars: : * ? " < > | // - Other potentially problematic chars let safeBranch = branch - .replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash - .replace(/\s+/g, "_") // Replace spaces with underscores - .replace(/\.+$/g, "") // Remove trailing dots (Windows issue) - .replace(/-+/g, "-") // Collapse multiple dashes - .replace(/^-|-$/g, ""); // Remove leading/trailing dashes + .replace(/[/\\:*?"<>|]/g, '-') // Replace invalid chars with dash + .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/\.+$/g, '') // Remove trailing dots (Windows issue) + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^-|-$/g, ''); // Remove leading/trailing dashes // Truncate to safe length (leave room for path components) safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH); @@ -44,7 +44,7 @@ function sanitizeBranchName(branch: string): string { // 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; if (windowsReserved.test(safeBranch) || safeBranch.length === 0) { - safeBranch = `_${safeBranch || "branch"}`; + safeBranch = `_${safeBranch || 'branch'}`; } return safeBranch; @@ -55,14 +55,14 @@ function sanitizeBranchName(branch: string): string { */ function getWorktreeMetadataDir(projectPath: string, branch: string): string { const safeBranch = sanitizeBranchName(branch); - return path.join(projectPath, ".automaker", "worktrees", safeBranch); + return path.join(projectPath, '.automaker', 'worktrees', safeBranch); } /** * Get the path to the worktree metadata file */ function getWorktreeMetadataPath(projectPath: string, branch: string): string { - return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json"); + return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json'); } /** @@ -74,7 +74,7 @@ export async function readWorktreeMetadata( ): Promise { try { const metadataPath = getWorktreeMetadataPath(projectPath, branch); - const content = await fs.readFile(metadataPath, "utf-8"); + const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string; return JSON.parse(content) as WorktreeMetadata; } catch (error) { // File doesn't exist or can't be read @@ -94,10 +94,10 @@ export async function writeWorktreeMetadata( const metadataPath = getWorktreeMetadataPath(projectPath, branch); // Ensure directory exists - await fs.mkdir(metadataDir, { recursive: true }); + await secureFs.mkdir(metadataDir, { recursive: true }); // Write metadata - await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); + await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); } /** @@ -143,16 +143,16 @@ export async function readAllWorktreeMetadata( projectPath: string ): Promise> { const result = new Map(); - const worktreesDir = path.join(projectPath, ".automaker", "worktrees"); + const worktreesDir = path.join(projectPath, '.automaker', 'worktrees'); try { - const dirs = await fs.readdir(worktreesDir, { withFileTypes: true }); + const dirs = await secureFs.readdir(worktreesDir, { withFileTypes: true }); for (const dir of dirs) { if (dir.isDirectory()) { - const metadataPath = path.join(worktreesDir, dir.name, "worktree.json"); + const metadataPath = path.join(worktreesDir, dir.name, 'worktree.json'); try { - const content = await fs.readFile(metadataPath, "utf-8"); + const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string; const metadata = JSON.parse(content) as WorktreeMetadata; result.set(metadata.branch, metadata); } catch { @@ -170,13 +170,10 @@ export async function readAllWorktreeMetadata( /** * Delete worktree metadata for a branch */ -export async function deleteWorktreeMetadata( - projectPath: string, - branch: string -): Promise { +export async function deleteWorktreeMetadata(projectPath: string, branch: string): Promise { const metadataDir = getWorktreeMetadataDir(projectPath, branch); try { - await fs.rm(metadataDir, { recursive: true, force: true }); + await secureFs.rm(metadataDir, { recursive: true, force: true }); } catch { // Ignore errors if directory doesn't exist } diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index bbce5d07..17a83078 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -2,16 +2,16 @@ * Generate features from existing app_spec.txt */ -import { query } from "@anthropic-ai/claude-agent-sdk"; -import fs from "fs/promises"; -import type { EventEmitter } from "../../lib/events.js"; -import { createLogger } from "@automaker/utils"; -import { createFeatureGenerationOptions } from "../../lib/sdk-options.js"; -import { logAuthStatus } from "./common.js"; -import { parseAndCreateFeatures } from "./parse-and-create-features.js"; -import { getAppSpecPath } from "@automaker/platform"; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { createFeatureGenerationOptions } from '../../lib/sdk-options.js'; +import { logAuthStatus } from './common.js'; +import { parseAndCreateFeatures } from './parse-and-create-features.js'; +import { getAppSpecPath } from '@automaker/platform'; -const logger = createLogger("SpecRegeneration"); +const logger = createLogger('SpecRegeneration'); const DEFAULT_MAX_FEATURES = 50; @@ -22,28 +22,26 @@ export async function generateFeaturesFromSpec( maxFeatures?: number ): Promise { const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES; - logger.debug("========== generateFeaturesFromSpec() started =========="); - logger.debug("projectPath:", projectPath); - logger.debug("maxFeatures:", featureCount); + logger.debug('========== generateFeaturesFromSpec() started =========='); + logger.debug('projectPath:', projectPath); + logger.debug('maxFeatures:', featureCount); // Read existing spec from .automaker directory const specPath = getAppSpecPath(projectPath); let spec: string; - logger.debug("Reading spec from:", specPath); + logger.debug('Reading spec from:', specPath); try { - spec = await fs.readFile(specPath, "utf-8"); + spec = (await secureFs.readFile(specPath, 'utf-8')) as string; logger.info(`Spec loaded successfully (${spec.length} chars)`); logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`); - logger.info( - `Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}` - ); + logger.info(`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`); } catch (readError) { - logger.error("❌ Failed to read spec file:", readError); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_error", - error: "No project spec found. Generate spec first.", + logger.error('❌ Failed to read spec file:', readError); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: 'No project spec found. Generate spec first.', projectPath: projectPath, }); return; @@ -82,16 +80,14 @@ Generate ${featureCount} features that build on each other logically. IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`; - logger.info("========== PROMPT BEING SENT =========="); + logger.info('========== PROMPT BEING SENT =========='); logger.info(`Prompt length: ${prompt.length} chars`); - logger.info( - `Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}` - ); - logger.info("========== END PROMPT PREVIEW =========="); + logger.info(`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`); + logger.info('========== END PROMPT PREVIEW =========='); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_progress", - content: "Analyzing spec and generating features...\n", + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Analyzing spec and generating features...\n', projectPath: projectPath, }); @@ -100,73 +96,67 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge abortController, }); - logger.debug("SDK Options:", JSON.stringify(options, null, 2)); - logger.info("Calling Claude Agent SDK query() for features..."); + logger.debug('SDK Options:', JSON.stringify(options, null, 2)); + logger.info('Calling Claude Agent SDK query() for features...'); - logAuthStatus("Right before SDK query() for features"); + logAuthStatus('Right before SDK query() for features'); let stream; try { stream = query({ prompt, options }); - logger.debug("query() returned stream successfully"); + logger.debug('query() returned stream successfully'); } catch (queryError) { - logger.error("❌ query() threw an exception:"); - logger.error("Error:", queryError); + logger.error('❌ query() threw an exception:'); + logger.error('Error:', queryError); throw queryError; } - let responseText = ""; + let responseText = ''; let messageCount = 0; - logger.debug("Starting to iterate over feature stream..."); + logger.debug('Starting to iterate over feature stream...'); try { for await (const msg of stream) { messageCount++; logger.debug( `Feature stream message #${messageCount}:`, - JSON.stringify( - { type: msg.type, subtype: (msg as any).subtype }, - null, - 2 - ) + JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2) ); - if (msg.type === "assistant" && msg.message.content) { + if (msg.type === 'assistant' && msg.message.content) { for (const block of msg.message.content) { - if (block.type === "text") { + if (block.type === 'text') { responseText += block.text; - logger.debug( - `Feature text block received (${block.text.length} chars)` - ); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_progress", + logger.debug(`Feature text block received (${block.text.length} chars)`); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', content: block.text, projectPath: projectPath, }); } } - } else if (msg.type === "result" && (msg as any).subtype === "success") { - logger.debug("Received success result for features"); + } else if (msg.type === 'result' && (msg as any).subtype === 'success') { + logger.debug('Received success result for features'); responseText = (msg as any).result || responseText; - } else if ((msg as { type: string }).type === "error") { - logger.error("❌ Received error message from feature stream:"); - logger.error("Error message:", JSON.stringify(msg, null, 2)); + } else if ((msg as { type: string }).type === 'error') { + logger.error('❌ Received error message from feature stream:'); + logger.error('Error message:', JSON.stringify(msg, null, 2)); } } } catch (streamError) { - logger.error("❌ Error while iterating feature stream:"); - logger.error("Stream error:", streamError); + logger.error('❌ Error while iterating feature stream:'); + logger.error('Stream error:', streamError); throw streamError; } logger.info(`Feature stream complete. Total messages: ${messageCount}`); logger.info(`Feature response length: ${responseText.length} chars`); - logger.info("========== FULL RESPONSE TEXT =========="); + logger.info('========== FULL RESPONSE TEXT =========='); logger.info(responseText); - logger.info("========== END RESPONSE TEXT =========="); + logger.info('========== END RESPONSE TEXT =========='); await parseAndCreateFeatures(projectPath, responseText, events); - logger.debug("========== generateFeaturesFromSpec() completed =========="); + logger.debug('========== generateFeaturesFromSpec() completed =========='); } diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 4f15ae2f..4b6a6426 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -2,23 +2,23 @@ * Generate app_spec.txt from project overview */ -import { query } from "@anthropic-ai/claude-agent-sdk"; -import path from "path"; -import fs from "fs/promises"; -import type { EventEmitter } from "../../lib/events.js"; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import path from 'path'; +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; import { specOutputSchema, specToXml, getStructuredSpecPromptInstruction, type SpecOutput, -} from "../../lib/app-spec-format.js"; -import { createLogger } from "@automaker/utils"; -import { createSpecGenerationOptions } from "../../lib/sdk-options.js"; -import { logAuthStatus } from "./common.js"; -import { generateFeaturesFromSpec } from "./generate-features-from-spec.js"; -import { ensureAutomakerDir, getAppSpecPath } from "@automaker/platform"; +} from '../../lib/app-spec-format.js'; +import { createLogger } from '@automaker/utils'; +import { createSpecGenerationOptions } from '../../lib/sdk-options.js'; +import { logAuthStatus } from './common.js'; +import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; +import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; -const logger = createLogger("SpecRegeneration"); +const logger = createLogger('SpecRegeneration'); export async function generateSpec( projectPath: string, @@ -29,17 +29,17 @@ export async function generateSpec( analyzeProject?: boolean, maxFeatures?: number ): Promise { - logger.info("========== generateSpec() started =========="); - logger.info("projectPath:", projectPath); - logger.info("projectOverview length:", `${projectOverview.length} chars`); - logger.info("projectOverview preview:", projectOverview.substring(0, 300)); - logger.info("generateFeatures:", generateFeatures); - logger.info("analyzeProject:", analyzeProject); - logger.info("maxFeatures:", maxFeatures); + logger.info('========== generateSpec() started =========='); + logger.info('projectPath:', projectPath); + logger.info('projectOverview length:', `${projectOverview.length} chars`); + logger.info('projectOverview preview:', projectOverview.substring(0, 300)); + logger.info('generateFeatures:', generateFeatures); + logger.info('analyzeProject:', analyzeProject); + logger.info('maxFeatures:', maxFeatures); // Build the prompt based on whether we should analyze the project - let analysisInstructions = ""; - let techStackDefaults = ""; + let analysisInstructions = ''; + let techStackDefaults = ''; if (analyzeProject !== false) { // Default to true - analyze the project @@ -73,114 +73,110 @@ ${analysisInstructions} ${getStructuredSpecPromptInstruction()}`; - logger.info("========== PROMPT BEING SENT =========="); + logger.info('========== PROMPT BEING SENT =========='); logger.info(`Prompt length: ${prompt.length} chars`); logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`); - logger.info("========== END PROMPT PREVIEW =========="); + logger.info('========== END PROMPT PREVIEW =========='); - events.emit("spec-regeneration:event", { - type: "spec_progress", - content: "Starting spec generation...\n", + events.emit('spec-regeneration:event', { + type: 'spec_progress', + content: 'Starting spec generation...\n', }); const options = createSpecGenerationOptions({ cwd: projectPath, abortController, outputFormat: { - type: "json_schema", + type: 'json_schema', schema: specOutputSchema, }, }); - logger.debug("SDK Options:", JSON.stringify(options, null, 2)); - logger.info("Calling Claude Agent SDK query()..."); + logger.debug('SDK Options:', JSON.stringify(options, null, 2)); + logger.info('Calling Claude Agent SDK query()...'); // Log auth status right before the SDK call - logAuthStatus("Right before SDK query()"); + logAuthStatus('Right before SDK query()'); let stream; try { stream = query({ prompt, options }); - logger.debug("query() returned stream successfully"); + logger.debug('query() returned stream successfully'); } catch (queryError) { - logger.error("❌ query() threw an exception:"); - logger.error("Error:", queryError); + logger.error('❌ query() threw an exception:'); + logger.error('Error:', queryError); throw queryError; } - let responseText = ""; + let responseText = ''; let messageCount = 0; let structuredOutput: SpecOutput | null = null; - logger.info("Starting to iterate over stream..."); + logger.info('Starting to iterate over stream...'); try { for await (const msg of stream) { messageCount++; logger.info( - `Stream message #${messageCount}: type=${msg.type}, subtype=${ - (msg as any).subtype - }` + `Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}` ); - if (msg.type === "assistant") { + if (msg.type === 'assistant') { const msgAny = msg as any; if (msgAny.message?.content) { for (const block of msgAny.message.content) { - if (block.type === "text") { + if (block.type === 'text') { responseText += block.text; logger.info( `Text block received (${block.text.length} chars), total now: ${responseText.length} chars` ); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_progress", + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', content: block.text, projectPath: projectPath, }); - } else if (block.type === "tool_use") { - logger.info("Tool use:", block.name); - events.emit("spec-regeneration:event", { - type: "spec_tool", + } else if (block.type === 'tool_use') { + logger.info('Tool use:', block.name); + events.emit('spec-regeneration:event', { + type: 'spec_tool', tool: block.name, input: block.input, }); } } } - } else if (msg.type === "result" && (msg as any).subtype === "success") { - logger.info("Received success result"); + } else if (msg.type === 'result' && (msg as any).subtype === 'success') { + logger.info('Received success result'); // Check for structured output - this is the reliable way to get spec data const resultMsg = msg as any; if (resultMsg.structured_output) { structuredOutput = resultMsg.structured_output as SpecOutput; - logger.info("✅ Received structured output"); - logger.debug("Structured output:", JSON.stringify(structuredOutput, null, 2)); + logger.info('✅ Received structured output'); + logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); } else { - logger.warn("⚠️ No structured output in result, will fall back to text parsing"); + logger.warn('⚠️ No structured output in result, will fall back to text parsing'); } - } else if (msg.type === "result") { + } else if (msg.type === 'result') { // Handle error result types const subtype = (msg as any).subtype; logger.info(`Result message: subtype=${subtype}`); - if (subtype === "error_max_turns") { - logger.error("❌ Hit max turns limit!"); - } else if (subtype === "error_max_structured_output_retries") { - logger.error("❌ Failed to produce valid structured output after retries"); - throw new Error("Could not produce valid spec output"); + if (subtype === 'error_max_turns') { + logger.error('❌ Hit max turns limit!'); + } else if (subtype === 'error_max_structured_output_retries') { + logger.error('❌ Failed to produce valid structured output after retries'); + throw new Error('Could not produce valid spec output'); } - } else if ((msg as { type: string }).type === "error") { - logger.error("❌ Received error message from stream:"); - logger.error("Error message:", JSON.stringify(msg, null, 2)); - } else if (msg.type === "user") { + } else if ((msg as { type: string }).type === 'error') { + logger.error('❌ Received error message from stream:'); + logger.error('Error message:', JSON.stringify(msg, null, 2)); + } else if (msg.type === 'user') { // Log user messages (tool results) - logger.info( - `User message (tool result): ${JSON.stringify(msg).substring(0, 500)}` - ); + logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`); } } } catch (streamError) { - logger.error("❌ Error while iterating stream:"); - logger.error("Stream error:", streamError); + logger.error('❌ Error while iterating stream:'); + logger.error('Stream error:', streamError); throw streamError; } @@ -192,40 +188,42 @@ ${getStructuredSpecPromptInstruction()}`; if (structuredOutput) { // Use structured output - convert JSON to XML - logger.info("✅ Using structured output for XML generation"); + logger.info('✅ Using structured output for XML generation'); xmlContent = specToXml(structuredOutput); logger.info(`Generated XML from structured output: ${xmlContent.length} chars`); } else { // Fallback: Extract XML content from response text // Claude might include conversational text before/after // See: https://github.com/AutoMaker-Org/automaker/issues/149 - logger.warn("⚠️ No structured output, falling back to text parsing"); - logger.info("========== FINAL RESPONSE TEXT =========="); - logger.info(responseText || "(empty)"); - logger.info("========== END RESPONSE TEXT =========="); + logger.warn('⚠️ No structured output, falling back to text parsing'); + logger.info('========== FINAL RESPONSE TEXT =========='); + logger.info(responseText || '(empty)'); + logger.info('========== END RESPONSE TEXT =========='); if (!responseText || responseText.trim().length === 0) { - throw new Error("No response text and no structured output - cannot generate spec"); + throw new Error('No response text and no structured output - cannot generate spec'); } - const xmlStart = responseText.indexOf(""); - const xmlEnd = responseText.lastIndexOf(""); + const xmlStart = responseText.indexOf(''); + const xmlEnd = responseText.lastIndexOf(''); if (xmlStart !== -1 && xmlEnd !== -1) { // Extract just the XML content, discarding any conversational text before/after - xmlContent = responseText.substring(xmlStart, xmlEnd + "".length); + xmlContent = responseText.substring(xmlStart, xmlEnd + ''.length); logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); } else { // No valid XML structure found in the response text // This happens when structured output was expected but not received, and the agent // output conversational text instead of XML (e.g., "The project directory appears to be empty...") // We should NOT save this conversational text as it's not a valid spec - logger.error("❌ Response does not contain valid XML structure"); - logger.error("This typically happens when structured output failed and the agent produced conversational text instead of XML"); + logger.error('❌ Response does not contain valid XML structure'); + logger.error( + 'This typically happens when structured output failed and the agent produced conversational text instead of XML' + ); throw new Error( - "Failed to generate spec: No valid XML structure found in response. " + - "The response contained conversational text but no tags. " + - "Please try again." + 'Failed to generate spec: No valid XML structure found in response. ' + + 'The response contained conversational text but no tags. ' + + 'Please try again.' ); } } @@ -234,60 +232,55 @@ ${getStructuredSpecPromptInstruction()}`; await ensureAutomakerDir(projectPath); const specPath = getAppSpecPath(projectPath); - logger.info("Saving spec to:", specPath); + logger.info('Saving spec to:', specPath); logger.info(`Content to save (${xmlContent.length} chars)`); - await fs.writeFile(specPath, xmlContent); + await secureFs.writeFile(specPath, xmlContent); // Verify the file was written - const savedContent = await fs.readFile(specPath, "utf-8"); + const savedContent = await secureFs.readFile(specPath, 'utf-8'); logger.info(`Verified saved file: ${savedContent.length} chars`); if (savedContent.length === 0) { - logger.error("❌ File was saved but is empty!"); + logger.error('❌ File was saved but is empty!'); } - logger.info("Spec saved successfully"); + logger.info('Spec saved successfully'); // Emit spec completion event if (generateFeatures) { // If features will be generated, emit intermediate completion - events.emit("spec-regeneration:event", { - type: "spec_regeneration_progress", - content: "[Phase: spec_complete] Spec created! Generating features...\n", + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: '[Phase: spec_complete] Spec created! Generating features...\n', projectPath: projectPath, }); } else { // If no features, emit final completion - events.emit("spec-regeneration:event", { - type: "spec_regeneration_complete", - message: "Spec regeneration complete!", + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', + message: 'Spec regeneration complete!', projectPath: projectPath, }); } // If generate features was requested, generate them from the spec if (generateFeatures) { - logger.info("Starting feature generation from spec..."); + logger.info('Starting feature generation from spec...'); // Create a new abort controller for feature generation const featureAbortController = new AbortController(); try { - await generateFeaturesFromSpec( - projectPath, - events, - featureAbortController, - maxFeatures - ); + await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures); // Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures } catch (featureError) { - logger.error("Feature generation failed:", featureError); + logger.error('Feature generation failed:', featureError); // Don't throw - spec generation succeeded, feature generation is optional - events.emit("spec-regeneration:event", { - type: "spec_regeneration_error", - error: (featureError as Error).message || "Feature generation failed", + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: (featureError as Error).message || 'Feature generation failed', projectPath: projectPath, }); } } - logger.debug("========== generateSpec() completed =========="); + logger.debug('========== generateSpec() completed =========='); } diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 27516d95..364f64ad 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -2,71 +2,71 @@ * Parse agent response and create feature files */ -import path from "path"; -import fs from "fs/promises"; -import type { EventEmitter } from "../../lib/events.js"; -import { createLogger } from "@automaker/utils"; -import { getFeaturesDir } from "@automaker/platform"; +import path from 'path'; +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { getFeaturesDir } from '@automaker/platform'; -const logger = createLogger("SpecRegeneration"); +const logger = createLogger('SpecRegeneration'); export async function parseAndCreateFeatures( projectPath: string, content: string, events: EventEmitter ): Promise { - logger.info("========== parseAndCreateFeatures() started =========="); + logger.info('========== parseAndCreateFeatures() started =========='); logger.info(`Content length: ${content.length} chars`); - logger.info("========== CONTENT RECEIVED FOR PARSING =========="); + logger.info('========== CONTENT RECEIVED FOR PARSING =========='); logger.info(content); - logger.info("========== END CONTENT =========="); + logger.info('========== END CONTENT =========='); try { // Extract JSON from response - logger.info("Extracting JSON from response..."); + logger.info('Extracting JSON from response...'); logger.info(`Looking for pattern: /{[\\s\\S]*"features"[\\s\\S]*}/`); const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/); if (!jsonMatch) { - logger.error("❌ No valid JSON found in response"); - logger.error("Full content received:"); + logger.error('❌ No valid JSON found in response'); + logger.error('Full content received:'); logger.error(content); - throw new Error("No valid JSON found in response"); + throw new Error('No valid JSON found in response'); } logger.info(`JSON match found (${jsonMatch[0].length} chars)`); - logger.info("========== MATCHED JSON =========="); + logger.info('========== MATCHED JSON =========='); logger.info(jsonMatch[0]); - logger.info("========== END MATCHED JSON =========="); + logger.info('========== END MATCHED JSON =========='); const parsed = JSON.parse(jsonMatch[0]); logger.info(`Parsed ${parsed.features?.length || 0} features`); - logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2)); + logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2)); const featuresDir = getFeaturesDir(projectPath); - await fs.mkdir(featuresDir, { recursive: true }); + await secureFs.mkdir(featuresDir, { recursive: true }); const createdFeatures: Array<{ id: string; title: string }> = []; for (const feature of parsed.features) { - logger.debug("Creating feature:", feature.id); + logger.debug('Creating feature:', feature.id); const featureDir = path.join(featuresDir, feature.id); - await fs.mkdir(featureDir, { recursive: true }); + await secureFs.mkdir(featureDir, { recursive: true }); const featureData = { id: feature.id, - category: feature.category || "Uncategorized", + category: feature.category || 'Uncategorized', title: feature.title, description: feature.description, - status: "backlog", // Features go to backlog - user must manually start them + status: 'backlog', // Features go to backlog - user must manually start them priority: feature.priority || 2, - complexity: feature.complexity || "moderate", + complexity: feature.complexity || 'moderate', dependencies: feature.dependencies || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; - await fs.writeFile( - path.join(featureDir, "feature.json"), + await secureFs.writeFile( + path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2) ); @@ -75,20 +75,20 @@ export async function parseAndCreateFeatures( logger.info(`✓ Created ${createdFeatures.length} features successfully`); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_complete", + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', message: `Spec regeneration complete! Created ${createdFeatures.length} features.`, projectPath: projectPath, }); } catch (error) { - logger.error("❌ parseAndCreateFeatures() failed:"); - logger.error("Error:", error); - events.emit("spec-regeneration:event", { - type: "spec_regeneration_error", + logger.error('❌ parseAndCreateFeatures() failed:'); + logger.error('Error:', error); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', error: (error as Error).message, projectPath: projectPath, }); } - logger.debug("========== parseAndCreateFeatures() completed =========="); + logger.debug('========== parseAndCreateFeatures() completed =========='); } diff --git a/apps/server/src/routes/fs/routes/browse.ts b/apps/server/src/routes/fs/routes/browse.ts index c003fafc..c3cd4c65 100644 --- a/apps/server/src/routes/fs/routes/browse.ts +++ b/apps/server/src/routes/fs/routes/browse.ts @@ -2,16 +2,12 @@ * POST /browse endpoint - Browse directories for file browser UI */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import os from "os"; -import path from "path"; -import { - getAllowedRootDirectory, - isPathAllowed, - PathNotAllowedError, -} from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import os from 'os'; +import path from 'path'; +import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createBrowseHandler() { return async (req: Request, res: Response): Promise => { @@ -22,24 +18,19 @@ export function createBrowseHandler() { const defaultPath = getAllowedRootDirectory() || os.homedir(); const targetPath = dirPath ? path.resolve(dirPath) : defaultPath; - // Validate that the path is allowed - if (!isPathAllowed(targetPath)) { - throw new PathNotAllowedError(dirPath || targetPath); - } - // Detect available drives on Windows const detectDrives = async (): Promise => { - if (os.platform() !== "win32") { + if (os.platform() !== 'win32') { return []; } const drives: string[] = []; - const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; for (const letter of letters) { const drivePath = `${letter}:\\`; try { - await fs.access(drivePath); + await secureFs.access(drivePath); drives.push(drivePath); } catch { // Drive doesn't exist, skip it @@ -57,21 +48,19 @@ export function createBrowseHandler() { const drives = await detectDrives(); try { - const stats = await fs.stat(targetPath); + const stats = await secureFs.stat(targetPath); if (!stats.isDirectory()) { - res - .status(400) - .json({ success: false, error: "Path is not a directory" }); + res.status(400).json({ success: false, error: 'Path is not a directory' }); return; } // Read directory contents - const entries = await fs.readdir(targetPath, { withFileTypes: true }); + const entries = await secureFs.readdir(targetPath, { withFileTypes: true }); // Filter for directories only and add parent directory option const directories = entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry) => ({ name: entry.name, path: path.join(targetPath, entry.name), @@ -87,10 +76,8 @@ export function createBrowseHandler() { }); } catch (error) { // Handle permission errors gracefully - still return path info so user can navigate away - const errorMessage = - error instanceof Error ? error.message : "Failed to read directory"; - const isPermissionError = - errorMessage.includes("EPERM") || errorMessage.includes("EACCES"); + const errorMessage = error instanceof Error ? error.message : 'Failed to read directory'; + const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES'); if (isPermissionError) { // Return success with empty directories so user can still navigate to parent @@ -101,7 +88,7 @@ export function createBrowseHandler() { directories: [], drives, warning: - "Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security", + 'Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security', }); } else { res.status(400).json({ @@ -117,7 +104,7 @@ export function createBrowseHandler() { return; } - logError(error, "Browse directories failed"); + logError(error, 'Browse directories failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/delete-board-background.ts b/apps/server/src/routes/fs/routes/delete-board-background.ts index 2a7b6099..3f053f2c 100644 --- a/apps/server/src/routes/fs/routes/delete-board-background.ts +++ b/apps/server/src/routes/fs/routes/delete-board-background.ts @@ -2,11 +2,11 @@ * POST /delete-board-background endpoint - Delete board background image */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; -import { getBoardDir } from "@automaker/platform"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import { getBoardDir } from '@automaker/platform'; export function createDeleteBoardBackgroundHandler() { return async (req: Request, res: Response): Promise => { @@ -16,7 +16,7 @@ export function createDeleteBoardBackgroundHandler() { if (!projectPath) { res.status(400).json({ success: false, - error: "projectPath is required", + error: 'projectPath is required', }); return; } @@ -26,10 +26,10 @@ export function createDeleteBoardBackgroundHandler() { try { // Try to remove all background files in the board directory - const files = await fs.readdir(boardDir); + const files = await secureFs.readdir(boardDir); for (const file of files) { - if (file.startsWith("background")) { - await fs.unlink(path.join(boardDir, file)); + if (file.startsWith('background')) { + await secureFs.unlink(path.join(boardDir, file)); } } } catch { @@ -38,7 +38,7 @@ export function createDeleteBoardBackgroundHandler() { res.json({ success: true }); } catch (error) { - logError(error, "Delete board background failed"); + logError(error, 'Delete board background failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/delete.ts b/apps/server/src/routes/fs/routes/delete.ts index 93d2f027..ffb40444 100644 --- a/apps/server/src/routes/fs/routes/delete.ts +++ b/apps/server/src/routes/fs/routes/delete.ts @@ -2,10 +2,10 @@ * POST /delete endpoint - Delete file */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import { validatePath, PathNotAllowedError } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createDeleteHandler() { return async (req: Request, res: Response): Promise => { @@ -13,12 +13,11 @@ export function createDeleteHandler() { const { filePath } = req.body as { filePath: string }; if (!filePath) { - res.status(400).json({ success: false, error: "filePath is required" }); + res.status(400).json({ success: false, error: 'filePath is required' }); return; } - const resolvedPath = validatePath(filePath); - await fs.rm(resolvedPath, { recursive: true }); + await secureFs.rm(filePath, { recursive: true }); res.json({ success: true }); } catch (error) { @@ -28,7 +27,7 @@ export function createDeleteHandler() { return; } - logError(error, "Delete file failed"); + logError(error, 'Delete file failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/exists.ts b/apps/server/src/routes/fs/routes/exists.ts index 86b27ea3..88050889 100644 --- a/apps/server/src/routes/fs/routes/exists.ts +++ b/apps/server/src/routes/fs/routes/exists.ts @@ -2,11 +2,10 @@ * POST /exists endpoint - Check if file/directory exists */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { isPathAllowed, PathNotAllowedError } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createExistsHandler() { return async (req: Request, res: Response): Promise => { @@ -14,21 +13,18 @@ export function createExistsHandler() { const { filePath } = req.body as { filePath: string }; if (!filePath) { - res.status(400).json({ success: false, error: "filePath is required" }); + res.status(400).json({ success: false, error: 'filePath is required' }); return; } - const resolvedPath = path.resolve(filePath); - - // Validate that the path is allowed - if (!isPathAllowed(resolvedPath)) { - throw new PathNotAllowedError(filePath); - } - try { - await fs.access(resolvedPath); + await secureFs.access(filePath); res.json({ success: true, exists: true }); - } catch { + } catch (accessError) { + // Check if it's a path not allowed error vs file not existing + if (accessError instanceof PathNotAllowedError) { + throw accessError; + } res.json({ success: true, exists: false }); } } catch (error) { @@ -38,7 +34,7 @@ export function createExistsHandler() { return; } - logError(error, "Check exists failed"); + logError(error, 'Check exists failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/image.ts b/apps/server/src/routes/fs/routes/image.ts index eddf5aed..b7e8c214 100644 --- a/apps/server/src/routes/fs/routes/image.ts +++ b/apps/server/src/routes/fs/routes/image.ts @@ -2,10 +2,11 @@ * GET /image endpoint - Serve image files */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createImageHandler() { return async (req: Request, res: Response): Promise => { @@ -16,7 +17,7 @@ export function createImageHandler() { }; if (!imagePath) { - res.status(400).json({ success: false, error: "path is required" }); + res.status(400).json({ success: false, error: 'path is required' }); return; } @@ -24,40 +25,41 @@ export function createImageHandler() { const fullPath = path.isAbsolute(imagePath) ? imagePath : projectPath - ? path.join(projectPath, imagePath) - : imagePath; + ? path.join(projectPath, imagePath) + : imagePath; // Check if file exists try { - await fs.access(fullPath); - } catch { - res.status(404).json({ success: false, error: "Image not found" }); + await secureFs.access(fullPath); + } catch (accessError) { + if (accessError instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: 'Path not allowed' }); + return; + } + res.status(404).json({ success: false, error: 'Image not found' }); return; } // Read the file - const buffer = await fs.readFile(fullPath); + const buffer = await secureFs.readFile(fullPath); // Determine MIME type from extension const ext = path.extname(fullPath).toLowerCase(); const mimeTypes: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', }; - res.setHeader( - "Content-Type", - mimeTypes[ext] || "application/octet-stream" - ); - res.setHeader("Cache-Control", "public, max-age=3600"); + res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=3600'); res.send(buffer); } catch (error) { - logError(error, "Serve image failed"); + logError(error, 'Serve image failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/mkdir.ts b/apps/server/src/routes/fs/routes/mkdir.ts index aee9d281..04d0a836 100644 --- a/apps/server/src/routes/fs/routes/mkdir.ts +++ b/apps/server/src/routes/fs/routes/mkdir.ts @@ -3,11 +3,11 @@ * Handles symlinks safely to avoid ELOOP errors */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { isPathAllowed, PathNotAllowedError } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createMkdirHandler() { return async (req: Request, res: Response): Promise => { @@ -15,20 +15,15 @@ export function createMkdirHandler() { const { dirPath } = req.body as { dirPath: string }; if (!dirPath) { - res.status(400).json({ success: false, error: "dirPath is required" }); + res.status(400).json({ success: false, error: 'dirPath is required' }); return; } const resolvedPath = path.resolve(dirPath); - // Validate that the path is allowed - if (!isPathAllowed(resolvedPath)) { - throw new PathNotAllowedError(dirPath); - } - // Check if path already exists using lstat (doesn't follow symlinks) try { - const stats = await fs.lstat(resolvedPath); + const stats = await secureFs.lstat(resolvedPath); // Path exists - if it's a directory or symlink, consider it success if (stats.isDirectory() || stats.isSymbolicLink()) { res.json({ success: true }); @@ -37,19 +32,19 @@ export function createMkdirHandler() { // It's a file - can't create directory res.status(400).json({ success: false, - error: "Path exists and is not a directory", + error: 'Path exists and is not a directory', }); return; } catch (statError: any) { // ENOENT means path doesn't exist - we should create it - if (statError.code !== "ENOENT") { + if (statError.code !== 'ENOENT') { // Some other error (could be ELOOP in parent path) throw statError; } } // Path doesn't exist, create it - await fs.mkdir(resolvedPath, { recursive: true }); + await secureFs.mkdir(resolvedPath, { recursive: true }); res.json({ success: true }); } catch (error: any) { @@ -60,15 +55,15 @@ export function createMkdirHandler() { } // Handle ELOOP specifically - if (error.code === "ELOOP") { - logError(error, "Create directory failed - symlink loop detected"); + if (error.code === 'ELOOP') { + logError(error, 'Create directory failed - symlink loop detected'); res.status(400).json({ success: false, - error: "Cannot create directory: symlink loop detected in path", + error: 'Cannot create directory: symlink loop detected in path', }); return; } - logError(error, "Create directory failed"); + logError(error, 'Create directory failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/read.ts b/apps/server/src/routes/fs/routes/read.ts index f485b7d9..27ce45b4 100644 --- a/apps/server/src/routes/fs/routes/read.ts +++ b/apps/server/src/routes/fs/routes/read.ts @@ -2,26 +2,21 @@ * POST /read endpoint - Read file */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import { validatePath, PathNotAllowedError } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; // Optional files that are expected to not exist in new projects // Don't log ENOENT errors for these to reduce noise -const OPTIONAL_FILES = ["categories.json", "app_spec.txt"]; +const OPTIONAL_FILES = ['categories.json', 'app_spec.txt']; function isOptionalFile(filePath: string): boolean { return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile)); } function isENOENT(error: unknown): boolean { - return ( - error !== null && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ); + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'; } export function createReadHandler() { @@ -30,12 +25,11 @@ export function createReadHandler() { const { filePath } = req.body as { filePath: string }; if (!filePath) { - res.status(400).json({ success: false, error: "filePath is required" }); + res.status(400).json({ success: false, error: 'filePath is required' }); return; } - const resolvedPath = validatePath(filePath); - const content = await fs.readFile(resolvedPath, "utf-8"); + const content = await secureFs.readFile(filePath, 'utf-8'); res.json({ success: true, content }); } catch (error) { @@ -46,9 +40,9 @@ export function createReadHandler() { } // Don't log ENOENT errors for optional files (expected to be missing in new projects) - const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || "")); + const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || '')); if (shouldLog) { - logError(error, "Read file failed"); + logError(error, 'Read file failed'); } res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/fs/routes/readdir.ts b/apps/server/src/routes/fs/routes/readdir.ts index 1b686610..43932778 100644 --- a/apps/server/src/routes/fs/routes/readdir.ts +++ b/apps/server/src/routes/fs/routes/readdir.ts @@ -2,10 +2,10 @@ * POST /readdir endpoint - Read directory */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import { validatePath, PathNotAllowedError } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createReaddirHandler() { return async (req: Request, res: Response): Promise => { @@ -13,12 +13,11 @@ export function createReaddirHandler() { const { dirPath } = req.body as { dirPath: string }; if (!dirPath) { - res.status(400).json({ success: false, error: "dirPath is required" }); + res.status(400).json({ success: false, error: 'dirPath is required' }); return; } - const resolvedPath = validatePath(dirPath); - const entries = await fs.readdir(resolvedPath, { withFileTypes: true }); + const entries = await secureFs.readdir(dirPath, { withFileTypes: true }); const result = entries.map((entry) => ({ name: entry.name, @@ -34,7 +33,7 @@ export function createReaddirHandler() { return; } - logError(error, "Read directory failed"); + logError(error, 'Read directory failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/resolve-directory.ts b/apps/server/src/routes/fs/routes/resolve-directory.ts index ca882aba..5e4147db 100644 --- a/apps/server/src/routes/fs/routes/resolve-directory.ts +++ b/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -2,10 +2,10 @@ * POST /resolve-directory endpoint - Resolve directory path from directory name */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; export function createResolveDirectoryHandler() { return async (req: Request, res: Response): Promise => { @@ -17,9 +17,7 @@ export function createResolveDirectoryHandler() { }; if (!directoryName) { - res - .status(400) - .json({ success: false, error: "directoryName is required" }); + res.status(400).json({ success: false, error: 'directoryName is required' }); return; } @@ -27,7 +25,7 @@ export function createResolveDirectoryHandler() { if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) { try { const resolvedPath = path.resolve(directoryName); - const stats = await fs.stat(resolvedPath); + const stats = await secureFs.stat(resolvedPath); if (stats.isDirectory()) { res.json({ success: true, @@ -43,17 +41,11 @@ export function createResolveDirectoryHandler() { // Search for directory in common locations const searchPaths: string[] = [ process.cwd(), // Current working directory - process.env.HOME || process.env.USERPROFILE || "", // User home - path.join( - process.env.HOME || process.env.USERPROFILE || "", - "Documents" - ), - path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"), + process.env.HOME || process.env.USERPROFILE || '', // User home + path.join(process.env.HOME || process.env.USERPROFILE || '', 'Documents'), + path.join(process.env.HOME || process.env.USERPROFILE || '', 'Desktop'), // Common project locations - path.join( - process.env.HOME || process.env.USERPROFILE || "", - "Projects" - ), + path.join(process.env.HOME || process.env.USERPROFILE || '', 'Projects'), ].filter(Boolean); // Also check parent of current working directory @@ -70,7 +62,7 @@ export function createResolveDirectoryHandler() { for (const searchPath of searchPaths) { try { const candidatePath = path.join(searchPath, directoryName); - const stats = await fs.stat(candidatePath); + const stats = await secureFs.stat(candidatePath); if (stats.isDirectory()) { // Verify it matches by checking for sample files @@ -78,15 +70,15 @@ export function createResolveDirectoryHandler() { let matches = 0; for (const sampleFile of sampleFiles.slice(0, 5)) { // Remove directory name prefix from sample file path - const relativeFile = sampleFile.startsWith(directoryName + "/") + const relativeFile = sampleFile.startsWith(directoryName + '/') ? sampleFile.substring(directoryName.length + 1) - : sampleFile.split("/").slice(1).join("/") || - sampleFile.split("/").pop() || + : sampleFile.split('/').slice(1).join('/') || + sampleFile.split('/').pop() || sampleFile; try { const filePath = path.join(candidatePath, relativeFile); - await fs.access(filePath); + await secureFs.access(filePath); matches++; } catch { // File doesn't exist, continue checking @@ -118,7 +110,7 @@ export function createResolveDirectoryHandler() { error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`, }); } catch (error) { - logError(error, "Resolve directory failed"); + logError(error, 'Resolve directory failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/save-board-background.ts b/apps/server/src/routes/fs/routes/save-board-background.ts index d3ac0fb1..e8988c6c 100644 --- a/apps/server/src/routes/fs/routes/save-board-background.ts +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -2,11 +2,11 @@ * POST /save-board-background endpoint - Save board background image */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; -import { getBoardDir } from "@automaker/platform"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import { getBoardDir } from '@automaker/platform'; export function createSaveBoardBackgroundHandler() { return async (req: Request, res: Response): Promise => { @@ -21,31 +21,31 @@ export function createSaveBoardBackgroundHandler() { if (!data || !filename || !projectPath) { res.status(400).json({ success: false, - error: "data, filename, and projectPath are required", + error: 'data, filename, and projectPath are required', }); return; } // Get board directory const boardDir = getBoardDir(projectPath); - await fs.mkdir(boardDir, { recursive: true }); + await secureFs.mkdir(boardDir, { recursive: true }); // Decode base64 data (remove data URL prefix if present) - const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); - const buffer = Buffer.from(base64Data, "base64"); + const base64Data = data.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); // Use a fixed filename for the board background (overwrite previous) - const ext = path.extname(filename) || ".png"; + const ext = path.extname(filename) || '.png'; const uniqueFilename = `background${ext}`; const filePath = path.join(boardDir, uniqueFilename); // Write file - await fs.writeFile(filePath, buffer); + await secureFs.writeFile(filePath, buffer); // Return the absolute path res.json({ success: true, path: filePath }); } catch (error) { - logError(error, "Save board background failed"); + logError(error, 'Save board background failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts index af87e014..059abfaf 100644 --- a/apps/server/src/routes/fs/routes/save-image.ts +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -2,11 +2,11 @@ * POST /save-image endpoint - Save image to .automaker images directory */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; -import { getImagesDir } from "@automaker/platform"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import { getImagesDir } from '@automaker/platform'; export function createSaveImageHandler() { return async (req: Request, res: Response): Promise => { @@ -21,33 +21,33 @@ export function createSaveImageHandler() { if (!data || !filename || !projectPath) { res.status(400).json({ success: false, - error: "data, filename, and projectPath are required", + error: 'data, filename, and projectPath are required', }); return; } // Get images directory const imagesDir = getImagesDir(projectPath); - await fs.mkdir(imagesDir, { recursive: true }); + await secureFs.mkdir(imagesDir, { recursive: true }); // Decode base64 data (remove data URL prefix if present) - const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); - const buffer = Buffer.from(base64Data, "base64"); + const base64Data = data.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); // Generate unique filename with timestamp const timestamp = Date.now(); - const ext = path.extname(filename) || ".png"; + const ext = path.extname(filename) || '.png'; const baseName = path.basename(filename, ext); const uniqueFilename = `${baseName}-${timestamp}${ext}`; const filePath = path.join(imagesDir, uniqueFilename); // Write file - await fs.writeFile(filePath, buffer); + await secureFs.writeFile(filePath, buffer); // Return the absolute path res.json({ success: true, path: filePath }); } catch (error) { - logError(error, "Save image failed"); + logError(error, 'Save image failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/stat.ts b/apps/server/src/routes/fs/routes/stat.ts index cd81cc74..f7df8109 100644 --- a/apps/server/src/routes/fs/routes/stat.ts +++ b/apps/server/src/routes/fs/routes/stat.ts @@ -2,10 +2,10 @@ * POST /stat endpoint - Get file stats */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import { validatePath, PathNotAllowedError } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createStatHandler() { return async (req: Request, res: Response): Promise => { @@ -13,12 +13,11 @@ export function createStatHandler() { const { filePath } = req.body as { filePath: string }; if (!filePath) { - res.status(400).json({ success: false, error: "filePath is required" }); + res.status(400).json({ success: false, error: 'filePath is required' }); return; } - const resolvedPath = validatePath(filePath); - const stats = await fs.stat(resolvedPath); + const stats = await secureFs.stat(filePath); res.json({ success: true, @@ -36,7 +35,7 @@ export function createStatHandler() { return; } - logError(error, "Get file stats failed"); + logError(error, 'Get file stats failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 24dcaf85..374fe18f 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -2,11 +2,11 @@ * POST /validate-path endpoint - Validate and add path to allowed list */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { isPathAllowed } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { isPathAllowed } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createValidatePathHandler() { return async (req: Request, res: Response): Promise => { @@ -14,7 +14,7 @@ export function createValidatePathHandler() { const { filePath } = req.body as { filePath: string }; if (!filePath) { - res.status(400).json({ success: false, error: "filePath is required" }); + res.status(400).json({ success: false, error: 'filePath is required' }); return; } @@ -22,12 +22,10 @@ export function createValidatePathHandler() { // Check if path exists try { - const stats = await fs.stat(resolvedPath); + const stats = await secureFs.stat(resolvedPath); if (!stats.isDirectory()) { - res - .status(400) - .json({ success: false, error: "Path is not a directory" }); + res.status(400).json({ success: false, error: 'Path is not a directory' }); return; } @@ -37,10 +35,10 @@ export function createValidatePathHandler() { isAllowed: isPathAllowed(resolvedPath), }); } catch { - res.status(400).json({ success: false, error: "Path does not exist" }); + res.status(400).json({ success: false, error: 'Path does not exist' }); } } catch (error) { - logError(error, "Validate path failed"); + logError(error, 'Validate path failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/routes/write.ts b/apps/server/src/routes/fs/routes/write.ts index c31eb63b..ad70cc9e 100644 --- a/apps/server/src/routes/fs/routes/write.ts +++ b/apps/server/src/routes/fs/routes/write.ts @@ -2,12 +2,12 @@ * POST /write endpoint - Write file */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { validatePath, PathNotAllowedError } from "@automaker/platform"; -import { mkdirSafe } from "@automaker/utils"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { mkdirSafe } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; export function createWriteHandler() { return async (req: Request, res: Response): Promise => { @@ -18,15 +18,13 @@ export function createWriteHandler() { }; if (!filePath) { - res.status(400).json({ success: false, error: "filePath is required" }); + res.status(400).json({ success: false, error: 'filePath is required' }); return; } - const resolvedPath = validatePath(filePath); - // Ensure parent directory exists (symlink-safe) - await mkdirSafe(path.dirname(resolvedPath)); - await fs.writeFile(resolvedPath, content, "utf-8"); + await mkdirSafe(path.dirname(path.resolve(filePath))); + await secureFs.writeFile(filePath, content, 'utf-8'); res.json({ success: true }); } catch (error) { @@ -36,7 +34,7 @@ export function createWriteHandler() { return; } - logError(error, "Write file failed"); + logError(error, 'Write file failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/templates/routes/clone.ts b/apps/server/src/routes/templates/routes/clone.ts index d8c7b6bd..5874a3ef 100644 --- a/apps/server/src/routes/templates/routes/clone.ts +++ b/apps/server/src/routes/templates/routes/clone.ts @@ -2,12 +2,12 @@ * POST /clone endpoint - Clone a GitHub template to a new project directory */ -import type { Request, Response } from "express"; -import { spawn } from "child_process"; -import path from "path"; -import fs from "fs/promises"; -import { isPathAllowed } from "@automaker/platform"; -import { logger, getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { spawn } from 'child_process'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { logger, getErrorMessage, logError } from '../common.js'; export function createCloneHandler() { return async (req: Request, res: Response): Promise => { @@ -22,7 +22,7 @@ export function createCloneHandler() { if (!repoUrl || !projectName || !parentDir) { res.status(400).json({ success: false, - error: "repoUrl, projectName, and parentDir are required", + error: 'repoUrl, projectName, and parentDir are required', }); return; } @@ -36,17 +36,15 @@ export function createCloneHandler() { if (!githubUrlPattern.test(repoUrl)) { res.status(400).json({ success: false, - error: "Invalid GitHub repository URL", + error: 'Invalid GitHub repository URL', }); return; } // Sanitize project name (allow alphanumeric, dash, underscore) - const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-"); + const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, '-'); if (sanitizedName !== projectName) { - logger.info( - `[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}` - ); + logger.info(`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`); } // Build full project path @@ -55,41 +53,30 @@ export function createCloneHandler() { const resolvedParent = path.resolve(parentDir); const resolvedProject = path.resolve(projectPath); const relativePath = path.relative(resolvedParent, resolvedProject); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { res.status(400).json({ success: false, - error: "Invalid project name; potential path traversal attempt.", + error: 'Invalid project name; potential path traversal attempt.', }); return; } - // Validate that parent directory is within allowed root directory - if (!isPathAllowed(resolvedParent)) { - res.status(403).json({ - success: false, - error: `Parent directory not allowed: ${parentDir}. Must be within ALLOWED_ROOT_DIRECTORY.`, - }); - return; - } - - // Validate that project path will be within allowed root directory - if (!isPathAllowed(resolvedProject)) { - res.status(403).json({ - success: false, - error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`, - }); - return; - } - - // Check if directory already exists + // Check if directory already exists (secureFs.access also validates path is allowed) try { - await fs.access(projectPath); + await secureFs.access(projectPath); res.status(400).json({ success: false, error: `Directory "${sanitizedName}" already exists in ${parentDir}`, }); return; - } catch { + } catch (accessError) { + if (accessError instanceof PathNotAllowedError) { + res.status(403).json({ + success: false, + error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`, + }); + return; + } // Directory doesn't exist, which is what we want } @@ -97,35 +84,33 @@ export function createCloneHandler() { try { // Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /) const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir); - const isUnixRoot = parentDir === "/" || parentDir === ""; + const isUnixRoot = parentDir === '/' || parentDir === ''; const isRoot = isWindowsRoot || isUnixRoot; if (isRoot) { // Root paths always exist, just verify access logger.info(`[Templates] Using root path: ${parentDir}`); - await fs.access(parentDir); + await secureFs.access(parentDir); } else { // Check if parent directory exists - const parentExists = await fs - .access(parentDir) - .then(() => true) - .catch(() => false); + let parentExists = false; + try { + await secureFs.access(parentDir); + parentExists = true; + } catch { + parentExists = false; + } if (!parentExists) { logger.info(`[Templates] Creating parent directory: ${parentDir}`); - await fs.mkdir(parentDir, { recursive: true }); + await secureFs.mkdir(parentDir, { recursive: true }); } else { logger.info(`[Templates] Parent directory exists: ${parentDir}`); } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.error( - "[Templates] Failed to access parent directory:", - parentDir, - error - ); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('[Templates] Failed to access parent directory:', parentDir, error); res.status(500).json({ success: false, error: `Failed to access parent directory: ${errorMessage}`, @@ -140,17 +125,17 @@ export function createCloneHandler() { success: boolean; error?: string; }>((resolve) => { - const gitProcess = spawn("git", ["clone", repoUrl, projectPath], { + const gitProcess = spawn('git', ['clone', repoUrl, projectPath], { cwd: parentDir, }); - let stderr = ""; + let stderr = ''; - gitProcess.stderr.on("data", (data) => { + gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); - gitProcess.on("close", (code) => { + gitProcess.on('close', (code) => { if (code === 0) { resolve({ success: true }); } else { @@ -161,7 +146,7 @@ export function createCloneHandler() { } }); - gitProcess.on("error", (error) => { + gitProcess.on('error', (error) => { resolve({ success: false, error: `Failed to spawn git: ${error.message}`, @@ -172,34 +157,34 @@ export function createCloneHandler() { if (!cloneResult.success) { res.status(500).json({ success: false, - error: cloneResult.error || "Failed to clone repository", + error: cloneResult.error || 'Failed to clone repository', }); return; } // Remove .git directory to start fresh try { - const gitDir = path.join(projectPath, ".git"); - await fs.rm(gitDir, { recursive: true, force: true }); - logger.info("[Templates] Removed .git directory"); + const gitDir = path.join(projectPath, '.git'); + await secureFs.rm(gitDir, { recursive: true, force: true }); + logger.info('[Templates] Removed .git directory'); } catch (error) { - logger.warn("[Templates] Could not remove .git directory:", error); + logger.warn('[Templates] Could not remove .git directory:', error); // Continue anyway - not critical } // Initialize a fresh git repository await new Promise((resolve) => { - const gitInit = spawn("git", ["init"], { + const gitInit = spawn('git', ['init'], { cwd: projectPath, }); - gitInit.on("close", () => { - logger.info("[Templates] Initialized fresh git repository"); + gitInit.on('close', () => { + logger.info('[Templates] Initialized fresh git repository'); resolve(); }); - gitInit.on("error", () => { - logger.warn("[Templates] Could not initialize git"); + gitInit.on('error', () => { + logger.warn('[Templates] Could not initialize git'); resolve(); }); }); @@ -212,7 +197,7 @@ export function createCloneHandler() { projectName: sanitizedName, }); } catch (error) { - logError(error, "Clone template failed"); + logError(error, 'Clone template failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/workspace/routes/config.ts b/apps/server/src/routes/workspace/routes/config.ts index 82063e08..5ea5cbee 100644 --- a/apps/server/src/routes/workspace/routes/config.ts +++ b/apps/server/src/routes/workspace/routes/config.ts @@ -2,14 +2,11 @@ * GET /config endpoint - Get workspace configuration status */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { - getAllowedRootDirectory, - getDataDirectory, -} from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createConfigHandler() { return async (_req: Request, res: Response): Promise => { @@ -30,12 +27,12 @@ export function createConfigHandler() { // Check if the directory exists try { const resolvedWorkspaceDir = path.resolve(allowedRootDirectory); - const stats = await fs.stat(resolvedWorkspaceDir); + const stats = await secureFs.stat(resolvedWorkspaceDir); if (!stats.isDirectory()) { res.json({ success: true, configured: false, - error: "ALLOWED_ROOT_DIRECTORY is not a valid directory", + error: 'ALLOWED_ROOT_DIRECTORY is not a valid directory', }); return; } @@ -50,11 +47,11 @@ export function createConfigHandler() { res.json({ success: true, configured: false, - error: "ALLOWED_ROOT_DIRECTORY path does not exist", + error: 'ALLOWED_ROOT_DIRECTORY path does not exist', }); } } catch (error) { - logError(error, "Get workspace config failed"); + logError(error, 'Get workspace config failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/workspace/routes/directories.ts b/apps/server/src/routes/workspace/routes/directories.ts index c4f26fec..09a66e1b 100644 --- a/apps/server/src/routes/workspace/routes/directories.ts +++ b/apps/server/src/routes/workspace/routes/directories.ts @@ -2,11 +2,11 @@ * GET /directories endpoint - List directories in workspace */ -import type { Request, Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { getAllowedRootDirectory } from "@automaker/platform"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getAllowedRootDirectory } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; export function createDirectoriesHandler() { return async (_req: Request, res: Response): Promise => { @@ -16,7 +16,7 @@ export function createDirectoriesHandler() { if (!allowedRootDirectory) { res.status(400).json({ success: false, - error: "ALLOWED_ROOT_DIRECTORY is not configured", + error: 'ALLOWED_ROOT_DIRECTORY is not configured', }); return; } @@ -25,23 +25,23 @@ export function createDirectoriesHandler() { // Check if directory exists try { - await fs.stat(resolvedWorkspaceDir); + await secureFs.stat(resolvedWorkspaceDir); } catch { res.status(400).json({ success: false, - error: "Workspace directory path does not exist", + error: 'Workspace directory path does not exist', }); return; } // Read directory contents - const entries = await fs.readdir(resolvedWorkspaceDir, { + const entries = await secureFs.readdir(resolvedWorkspaceDir, { withFileTypes: true, }); // Filter to directories only and map to result format const directories = entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry) => ({ name: entry.name, path: path.join(resolvedWorkspaceDir, entry.name), @@ -53,7 +53,7 @@ export function createDirectoriesHandler() { directories, }); } catch (error) { - logError(error, "List workspace directories failed"); + logError(error, 'List workspace directories failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 7273a09e..bc6e59ba 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -2,18 +2,14 @@ * Common utilities for worktree routes */ -import { createLogger } from "@automaker/utils"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import fs from "fs/promises"; -import { - getErrorMessage as getErrorMessageShared, - createLogError, -} from "../common.js"; -import { FeatureLoader } from "../../services/feature-loader.js"; +import { createLogger } from '@automaker/utils'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; -const logger = createLogger("Worktree"); +const logger = createLogger('Worktree'); export const execAsync = promisify(exec); const featureLoader = new FeatureLoader(); @@ -28,10 +24,10 @@ export const MAX_BRANCH_NAME_LENGTH = 250; // Extended PATH configuration for Electron apps // ============================================================================ -const pathSeparator = process.platform === "win32" ? ";" : ":"; +const pathSeparator = process.platform === 'win32' ? ';' : ':'; const additionalPaths: string[] = []; -if (process.platform === "win32") { +if (process.platform === 'win32') { // Windows paths if (process.env.LOCALAPPDATA) { additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); @@ -39,23 +35,22 @@ if (process.platform === "win32") { if (process.env.PROGRAMFILES) { additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); } - if (process.env["ProgramFiles(x86)"]) { - additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\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 + '/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 extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)] + .filter(Boolean) + .join(pathSeparator); /** * Environment variables with extended PATH for executing shell commands. @@ -85,9 +80,7 @@ export function isValidBranchName(name: string): boolean { */ export async function isGhCliAvailable(): Promise { try { - const checkCommand = process.platform === "win32" - ? "where gh" - : "command -v gh"; + const checkCommand = process.platform === 'win32' ? 'where gh' : 'command -v gh'; await execAsync(checkCommand, { env: execEnv }); return true; } catch { @@ -95,8 +88,7 @@ export async function isGhCliAvailable(): Promise { } } -export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = - "chore: automaker initial commit"; +export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = 'chore: automaker initial commit'; /** * Normalize path separators to forward slashes for cross-platform consistency. @@ -104,7 +96,7 @@ export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = * from git commands (which may use forward slashes). */ export function normalizePath(p: string): string { - return p.replace(/\\/g, "/"); + return p.replace(/\\/g, '/'); } /** @@ -112,7 +104,7 @@ export function normalizePath(p: string): string { */ export async function isGitRepo(repoPath: string): Promise { try { - await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath }); + await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath }); return true; } catch { return false; @@ -124,30 +116,21 @@ export async function isGitRepo(repoPath: string): Promise { * These are expected in test environments with mock paths */ export function isENOENT(error: unknown): boolean { - return ( - error !== null && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ); + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'; } /** * Check if a path is a mock/test path that doesn't exist */ export function isMockPath(worktreePath: string): boolean { - return worktreePath.startsWith("/mock/") || worktreePath.includes("/mock/"); + return worktreePath.startsWith('/mock/') || worktreePath.includes('/mock/'); } /** * Conditionally log worktree errors - suppress ENOENT for mock paths * to reduce noise in test output */ -export function logWorktreeError( - error: unknown, - message: string, - worktreePath?: string -): void { +export function logWorktreeError(error: unknown, message: string, worktreePath?: string): void { // Don't log ENOENT errors for mock paths (expected in tests) if (isENOENT(error) && worktreePath && isMockPath(worktreePath)) { return; @@ -165,17 +148,14 @@ export const logError = createLogError(logger); */ export async function ensureInitialCommit(repoPath: string): Promise { try { - await execAsync("git rev-parse --verify HEAD", { cwd: repoPath }); + await execAsync('git rev-parse --verify HEAD', { cwd: repoPath }); return false; } catch { try { - await execAsync( - `git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, - { cwd: repoPath } - ); - logger.info( - `[Worktree] Created initial empty commit to enable worktrees in ${repoPath}` - ); + await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, { + cwd: repoPath, + }); + logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`); return true; } catch (error) { const reason = getErrorMessageShared(error); diff --git a/apps/server/src/routes/worktree/routes/branch-tracking.ts b/apps/server/src/routes/worktree/routes/branch-tracking.ts index dc55cfc4..478ebc06 100644 --- a/apps/server/src/routes/worktree/routes/branch-tracking.ts +++ b/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -5,12 +5,9 @@ * can switch between branches even after worktrees are removed. */ -import { readFile, writeFile } from "fs/promises"; -import path from "path"; -import { - getBranchTrackingPath, - ensureAutomakerDir, -} from "@automaker/platform"; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform'; export interface TrackedBranch { name: string; @@ -25,19 +22,17 @@ interface BranchTrackingData { /** * Read tracked branches from file */ -export async function getTrackedBranches( - projectPath: string -): Promise { +export async function getTrackedBranches(projectPath: string): Promise { try { const filePath = getBranchTrackingPath(projectPath); - const content = await readFile(filePath, "utf-8"); + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; const data: BranchTrackingData = JSON.parse(content); return data.branches || []; } catch (error: any) { - if (error.code === "ENOENT") { + if (error.code === 'ENOENT') { return []; } - console.warn("[branch-tracking] Failed to read tracked branches:", error); + console.warn('[branch-tracking] Failed to read tracked branches:', error); return []; } } @@ -45,23 +40,17 @@ export async function getTrackedBranches( /** * Save tracked branches to file */ -async function saveTrackedBranches( - projectPath: string, - branches: TrackedBranch[] -): Promise { +async function saveTrackedBranches(projectPath: string, branches: TrackedBranch[]): Promise { const automakerDir = await ensureAutomakerDir(projectPath); - const filePath = path.join(automakerDir, "active-branches.json"); + const filePath = path.join(automakerDir, 'active-branches.json'); const data: BranchTrackingData = { branches }; - await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); + await secureFs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); } /** * Add a branch to tracking */ -export async function trackBranch( - projectPath: string, - branchName: string -): Promise { +export async function trackBranch(projectPath: string, branchName: string): Promise { const branches = await getTrackedBranches(projectPath); // Check if already tracked @@ -82,10 +71,7 @@ export async function trackBranch( /** * Remove a branch from tracking */ -export async function untrackBranch( - projectPath: string, - branchName: string -): Promise { +export async function untrackBranch(projectPath: string, branchName: string): Promise { const branches = await getTrackedBranches(projectPath); const filtered = branches.filter((b) => b.name !== branchName); @@ -114,10 +100,7 @@ export async function updateBranchActivation( /** * Check if a branch is tracked */ -export async function isBranchTracked( - projectPath: string, - branchName: string -): Promise { +export async function isBranchTracked(projectPath: string, branchName: string): Promise { const branches = await getTrackedBranches(projectPath); return branches.some((b) => b.name === branchName); } diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 690afe48..943d3bdd 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -7,19 +7,19 @@ * 3. Only creates a new worktree if none exists for the branch */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import { mkdir } from "fs/promises"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo, getErrorMessage, logError, normalizePath, ensureInitialCommit, -} from "../common.js"; -import { trackBranch } from "./branch-tracking.js"; +} from '../common.js'; +import { trackBranch } from './branch-tracking.js'; const execAsync = promisify(exec); @@ -31,20 +31,20 @@ async function findExistingWorktreeForBranch( branchName: string ): Promise<{ path: string; branch: string } | null> { try { - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await execAsync('git worktree list --porcelain', { cwd: projectPath, }); - const lines = stdout.split("\n"); + const lines = stdout.split('\n'); let currentPath: string | null = null; let currentBranch: string | null = null; for (const line of lines) { - if (line.startsWith("worktree ")) { + if (line.startsWith('worktree ')) { currentPath = line.slice(9); - } else if (line.startsWith("branch ")) { - currentBranch = line.slice(7).replace("refs/heads/", ""); - } else if (line === "" && currentPath && currentBranch) { + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '' && currentPath && currentBranch) { // End of a worktree entry if (currentBranch === branchName) { // Resolve to absolute path - git may return relative paths @@ -86,7 +86,7 @@ export function createCreateHandler() { if (!projectPath || !branchName) { res.status(400).json({ success: false, - error: "projectPath and branchName required", + error: 'projectPath and branchName required', }); return; } @@ -94,7 +94,7 @@ export function createCreateHandler() { if (!(await isGitRepo(projectPath))) { res.status(400).json({ success: false, - error: "Not a git repository", + error: 'Not a git repository', }); return; } @@ -107,7 +107,9 @@ export function createCreateHandler() { if (existingWorktree) { // Worktree already exists, return it as success (not an error) // This handles manually created worktrees or worktrees from previous runs - console.log(`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`); + console.log( + `[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}` + ); // Track the branch so it persists in the UI await trackBranch(projectPath, branchName); @@ -124,12 +126,12 @@ export function createCreateHandler() { } // Sanitize branch name for directory usage - const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-"); - const worktreesDir = path.join(projectPath, ".worktrees"); + const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreesDir = path.join(projectPath, '.worktrees'); const worktreePath = path.join(worktreesDir, sanitizedName); // Create worktrees directory if it doesn't exist - await mkdir(worktreesDir, { recursive: true }); + await secureFs.mkdir(worktreesDir, { recursive: true }); // Check if branch exists let branchExists = false; @@ -149,7 +151,7 @@ export function createCreateHandler() { createCmd = `git worktree add "${worktreePath}" ${branchName}`; } else { // Create new branch from base or HEAD - const base = baseBranch || "HEAD"; + const base = baseBranch || 'HEAD'; createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`; } @@ -174,7 +176,7 @@ export function createCreateHandler() { }, }); } catch (error) { - logError(error, "Create worktree failed"); + logError(error, 'Create worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index d3b6ed09..801dd514 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -2,11 +2,11 @@ * POST /diffs endpoint - Get diffs for a worktree */ -import type { Request, Response } from "express"; -import path from "path"; -import fs from "fs/promises"; -import { getErrorMessage, logError } from "../common.js"; -import { getGitRepositoryDiffs } from "../../common.js"; +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getGitRepositoryDiffs } from '../../common.js'; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { @@ -17,21 +17,19 @@ export function createDiffsHandler() { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); return; } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, ".worktrees", featureId); + const worktreePath = path.join(projectPath, '.worktrees', featureId); try { // Check if worktree exists - await fs.access(worktreePath); + await secureFs.access(worktreePath); // Get diffs from worktree const result = await getGitRepositoryDiffs(worktreePath); @@ -43,7 +41,7 @@ export function createDiffsHandler() { }); } catch (innerError) { // Worktree doesn't exist - fallback to main project path - logError(innerError, "Worktree access failed, falling back to main project"); + logError(innerError, 'Worktree access failed, falling back to main project'); try { const result = await getGitRepositoryDiffs(projectPath); @@ -54,12 +52,12 @@ export function createDiffsHandler() { hasChanges: result.hasChanges, }); } catch (fallbackError) { - logError(fallbackError, "Fallback to main project also failed"); - res.json({ success: true, diff: "", files: [], hasChanges: false }); + logError(fallbackError, 'Fallback to main project also failed'); + res.json({ success: true, diff: '', files: [], hasChanges: false }); } } } catch (error) { - logError(error, "Get worktree diffs failed"); + logError(error, 'Get worktree diffs failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 70306b6a..82ed79bd 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -2,13 +2,13 @@ * POST /file-diff endpoint - Get diff for a specific file */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import fs from "fs/promises"; -import { getErrorMessage, logError } from "../common.js"; -import { generateSyntheticDiffForNewFile } from "../../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; +import { generateSyntheticDiffForNewFile } from '../../common.js'; const execAsync = promisify(exec); @@ -24,24 +24,23 @@ export function createFileDiffHandler() { if (!projectPath || !featureId || !filePath) { res.status(400).json({ success: false, - error: "projectPath, featureId, and filePath required", + error: 'projectPath, featureId, and filePath required', }); return; } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, ".worktrees", featureId); + const worktreePath = path.join(projectPath, '.worktrees', featureId); try { - await fs.access(worktreePath); + await secureFs.access(worktreePath); // First check if the file is untracked - const { stdout: status } = await execAsync( - `git status --porcelain -- "${filePath}"`, - { cwd: worktreePath } - ); + const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, { + cwd: worktreePath, + }); - const isUntracked = status.trim().startsWith("??"); + const isUntracked = status.trim().startsWith('??'); let diff: string; if (isUntracked) { @@ -49,23 +48,20 @@ export function createFileDiffHandler() { diff = await generateSyntheticDiffForNewFile(worktreePath, filePath); } else { // Use regular git diff for tracked files - const result = await execAsync( - `git diff HEAD -- "${filePath}"`, - { - cwd: worktreePath, - maxBuffer: 10 * 1024 * 1024, - } - ); + const result = await execAsync(`git diff HEAD -- "${filePath}"`, { + cwd: worktreePath, + maxBuffer: 10 * 1024 * 1024, + }); diff = result.stdout; } res.json({ success: true, diff, filePath }); } catch (innerError) { - logError(innerError, "Worktree file diff failed"); - res.json({ success: true, diff: "", filePath }); + logError(innerError, 'Worktree file diff failed'); + res.json({ success: true, diff: '', filePath }); } } catch (error) { - logError(error, "Get worktree file diff failed"); + logError(error, 'Get worktree file diff failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts index 1a5bb463..3d512452 100644 --- a/apps/server/src/routes/worktree/routes/info.ts +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -2,12 +2,12 @@ * POST /info endpoint - Get worktree info */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import fs from "fs/promises"; -import { getErrorMessage, logError, normalizePath } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError, normalizePath } from '../common.js'; const execAsync = promisify(exec); @@ -20,20 +20,18 @@ export function createInfoHandler() { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); return; } // Check if worktree exists (git worktrees are stored in project directory) - const worktreePath = path.join(projectPath, ".worktrees", featureId); + const worktreePath = path.join(projectPath, '.worktrees', featureId); try { - await fs.access(worktreePath); - const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + await secureFs.access(worktreePath); + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: worktreePath, }); res.json({ @@ -45,7 +43,7 @@ export function createInfoHandler() { res.json({ success: true, worktreePath: null, branchName: null }); } } catch (error) { - logError(error, "Get worktree info failed"); + logError(error, 'Get worktree info failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/init-git.ts b/apps/server/src/routes/worktree/routes/init-git.ts index 0aecc8af..0a5c1a0b 100644 --- a/apps/server/src/routes/worktree/routes/init-git.ts +++ b/apps/server/src/routes/worktree/routes/init-git.ts @@ -2,12 +2,12 @@ * POST /init-git endpoint - Initialize a git repository in a directory */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { existsSync } from "fs"; -import { join } from "path"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { join } from 'path'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -21,39 +21,42 @@ export function createInitGitHandler() { if (!projectPath) { res.status(400).json({ success: false, - error: "projectPath required", + error: 'projectPath required', }); return; } // Check if .git already exists - const gitDirPath = join(projectPath, ".git"); - if (existsSync(gitDirPath)) { + const gitDirPath = join(projectPath, '.git'); + try { + await secureFs.access(gitDirPath); + // .git exists res.json({ success: true, result: { initialized: false, - message: "Git repository already exists", + message: 'Git repository already exists', }, }); return; + } catch { + // .git doesn't exist, continue with initialization } // Initialize git and create an initial empty commit - await execAsync( - `git init && git commit --allow-empty -m "Initial commit"`, - { cwd: projectPath } - ); + await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, { + cwd: projectPath, + }); res.json({ success: true, result: { initialized: true, - message: "Git repository initialized with initial commit", + message: 'Git repository initialized with initial commit', }, }); } catch (error) { - logError(error, "Init git failed"); + logError(error, 'Init git failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 1cf83456..93d93dad 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -5,13 +5,13 @@ * Does NOT include tracked branches - only real worktrees with separate directories. */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { existsSync } from "fs"; -import { isGitRepo } from "@automaker/git-utils"; -import { getErrorMessage, logError, normalizePath } from "../common.js"; -import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { getErrorMessage, logError, normalizePath } from '../common.js'; +import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; const execAsync = promisify(exec); @@ -28,10 +28,10 @@ interface WorktreeInfo { async function getCurrentBranch(cwd: string): Promise { try { - const { stdout } = await execAsync("git branch --show-current", { cwd }); + const { stdout } = await execAsync('git branch --show-current', { cwd }); return stdout.trim(); } catch { - return ""; + return ''; } } @@ -44,7 +44,7 @@ export function createListHandler() { }; if (!projectPath) { - res.status(400).json({ success: false, error: "projectPath required" }); + res.status(400).json({ success: false, error: 'projectPath required' }); return; } @@ -57,28 +57,35 @@ export function createListHandler() { const currentBranch = await getCurrentBranch(projectPath); // Get actual worktrees from git - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await execAsync('git worktree list --porcelain', { cwd: projectPath, }); const worktrees: WorktreeInfo[] = []; const removedWorktrees: Array<{ path: string; branch: string }> = []; - const lines = stdout.split("\n"); + const lines = stdout.split('\n'); let current: { path?: string; branch?: string } = {}; let isFirst = true; // First pass: detect removed worktrees for (const line of lines) { - if (line.startsWith("worktree ")) { + if (line.startsWith('worktree ')) { current.path = normalizePath(line.slice(9)); - } else if (line.startsWith("branch ")) { - current.branch = line.slice(7).replace("refs/heads/", ""); - } else if (line === "") { + } else if (line.startsWith('branch ')) { + current.branch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '') { if (current.path && current.branch) { const isMainWorktree = isFirst; // Check if the worktree directory actually exists // Skip checking/pruning the main worktree (projectPath itself) - if (!isMainWorktree && !existsSync(current.path)) { + let worktreeExists = false; + try { + await secureFs.access(current.path); + worktreeExists = true; + } catch { + worktreeExists = false; + } + if (!isMainWorktree && !worktreeExists) { // Worktree directory doesn't exist - it was manually deleted removedWorktrees.push({ path: current.path, @@ -103,7 +110,7 @@ export function createListHandler() { // Prune removed worktrees from git (only if any were detected) if (removedWorktrees.length > 0) { try { - await execAsync("git worktree prune", { cwd: projectPath }); + await execAsync('git worktree prune', { cwd: projectPath }); } catch { // Prune failed, but we'll still report the removed worktrees } @@ -116,13 +123,12 @@ export function createListHandler() { if (includeDetails) { for (const worktree of worktrees) { try { - const { stdout: statusOutput } = await execAsync( - "git status --porcelain", - { cwd: worktree.path } - ); + const { stdout: statusOutput } = await execAsync('git status --porcelain', { + cwd: worktree.path, + }); const changedFiles = statusOutput .trim() - .split("\n") + .split('\n') .filter((line) => line.trim()); worktree.hasChanges = changedFiles.length > 0; worktree.changedFilesCount = changedFiles.length; @@ -141,13 +147,13 @@ export function createListHandler() { } } - res.json({ - success: true, + res.json({ + success: true, worktrees, removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined, }); } catch (error) { - logError(error, "List worktrees failed"); + logError(error, 'List worktrees failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts index 3f56ef17..f9d6bf88 100644 --- a/apps/server/src/routes/worktree/routes/status.ts +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -2,12 +2,12 @@ * POST /status endpoint - Get worktree status */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import fs from "fs/promises"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -20,53 +20,50 @@ export function createStatusHandler() { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); return; } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, ".worktrees", featureId); + const worktreePath = path.join(projectPath, '.worktrees', featureId); try { - await fs.access(worktreePath); - const { stdout: status } = await execAsync("git status --porcelain", { + await secureFs.access(worktreePath); + const { stdout: status } = await execAsync('git status --porcelain', { cwd: worktreePath, }); const files = status - .split("\n") + .split('\n') .filter(Boolean) .map((line) => line.slice(3)); - const { stdout: diffStat } = await execAsync("git diff --stat", { + const { stdout: diffStat } = await execAsync('git diff --stat', { + cwd: worktreePath, + }); + const { stdout: logOutput } = await execAsync('git log --oneline -5 --format="%h %s"', { cwd: worktreePath, }); - const { stdout: logOutput } = await execAsync( - 'git log --oneline -5 --format="%h %s"', - { cwd: worktreePath } - ); res.json({ success: true, modifiedFiles: files.length, files, diffStat: diffStat.trim(), - recentCommits: logOutput.trim().split("\n").filter(Boolean), + recentCommits: logOutput.trim().split('\n').filter(Boolean), }); } catch { res.json({ success: true, modifiedFiles: 0, files: [], - diffStat: "", + diffStat: '', recentCommits: [], }); } } catch (error) { - logError(error, "Get worktree status failed"); + logError(error, 'Get worktree status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index b175074f..996a4a38 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -3,22 +3,18 @@ * Manages conversation sessions and streams responses via WebSocket */ -import path from "path"; -import * as secureFs from "../lib/secure-fs.js"; -import type { EventEmitter } from "../lib/events.js"; -import type { ExecuteOptions } from "@automaker/types"; -import { - readImageAsBase64, - buildPromptWithImages, - isAbortError, -} from "@automaker/utils"; -import { ProviderFactory } from "../providers/provider-factory.js"; -import { createChatOptions } from "../lib/sdk-options.js"; -import { isPathAllowed, PathNotAllowedError } from "@automaker/platform"; +import path from 'path'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import type { ExecuteOptions } from '@automaker/types'; +import { readImageAsBase64, buildPromptWithImages, isAbortError } from '@automaker/utils'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; +import { PathNotAllowedError } from '@automaker/platform'; interface Message { id: string; - role: "user" | "assistant"; + role: 'user' | 'assistant'; content: string; images?: Array<{ data: string; @@ -58,8 +54,8 @@ export class AgentService { private events: EventEmitter; constructor(dataDir: string, events: EventEmitter) { - this.stateDir = path.join(dataDir, "agent-sessions"); - this.metadataFile = path.join(dataDir, "sessions-metadata.json"); + this.stateDir = path.join(dataDir, 'agent-sessions'); + this.metadataFile = path.join(dataDir, 'sessions-metadata.json'); this.events = events; } @@ -86,12 +82,8 @@ export class AgentService { const effectiveWorkingDirectory = workingDirectory || process.cwd(); const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); - // Validate that the working directory is allowed - if (!isPathAllowed(resolvedWorkingDirectory)) { - throw new Error( - `Working directory ${effectiveWorkingDirectory} is not allowed` - ); - } + // Validate that the working directory is allowed using centralized validation + validateWorkingDirectory(resolvedWorkingDirectory); this.sessions.set(sessionId, { messages, @@ -132,7 +124,7 @@ export class AgentService { } if (session.isRunning) { - throw new Error("Agent is already processing a message"); + throw new Error('Agent is already processing a message'); } // Update session model if provided @@ -142,7 +134,7 @@ export class AgentService { } // Read images and convert to base64 - const images: Message["images"] = []; + const images: Message['images'] = []; if (imagePaths && imagePaths.length > 0) { for (const imagePath of imagePaths) { try { @@ -153,10 +145,7 @@ export class AgentService { filename: imageData.filename, }); } catch (error) { - console.error( - `[AgentService] Failed to load image ${imagePath}:`, - error - ); + console.error(`[AgentService] Failed to load image ${imagePath}:`, error); } } } @@ -164,7 +153,7 @@ export class AgentService { // Add user message const userMessage: Message = { id: this.generateId(), - role: "user", + role: 'user', content: message, images: images.length > 0 ? images : undefined, timestamp: new Date().toISOString(), @@ -182,7 +171,7 @@ export class AgentService { // Emit user message event this.emitAgentEvent(sessionId, { - type: "message", + type: 'message', message: userMessage, }); @@ -212,15 +201,14 @@ export class AgentService { // Build options for provider const options: ExecuteOptions = { - prompt: "", // Will be set below based on images + prompt: '', // Will be set below based on images model: effectiveModel, cwd: workingDirectory || session.workingDirectory, systemPrompt: this.getSystemPrompt(), maxTurns: maxTurns, allowedTools: allowedTools, abortController: session.abortController!, - conversationHistory: - conversationHistory.length > 0 ? conversationHistory : undefined, + conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming }; @@ -239,30 +227,28 @@ export class AgentService { const stream = provider.executeQuery(options); let currentAssistantMessage: Message | null = null; - let responseText = ""; + let responseText = ''; const toolUses: Array<{ name: string; input: unknown }> = []; for await (const msg of stream) { // Capture SDK session ID from any message and persist it if (msg.session_id && !session.sdkSessionId) { session.sdkSessionId = msg.session_id; - console.log( - `[AgentService] Captured SDK session ID: ${msg.session_id}` - ); + console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`); // Persist the SDK session ID to ensure conversation continuity across server restarts await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); } - if (msg.type === "assistant") { + if (msg.type === 'assistant') { if (msg.message?.content) { for (const block of msg.message.content) { - if (block.type === "text") { + if (block.type === 'text') { responseText += block.text; if (!currentAssistantMessage) { currentAssistantMessage = { id: this.generateId(), - role: "assistant", + role: 'assistant', content: responseText, timestamp: new Date().toISOString(), }; @@ -272,27 +258,27 @@ export class AgentService { } this.emitAgentEvent(sessionId, { - type: "stream", + type: 'stream', messageId: currentAssistantMessage.id, content: responseText, isComplete: false, }); - } else if (block.type === "tool_use") { + } else if (block.type === 'tool_use') { const toolUse = { - name: block.name || "unknown", + name: block.name || 'unknown', input: block.input, }; toolUses.push(toolUse); this.emitAgentEvent(sessionId, { - type: "tool_use", + type: 'tool_use', tool: toolUse, }); } } } - } else if (msg.type === "result") { - if (msg.subtype === "success" && msg.result) { + } else if (msg.type === 'result') { + if (msg.subtype === 'success' && msg.result) { if (currentAssistantMessage) { currentAssistantMessage.content = msg.result; responseText = msg.result; @@ -300,7 +286,7 @@ export class AgentService { } this.emitAgentEvent(sessionId, { - type: "complete", + type: 'complete', messageId: currentAssistantMessage?.id, content: responseText, toolUses, @@ -324,14 +310,14 @@ export class AgentService { return { success: false, aborted: true }; } - console.error("[AgentService] Error:", error); + console.error('[AgentService] Error:', error); session.isRunning = false; session.abortController = null; const errorMessage: Message = { id: this.generateId(), - role: "assistant", + role: 'assistant', content: `Error: ${(error as Error).message}`, timestamp: new Date().toISOString(), isError: true, @@ -341,7 +327,7 @@ export class AgentService { await this.saveSession(sessionId, session.messages); this.emitAgentEvent(sessionId, { - type: "error", + type: 'error', error: (error as Error).message, message: errorMessage, }); @@ -356,7 +342,7 @@ export class AgentService { getHistory(sessionId: string) { const session = this.sessions.get(sessionId); if (!session) { - return { success: false, error: "Session not found" }; + return { success: false, error: 'Session not found' }; } return { @@ -372,7 +358,7 @@ export class AgentService { async stopExecution(sessionId: string) { const session = this.sessions.get(sessionId); if (!session) { - return { success: false, error: "Session not found" }; + return { success: false, error: 'Session not found' }; } if (session.abortController) { @@ -404,7 +390,7 @@ export class AgentService { const sessionFile = path.join(this.stateDir, `${sessionId}.json`); try { - const data = (await secureFs.readFile(sessionFile, "utf-8")) as string; + const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string; return JSON.parse(data); } catch { return []; @@ -415,23 +401,16 @@ export class AgentService { const sessionFile = path.join(this.stateDir, `${sessionId}.json`); try { - await secureFs.writeFile( - sessionFile, - JSON.stringify(messages, null, 2), - "utf-8" - ); + await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8'); await this.updateSessionTimestamp(sessionId); } catch (error) { - console.error("[AgentService] Failed to save session:", error); + console.error('[AgentService] Failed to save session:', error); } } async loadMetadata(): Promise> { try { - const data = (await secureFs.readFile( - this.metadataFile, - "utf-8" - )) as string; + const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string; return JSON.parse(data); } catch { return {}; @@ -439,11 +418,7 @@ export class AgentService { } async saveMetadata(metadata: Record): Promise { - await secureFs.writeFile( - this.metadataFile, - JSON.stringify(metadata, null, 2), - "utf-8" - ); + await secureFs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8'); } async updateSessionTimestamp(sessionId: string): Promise { @@ -463,8 +438,7 @@ export class AgentService { } return sessions.sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } @@ -478,21 +452,15 @@ export class AgentService { const metadata = await this.loadMetadata(); // Determine the effective working directory - const effectiveWorkingDirectory = - workingDirectory || projectPath || process.cwd(); + const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd(); const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); - // Validate that the working directory is allowed - if (!isPathAllowed(resolvedWorkingDirectory)) { - throw new PathNotAllowedError(effectiveWorkingDirectory); - } + // Validate that the working directory is allowed using centralized validation + validateWorkingDirectory(resolvedWorkingDirectory); // Validate that projectPath is allowed if provided if (projectPath) { - const resolvedProjectPath = path.resolve(projectPath); - if (!isPathAllowed(resolvedProjectPath)) { - throw new PathNotAllowedError(projectPath); - } + validateWorkingDirectory(projectPath); } const session: SessionMetadata = { @@ -569,11 +537,8 @@ export class AgentService { return true; } - private emitAgentEvent( - sessionId: string, - data: Record - ): void { - this.events.emit("agent:stream", { sessionId, ...data }); + private emitAgentEvent(sessionId: string, data: Record): void { + this.events.emit('agent:stream', { sessionId, ...data }); } private getSystemPrompt(): string { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 5a5b7f58..da48308e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -9,48 +9,35 @@ * - Verification and merge workflows */ -import { ProviderFactory } from "../providers/provider-factory.js"; -import type { ExecuteOptions, Feature } from "@automaker/types"; -import { - buildPromptWithImages, - isAbortError, - classifyError, -} from "@automaker/utils"; -import { resolveModelString, DEFAULT_MODELS } from "@automaker/model-resolver"; -import { - resolveDependencies, - areDependenciesSatisfied, -} from "@automaker/dependency-resolver"; -import { - getFeatureDir, - getAutomakerDir, - getFeaturesDir, - getContextDir, -} from "@automaker/platform"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import * as secureFs from "../lib/secure-fs.js"; -import type { EventEmitter } from "../lib/events.js"; -import { createAutoModeOptions } from "../lib/sdk-options.js"; -import { FeatureLoader } from "./feature-loader.js"; -import { isPathAllowed, PathNotAllowedError } from "@automaker/platform"; +import { ProviderFactory } from '../providers/provider-factory.js'; +import type { ExecuteOptions, Feature } from '@automaker/types'; +import { buildPromptWithImages, isAbortError, classifyError } from '@automaker/utils'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; +import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from '@automaker/platform'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; +import { FeatureLoader } from './feature-loader.js'; const execAsync = promisify(exec); // Planning mode types for spec-driven development -type PlanningMode = "skip" | "lite" | "spec" | "full"; +type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; interface ParsedTask { id: string; // e.g., "T001" description: string; // e.g., "Create user model" filePath?: string; // e.g., "src/models/user.ts" phase?: string; // e.g., "Phase 1: Foundation" (for full mode) - status: "pending" | "in_progress" | "completed" | "failed"; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; } interface PlanSpec { - status: "pending" | "generating" | "generated" | "approved" | "rejected"; + status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; content?: string; version: number; generatedAt?: string; @@ -246,7 +233,7 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] { } const tasksContent = tasksBlockMatch[1]; - const lines = tasksContent.split("\n"); + const lines = tasksContent.split('\n'); let currentPhase: string | undefined; @@ -261,7 +248,7 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] { } // Check for task line - if (trimmedLine.startsWith("- [ ]")) { + if (trimmedLine.startsWith('- [ ]')) { const parsed = parseTaskLine(trimmedLine, currentPhase); if (parsed) { tasks.push(parsed); @@ -278,9 +265,7 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] { */ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { // Match pattern: - [ ] T###: Description | File: path - const taskMatch = line.match( - /- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/ - ); + const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); if (!taskMatch) { // Try simpler pattern without file const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); @@ -289,7 +274,7 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { id: simpleMatch[1], description: simpleMatch[2].trim(), phase: currentPhase, - status: "pending", + status: 'pending', }; } return null; @@ -300,7 +285,7 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { description: taskMatch[2].trim(), filePath: taskMatch[3]?.trim(), phase: currentPhase, - status: "pending", + status: 'pending', }; } @@ -330,11 +315,7 @@ interface AutoLoopState { } interface PendingApproval { - resolve: (result: { - approved: boolean; - editedPlan?: string; - feedback?: string; - }) => void; + resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void; reject: (error: Error) => void; featureId: string; projectPath: string; @@ -365,7 +346,7 @@ export class AutoModeService { */ async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise { if (this.autoLoopRunning) { - throw new Error("Auto mode is already running"); + throw new Error('Auto mode is already running'); } this.autoLoopRunning = true; @@ -376,16 +357,16 @@ export class AutoModeService { projectPath, }; - this.emitAutoModeEvent("auto_mode_started", { + this.emitAutoModeEvent('auto_mode_started', { message: `Auto mode started with max ${maxConcurrency} concurrent features`, projectPath, }); // Run the loop in the background this.runAutoLoop().catch((error) => { - console.error("[AutoMode] Loop error:", error); + console.error('[AutoMode] Loop error:', error); const errorInfo = classifyError(error); - this.emitAutoModeEvent("auto_mode_error", { + this.emitAutoModeEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type, }); @@ -406,13 +387,11 @@ export class AutoModeService { } // Load pending features - const pendingFeatures = await this.loadPendingFeatures( - this.config!.projectPath - ); + const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); if (pendingFeatures.length === 0) { - this.emitAutoModeEvent("auto_mode_idle", { - message: "No pending features - auto mode idle", + this.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', projectPath: this.config!.projectPath, }); await this.sleep(10000); @@ -420,9 +399,7 @@ export class AutoModeService { } // Find a feature not currently running - const nextFeature = pendingFeatures.find( - (f) => !this.runningFeatures.has(f.id) - ); + const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); if (nextFeature) { // Start feature execution in background @@ -438,7 +415,7 @@ export class AutoModeService { await this.sleep(2000); } catch (error) { - console.error("[AutoMode] Loop iteration error:", error); + console.error('[AutoMode] Loop iteration error:', error); await this.sleep(5000); } } @@ -459,8 +436,8 @@ export class AutoModeService { // Emit stop event immediately when user explicitly stops if (wasRunning) { - this.emitAutoModeEvent("auto_mode_stopped", { - message: "Auto mode stopped", + this.emitAutoModeEvent('auto_mode_stopped', { + message: 'Auto mode stopped', projectPath: this.config?.projectPath, }); } @@ -486,7 +463,7 @@ export class AutoModeService { } ): Promise { if (this.runningFeatures.has(featureId)) { - throw new Error("already running"); + throw new Error('already running'); } // Add to running features immediately to prevent race conditions @@ -503,18 +480,13 @@ export class AutoModeService { this.runningFeatures.set(featureId, tempRunningFeature); try { - // Validate that project path is allowed - if (!isPathAllowed(projectPath)) { - throw new PathNotAllowedError(projectPath); - } + // Validate that project path is allowed using centralized validation + validateWorkingDirectory(projectPath); // Check if feature has existing context - if so, resume instead of starting fresh // Skip this check if we're already being called with a continuation prompt (from resumeFeature) if (!options?.continuationPrompt) { - const hasExistingContext = await this.contextExists( - projectPath, - featureId - ); + const hasExistingContext = await this.contextExists(projectPath, featureId); if (hasExistingContext) { console.log( `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh` @@ -526,13 +498,13 @@ export class AutoModeService { } // Emit feature start event early - this.emitAutoModeEvent("auto_mode_feature_start", { + this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, feature: { id: featureId, - title: "Loading...", - description: "Feature is starting", + title: 'Loading...', + description: 'Feature is starting', }, }); // Load feature details FIRST to get branchName @@ -549,15 +521,10 @@ export class AutoModeService { if (useWorktrees && branchName) { // Try to find existing worktree for this branch // Worktree should already exist (created when feature was added/edited) - worktreePath = await this.findExistingWorktreeForBranch( - projectPath, - branchName - ); + worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { - console.log( - `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` - ); + console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`); } else { // Worktree doesn't exist - log warning and continue with project path console.warn( @@ -567,21 +534,17 @@ export class AutoModeService { } // Ensure workDir is always an absolute path for cross-platform compatibility - const workDir = worktreePath - ? path.resolve(worktreePath) - : path.resolve(projectPath); + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); - // Validate that working directory is allowed - if (!isPathAllowed(workDir)) { - throw new PathNotAllowedError(workDir); - } + // Validate that working directory is allowed using centralized validation + validateWorkingDirectory(workDir); // Update running feature with actual worktree info tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.branchName = branchName ?? null; // Update feature status to in_progress - await this.updateFeatureStatus(projectPath, featureId, "in_progress"); + await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); // Build the prompt - use continuation prompt if provided (for recovery after plan approval) let prompt: string; @@ -592,9 +555,7 @@ export class AutoModeService { // Continuation prompt is used when recovering from a plan approval // The plan was already approved, so skip the planning phase prompt = options.continuationPrompt; - console.log( - `[AutoMode] Using continuation prompt for feature ${featureId}` - ); + console.log(`[AutoMode] Using continuation prompt for feature ${featureId}`); } else { // Normal flow: build prompt with planning phase const featurePrompt = this.buildFeaturePrompt(feature); @@ -602,8 +563,8 @@ export class AutoModeService { prompt = planningPrefix + featurePrompt; // Emit planning mode info - if (feature.planningMode && feature.planningMode !== "skip") { - this.emitAutoModeEvent("planning_started", { + if (feature.planningMode && feature.planningMode !== 'skip') { + this.emitAutoModeEvent('planning_started', { featureId: feature.id, mode: feature.planningMode, message: `Starting ${feature.planningMode} planning phase`, @@ -613,14 +574,12 @@ export class AutoModeService { // Extract image paths from feature const imagePaths = feature.imagePaths?.map((img) => - typeof img === "string" ? img : img.path + typeof img === 'string' ? img : img.path ); // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); - console.log( - `[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}` - ); + console.log(`[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`); // Run the agent with the feature's model and images // Context files are passed as system prompt for higher priority @@ -641,13 +600,9 @@ export class AutoModeService { ); // Mark as waiting_approval for user review - await this.updateFeatureStatus( - projectPath, - featureId, - "waiting_approval" - ); + await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval'); - this.emitAutoModeEvent("auto_mode_feature_complete", { + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, message: `Feature completed in ${Math.round( @@ -659,16 +614,16 @@ export class AutoModeService { const errorInfo = classifyError(error); if (errorInfo.isAbort) { - this.emitAutoModeEvent("auto_mode_feature_complete", { + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: false, - message: "Feature stopped by user", + message: 'Feature stopped by user', projectPath, }); } else { console.error(`[AutoMode] Feature ${featureId} failed:`, error); - await this.updateFeatureStatus(projectPath, featureId, "backlog"); - this.emitAutoModeEvent("auto_mode_error", { + await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + this.emitAutoModeEvent('auto_mode_error', { featureId, error: errorInfo.message, errorType: errorInfo.type, @@ -676,11 +631,9 @@ export class AutoModeService { }); } } finally { + console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`); console.log( - `[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures` - ); - console.log( - `[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}` + `[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); this.runningFeatures.delete(featureId); } @@ -705,18 +658,14 @@ export class AutoModeService { /** * Resume a feature (continues from saved context) */ - async resumeFeature( - projectPath: string, - featureId: string, - useWorktrees = false - ): Promise { + async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { if (this.runningFeatures.has(featureId)) { - throw new Error("already running"); + throw new Error('already running'); } // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, "agent-output.md"); + const contextPath = path.join(featureDir, 'agent-output.md'); let hasContext = false; try { @@ -728,13 +677,8 @@ export class AutoModeService { if (hasContext) { // Load previous context and continue - const context = (await secureFs.readFile(contextPath, "utf-8")) as string; - return this.executeFeatureWithContext( - projectPath, - featureId, - context, - useWorktrees - ); + const context = (await secureFs.readFile(contextPath, 'utf-8')) as string; + return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); } // No context, start fresh - executeFeature will handle adding to runningFeatures @@ -753,6 +697,9 @@ export class AutoModeService { imagePaths?: string[], useWorktrees = true ): Promise { + // Validate project path early for fast failure + validateWorkingDirectory(projectPath); + if (this.runningFeatures.has(featureId)) { throw new Error(`Feature ${featureId} is already running`); } @@ -770,28 +717,20 @@ export class AutoModeService { if (useWorktrees && branchName) { // Try to find existing worktree for this branch - worktreePath = await this.findExistingWorktreeForBranch( - projectPath, - branchName - ); + worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { workDir = worktreePath; - console.log( - `[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}` - ); + console.log(`[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`); } } // Load previous agent output if it exists const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, "agent-output.md"); - let previousContext = ""; + const contextPath = path.join(featureDir, 'agent-output.md'); + let previousContext = ''; try { - previousContext = (await secureFs.readFile( - contextPath, - "utf-8" - )) as string; + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; } catch { // No previous context } @@ -831,12 +770,12 @@ Address the follow-up instructions above. Review the previous work and make the startTime: Date.now(), }); - this.emitAutoModeEvent("auto_mode_feature_start", { + this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, feature: feature || { id: featureId, - title: "Follow-up", + title: 'Follow-up', description: prompt.substring(0, 100), }, }); @@ -844,18 +783,16 @@ Address the follow-up instructions above. Review the previous work and make the try { // Get model from feature (already loaded above) const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); - console.log( - `[AutoMode] Follow-up for feature ${featureId} using model: ${model}` - ); + console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); // Update feature status to in_progress - await this.updateFeatureStatus(projectPath, featureId, "in_progress"); + await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); // Copy follow-up images to feature folder const copiedImagePaths: string[] = []; if (imagePaths && imagePaths.length > 0) { const featureDirForImages = getFeatureDir(projectPath, featureId); - const featureImagesDir = path.join(featureDirForImages, "images"); + const featureImagesDir = path.join(featureDirForImages, 'images'); await secureFs.mkdir(featureImagesDir, { recursive: true }); @@ -871,10 +808,7 @@ Address the follow-up instructions above. Review the previous work and make the // Store the absolute path (external storage uses absolute paths) copiedImagePaths.push(destPath); } catch (error) { - console.error( - `[AutoMode] Failed to copy follow-up image ${imagePath}:`, - error - ); + console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error); } } } @@ -885,7 +819,7 @@ Address the follow-up instructions above. Review the previous work and make the const newImagePaths = copiedImagePaths.map((p) => ({ path: p, filename: path.basename(p), - mimeType: "image/png", // Default, could be improved + mimeType: 'image/png', // Default, could be improved })); feature.imagePaths = [...currentImagePaths, ...newImagePaths]; @@ -897,7 +831,7 @@ Address the follow-up instructions above. Review the previous work and make the // Add all images from feature (now includes both original and new) if (feature?.imagePaths) { const allPaths = feature.imagePaths.map((img) => - typeof img === "string" ? img : img.path + typeof img === 'string' ? img : img.path ); allImagePaths.push(...allPaths); } @@ -905,13 +839,10 @@ Address the follow-up instructions above. Review the previous work and make the // Save updated feature.json with new images if (copiedImagePaths.length > 0 && feature) { const featureDirForSave = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDirForSave, "feature.json"); + const featurePath = path.join(featureDirForSave, 'feature.json'); try { - await secureFs.writeFile( - featurePath, - JSON.stringify(feature, null, 2) - ); + await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch (error) { console.error(`[AutoMode] Failed to save feature.json:`, error); } @@ -931,29 +862,25 @@ Address the follow-up instructions above. Review the previous work and make the model, { projectPath, - planningMode: "skip", // Follow-ups don't require approval + planningMode: 'skip', // Follow-ups don't require approval previousContent: previousContext || undefined, systemPrompt: contextFiles || undefined, } ); // Mark as waiting_approval for user review - await this.updateFeatureStatus( - projectPath, - featureId, - "waiting_approval" - ); + await this.updateFeatureStatus(projectPath, featureId, 'waiting_approval'); - this.emitAutoModeEvent("auto_mode_feature_complete", { + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, - message: "Follow-up completed successfully", + message: 'Follow-up completed successfully', projectPath, }); } catch (error) { const errorInfo = classifyError(error); if (!errorInfo.isCancellation) { - this.emitAutoModeEvent("auto_mode_error", { + this.emitAutoModeEvent('auto_mode_error', { featureId, error: errorInfo.message, errorType: errorInfo.type, @@ -968,12 +895,9 @@ Address the follow-up instructions above. Review the previous work and make the /** * Verify a feature's implementation */ - async verifyFeature( - projectPath: string, - featureId: string - ): Promise { + async verifyFeature(projectPath: string, featureId: string): Promise { // Worktrees are in project dir - const worktreePath = path.join(projectPath, ".worktrees", featureId); + const worktreePath = path.join(projectPath, '.worktrees', featureId); let workDir = projectPath; try { @@ -985,15 +909,14 @@ Address the follow-up instructions above. Review the previous work and make the // Run verification - check if tests pass, build works, etc. const verificationChecks = [ - { cmd: "npm run lint", name: "Lint" }, - { cmd: "npm run typecheck", name: "Type check" }, - { cmd: "npm test", name: "Tests" }, - { cmd: "npm run build", name: "Build" }, + { cmd: 'npm run lint', name: 'Lint' }, + { cmd: 'npm run typecheck', name: 'Type check' }, + { cmd: 'npm test', name: 'Tests' }, + { cmd: 'npm run build', name: 'Build' }, ]; let allPassed = true; - const results: Array<{ check: string; passed: boolean; output?: string }> = - []; + const results: Array<{ check: string; passed: boolean; output?: string }> = []; for (const check of verificationChecks) { try { @@ -1017,14 +940,12 @@ Address the follow-up instructions above. Review the previous work and make the } } - this.emitAutoModeEvent("auto_mode_feature_complete", { + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: allPassed, message: allPassed - ? "All verification checks passed" - : `Verification failed: ${ - results.find((r) => !r.passed)?.check || "Unknown" - }`, + ? 'All verification checks passed' + : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, }); return allPassed; @@ -1056,25 +977,19 @@ Address the follow-up instructions above. Review the previous work and make the } } else { // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join( - projectPath, - ".worktrees", - featureId - ); + const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId); try { await secureFs.access(legacyWorktreePath); workDir = legacyWorktreePath; console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`); } catch { - console.log( - `[AutoMode] No worktree found, committing in project path: ${workDir}` - ); + console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`); } } try { // Check for changes - const { stdout: status } = await execAsync("git status --porcelain", { + const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir, }); if (!status.trim()) { @@ -1090,17 +1005,17 @@ Address the follow-up instructions above. Review the previous work and make the : `feat: Feature ${featureId}`; // Stage and commit - await execAsync("git add -A", { cwd: workDir }); + await execAsync('git add -A', { cwd: workDir }); await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir, }); // Get commit hash - const { stdout: hash } = await execAsync("git rev-parse HEAD", { + const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir, }); - this.emitAutoModeEvent("auto_mode_feature_complete", { + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, message: `Changes committed: ${hash.trim().substring(0, 8)}`, @@ -1116,13 +1031,10 @@ Address the follow-up instructions above. Review the previous work and make the /** * Check if context exists for a feature */ - async contextExists( - projectPath: string, - featureId: string - ): Promise { + async contextExists(projectPath: string, featureId: string): Promise { // Context is stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, "agent-output.md"); + const contextPath = path.join(featureDir, 'agent-output.md'); try { await secureFs.access(contextPath); @@ -1149,22 +1061,20 @@ Address the follow-up instructions above. Review the previous work and make the // Filter for text-based context files (case-insensitive for Windows) const textFiles = files.filter((f) => { const lower = f.toLowerCase(); - return lower.endsWith(".md") || lower.endsWith(".txt"); + return lower.endsWith('.md') || lower.endsWith('.txt'); }); - if (textFiles.length === 0) return ""; + if (textFiles.length === 0) return ''; const contents: string[] = []; for (const file of textFiles) { // Use path.join for cross-platform path construction const filePath = path.join(contextDir, file); - const content = (await secureFs.readFile(filePath, "utf-8")) as string; + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; contents.push(`## ${file}\n\n${content}`); } - console.log( - `[AutoMode] Loaded ${textFiles.length} context file(s): ${textFiles.join(", ")}` - ); + console.log(`[AutoMode] Loaded ${textFiles.length} context file(s): ${textFiles.join(', ')}`); return `# ⚠️ CRITICAL: Project Context Files - READ AND FOLLOW STRICTLY @@ -1176,7 +1086,7 @@ Address the follow-up instructions above. Review the previous work and make the Failure to follow these rules will result in broken builds, failed CI, and rejected commits. -${contents.join("\n\n---\n\n")} +${contents.join('\n\n---\n\n')} --- @@ -1187,7 +1097,7 @@ ${contents.join("\n\n---\n\n")} `; } catch { // Context directory doesn't exist or is empty - this is fine - return ""; + return ''; } } @@ -1195,16 +1105,21 @@ ${contents.join("\n\n---\n\n")} * Analyze project to gather context */ async analyzeProject(projectPath: string): Promise { + // Validate project path before proceeding + // This is called here because analyzeProject builds ExecuteOptions directly + // without using a factory function from sdk-options.ts + validateWorkingDirectory(projectPath); + const abortController = new AbortController(); const analysisFeatureId = `analysis-${Date.now()}`; - this.emitAutoModeEvent("auto_mode_feature_start", { + this.emitAutoModeEvent('auto_mode_feature_start', { featureId: analysisFeatureId, projectPath, feature: { id: analysisFeatureId, - title: "Project Analysis", - description: "Analyzing project structure", + title: 'Project Analysis', + description: 'Analyzing project structure', }, }); @@ -1219,10 +1134,7 @@ Format your response as a structured markdown document.`; try { // Use default Claude model for analysis (can be overridden in the future) - const analysisModel = resolveModelString( - undefined, - DEFAULT_MODELS.claude - ); + const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); const provider = ProviderFactory.getProviderForModel(analysisModel); const options: ExecuteOptions = { @@ -1230,45 +1142,45 @@ Format your response as a structured markdown document.`; model: analysisModel, maxTurns: 5, cwd: projectPath, - allowedTools: ["Read", "Glob", "Grep"], + allowedTools: ['Read', 'Glob', 'Grep'], abortController, }; const stream = provider.executeQuery(options); - let analysisResult = ""; + let analysisResult = ''; for await (const msg of stream) { - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === "text") { - analysisResult = block.text || ""; - this.emitAutoModeEvent("auto_mode_progress", { + if (block.type === 'text') { + analysisResult = block.text || ''; + this.emitAutoModeEvent('auto_mode_progress', { featureId: analysisFeatureId, content: block.text, projectPath, }); } } - } else if (msg.type === "result" && msg.subtype === "success") { + } else if (msg.type === 'result' && msg.subtype === 'success') { analysisResult = msg.result || analysisResult; } } // Save analysis to .automaker directory const automakerDir = getAutomakerDir(projectPath); - const analysisPath = path.join(automakerDir, "project-analysis.md"); + const analysisPath = path.join(automakerDir, 'project-analysis.md'); await secureFs.mkdir(automakerDir, { recursive: true }); await secureFs.writeFile(analysisPath, analysisResult); - this.emitAutoModeEvent("auto_mode_feature_complete", { + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId: analysisFeatureId, passes: true, - message: "Project analysis completed", + message: 'Project analysis completed', projectPath, }); } catch (error) { const errorInfo = classifyError(error); - this.emitAutoModeEvent("auto_mode_error", { + this.emitAutoModeEvent('auto_mode_error', { featureId: analysisFeatureId, error: errorInfo.message, errorType: errorInfo.type, @@ -1317,11 +1229,9 @@ Format your response as a structured markdown document.`; featureId: string, projectPath: string ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { + console.log(`[AutoMode] Registering pending approval for feature ${featureId}`); console.log( - `[AutoMode] Registering pending approval for feature ${featureId}` - ); - console.log( - `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}` + `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); return new Promise((resolve, reject) => { this.pendingApprovals.set(featureId, { @@ -1330,9 +1240,7 @@ Format your response as a structured markdown document.`; featureId, projectPath, }); - console.log( - `[AutoMode] Pending approval registered for feature ${featureId}` - ); + console.log(`[AutoMode] Pending approval registered for feature ${featureId}`); }); } @@ -1351,27 +1259,20 @@ Format your response as a structured markdown document.`; `[AutoMode] resolvePlanApproval called for feature ${featureId}, approved=${approved}` ); console.log( - `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}` + `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); const pending = this.pendingApprovals.get(featureId); if (!pending) { - console.log( - `[AutoMode] No pending approval in Map for feature ${featureId}` - ); + console.log(`[AutoMode] No pending approval in Map for feature ${featureId}`); // RECOVERY: If no pending approval but we have projectPath from client, // check if feature's planSpec.status is 'generated' and handle recovery if (projectPathFromClient) { - console.log( - `[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}` - ); - const feature = await this.loadFeature( - projectPathFromClient, - featureId - ); + console.log(`[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}`); + const feature = await this.loadFeature(projectPathFromClient, featureId); - if (feature?.planSpec?.status === "generated") { + if (feature?.planSpec?.status === 'generated') { console.log( `[AutoMode] Feature ${featureId} has planSpec.status='generated', performing recovery` ); @@ -1379,36 +1280,27 @@ Format your response as a structured markdown document.`; if (approved) { // Update planSpec to approved await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { - status: "approved", + status: 'approved', approvedAt: new Date().toISOString(), reviewedByUser: true, content: editedPlan || feature.planSpec.content, }); // Build continuation prompt and re-run the feature - const planContent = editedPlan || feature.planSpec.content || ""; + const planContent = editedPlan || feature.planSpec.content || ''; let continuationPrompt = `The plan/specification has been approved. `; if (feedback) { continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`; } continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`; - console.log( - `[AutoMode] Starting recovery execution for feature ${featureId}` - ); + console.log(`[AutoMode] Starting recovery execution for feature ${featureId}`); // Start feature execution with the continuation prompt (async, don't await) // Pass undefined for providedWorktreePath, use options for continuation prompt - this.executeFeature( - projectPathFromClient, - featureId, - true, - false, - undefined, - { - continuationPrompt, - } - ).catch((error) => { + this.executeFeature(projectPathFromClient, featureId, true, false, undefined, { + continuationPrompt, + }).catch((error) => { console.error( `[AutoMode] Recovery execution failed for feature ${featureId}:`, error @@ -1419,17 +1311,13 @@ Format your response as a structured markdown document.`; } else { // Rejected - update status and emit event await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { - status: "rejected", + status: 'rejected', reviewedByUser: true, }); - await this.updateFeatureStatus( - projectPathFromClient, - featureId, - "backlog" - ); + await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog'); - this.emitAutoModeEvent("plan_rejected", { + this.emitAutoModeEvent('plan_rejected', { featureId, projectPath: projectPathFromClient, feedback, @@ -1448,15 +1336,13 @@ Format your response as a structured markdown document.`; error: `No pending approval for feature ${featureId}`, }; } - console.log( - `[AutoMode] Found pending approval for feature ${featureId}, proceeding...` - ); + console.log(`[AutoMode] Found pending approval for feature ${featureId}, proceeding...`); const { projectPath } = pending; // Update feature's planSpec status await this.updateFeaturePlanSpec(projectPath, featureId, { - status: approved ? "approved" : "rejected", + status: approved ? 'approved' : 'rejected', approvedAt: approved ? new Date().toISOString() : undefined, reviewedByUser: true, content: editedPlan, // Update content if user provided an edited version @@ -1465,7 +1351,7 @@ Format your response as a structured markdown document.`; // If rejected with feedback, we can store it for the user to see if (!approved && feedback) { // Emit event so client knows the rejection reason - this.emitAutoModeEvent("plan_rejected", { + this.emitAutoModeEvent('plan_rejected', { featureId, projectPath, feedback, @@ -1483,25 +1369,17 @@ Format your response as a structured markdown document.`; * Cancel a pending plan approval (e.g., when feature is stopped). */ cancelPlanApproval(featureId: string): void { + console.log(`[AutoMode] cancelPlanApproval called for feature ${featureId}`); console.log( - `[AutoMode] cancelPlanApproval called for feature ${featureId}` - ); - console.log( - `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(", ") || "none"}` + `[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); const pending = this.pendingApprovals.get(featureId); if (pending) { - console.log( - `[AutoMode] Found and cancelling pending approval for feature ${featureId}` - ); - pending.reject( - new Error("Plan approval cancelled - feature was stopped") - ); + console.log(`[AutoMode] Found and cancelling pending approval for feature ${featureId}`); + pending.reject(new Error('Plan approval cancelled - feature was stopped')); this.pendingApprovals.delete(featureId); } else { - console.log( - `[AutoMode] No pending approval to cancel for feature ${featureId}` - ); + console.log(`[AutoMode] No pending approval to cancel for feature ${featureId}`); } } @@ -1522,20 +1400,20 @@ Format your response as a structured markdown document.`; branchName: string ): Promise { try { - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await execAsync('git worktree list --porcelain', { cwd: projectPath, }); - const lines = stdout.split("\n"); + const lines = stdout.split('\n'); let currentPath: string | null = null; let currentBranch: string | null = null; for (const line of lines) { - if (line.startsWith("worktree ")) { + if (line.startsWith('worktree ')) { currentPath = line.slice(9); - } else if (line.startsWith("branch ")) { - currentBranch = line.slice(7).replace("refs/heads/", ""); - } else if (line === "" && currentPath && currentBranch) { + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '' && currentPath && currentBranch) { // End of a worktree entry if (currentBranch === branchName) { // Resolve to absolute path - git may return relative paths @@ -1566,16 +1444,13 @@ Format your response as a structured markdown document.`; } } - private async loadFeature( - projectPath: string, - featureId: string - ): Promise { + private async loadFeature(projectPath: string, featureId: string): Promise { // Features are stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, "feature.json"); + const featurePath = path.join(featureDir, 'feature.json'); try { - const data = (await secureFs.readFile(featurePath, "utf-8")) as string; + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; return JSON.parse(data); } catch { return null; @@ -1589,16 +1464,16 @@ Format your response as a structured markdown document.`; ): Promise { // Features are stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, "feature.json"); + const featurePath = path.join(featureDir, 'feature.json'); try { - const data = (await secureFs.readFile(featurePath, "utf-8")) as string; + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; const feature = JSON.parse(data); feature.status = status; feature.updatedAt = new Date().toISOString(); // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Badge will show for 2 minutes after this timestamp - if (status === "waiting_approval") { + if (status === 'waiting_approval') { feature.justFinishedAt = new Date().toISOString(); } else { // Clear the timestamp when moving to other statuses @@ -1618,22 +1493,16 @@ Format your response as a structured markdown document.`; featureId: string, updates: Partial ): Promise { - const featurePath = path.join( - projectPath, - ".automaker", - "features", - featureId, - "feature.json" - ); + const featurePath = path.join(projectPath, '.automaker', 'features', featureId, 'feature.json'); try { - const data = (await secureFs.readFile(featurePath, "utf-8")) as string; + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; const feature = JSON.parse(data); // Initialize planSpec if it doesn't exist if (!feature.planSpec) { feature.planSpec = { - status: "pending", + status: 'pending', version: 1, reviewedByUser: false, }; @@ -1650,10 +1519,7 @@ Format your response as a structured markdown document.`; feature.updatedAt = new Date().toISOString(); await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); } catch (error) { - console.error( - `[AutoMode] Failed to update planSpec for ${featureId}:`, - error - ); + console.error(`[AutoMode] Failed to update planSpec for ${featureId}:`, error); } } @@ -1671,24 +1537,17 @@ Format your response as a structured markdown document.`; // Load all features (for dependency checking) for (const entry of entries) { if (entry.isDirectory()) { - const featurePath = path.join( - featuresDir, - entry.name, - "feature.json" - ); + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); try { - const data = (await secureFs.readFile( - featurePath, - "utf-8" - )) as string; + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; const feature = JSON.parse(data); allFeatures.push(feature); // Track pending features separately if ( - feature.status === "pending" || - feature.status === "ready" || - feature.status === "backlog" + feature.status === 'pending' || + feature.status === 'ready' || + feature.status === 'backlog' ) { pendingFeatures.push(feature); } @@ -1717,42 +1576,41 @@ Format your response as a structured markdown document.`; */ private extractTitleFromDescription(description: string): string { if (!description || !description.trim()) { - return "Untitled Feature"; + return 'Untitled Feature'; } // Get first line, or first 60 characters if no newline - const firstLine = description.split("\n")[0].trim(); + const firstLine = description.split('\n')[0].trim(); if (firstLine.length <= 60) { return firstLine; } // Truncate to 60 characters and add ellipsis - return firstLine.substring(0, 57) + "..."; + return firstLine.substring(0, 57) + '...'; } /** * Get the planning prompt prefix based on feature's planning mode */ private getPlanningPromptPrefix(feature: Feature): string { - const mode = feature.planningMode || "skip"; + const mode = feature.planningMode || 'skip'; - if (mode === "skip") { - return ""; // No planning phase + if (mode === 'skip') { + return ''; // No planning phase } // For lite mode, use the approval variant if requirePlanApproval is true let promptKey: string = mode; - if (mode === "lite" && feature.requirePlanApproval === true) { - promptKey = "lite_with_approval"; + if (mode === 'lite' && feature.requirePlanApproval === true) { + promptKey = 'lite_with_approval'; } - const planningPrompt = - PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS]; + const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS]; if (!planningPrompt) { - return ""; + return ''; } - return planningPrompt + "\n\n---\n\n## Feature Request\n\n"; + return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; } private buildFeaturePrompt(feature: Feature): string { @@ -1776,18 +1634,13 @@ ${feature.spec} if (feature.imagePaths && feature.imagePaths.length > 0) { const imagesList = feature.imagePaths .map((img, idx) => { - const path = typeof img === "string" ? img : img.path; + const path = typeof img === 'string' ? img : img.path; const filename = - typeof img === "string" - ? path.split("/").pop() - : img.filename || path.split("/").pop(); - const mimeType = - typeof img === "string" ? "image/*" : img.mimeType || "image/*"; - return ` ${ - idx + 1 - }. ${filename} (${mimeType})\n Path: ${path}`; + typeof img === 'string' ? path.split('/').pop() : img.filename || path.split('/').pop(); + const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; + return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; }) - .join("\n"); + .join('\n'); prompt += ` **📎 Context Images Attached:** @@ -1846,49 +1699,46 @@ This helps parse your summary correctly in the output logs.`; } ): Promise { const finalProjectPath = options?.projectPath || projectPath; - const planningMode = options?.planningMode || "skip"; + const planningMode = options?.planningMode || 'skip'; const previousContent = options?.previousContent; // Check if this planning mode can generate a spec/plan that needs approval // - spec and full always generate specs // - lite only generates approval-ready content when requirePlanApproval is true const planningModeRequiresApproval = - planningMode === "spec" || - planningMode === "full" || - (planningMode === "lite" && options?.requirePlanApproval === true); - const requiresApproval = - planningModeRequiresApproval && options?.requirePlanApproval === true; + planningMode === 'spec' || + planningMode === 'full' || + (planningMode === 'lite' && options?.requirePlanApproval === true); + const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // This prevents actual API calls during automated testing - if (process.env.AUTOMAKER_MOCK_AGENT === "true") { - console.log( - `[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}` - ); + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`); // Simulate some work being done await this.sleep(500); // Emit mock progress events to simulate agent activity - this.emitAutoModeEvent("auto_mode_progress", { + this.emitAutoModeEvent('auto_mode_progress', { featureId, - content: "Mock agent: Analyzing the codebase...", + content: 'Mock agent: Analyzing the codebase...', }); await this.sleep(300); - this.emitAutoModeEvent("auto_mode_progress", { + this.emitAutoModeEvent('auto_mode_progress', { featureId, - content: "Mock agent: Implementing the feature...", + content: 'Mock agent: Implementing the feature...', }); await this.sleep(300); // Create a mock file with "yellow" content as requested in the test - const mockFilePath = path.join(workDir, "yellow.txt"); - await secureFs.writeFile(mockFilePath, "yellow"); + const mockFilePath = path.join(workDir, 'yellow.txt'); + await secureFs.writeFile(mockFilePath, 'yellow'); - this.emitAutoModeEvent("auto_mode_progress", { + this.emitAutoModeEvent('auto_mode_progress', { featureId, content: "Mock agent: Created yellow.txt file with content 'yellow'", }); @@ -1897,7 +1747,7 @@ This helps parse your summary correctly in the output logs.`; // Save mock agent output const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, "agent-output.md"); + const outputPath = path.join(featureDirForOutput, 'agent-output.md'); const mockOutput = `# Mock Agent Output @@ -1914,9 +1764,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); await secureFs.writeFile(outputPath, mockOutput); - console.log( - `[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}` - ); + console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`); return; } @@ -1939,9 +1787,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Get provider for this model const provider = ProviderFactory.getProviderForModel(finalModel); - console.log( - `[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"` - ); + console.log(`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`); // Build prompt content with images using utility const { content: promptContent } = await buildPromptWithImages( @@ -1973,13 +1819,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Initialize with previous content if this is a follow-up, with a separator let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` - : ""; + : ''; let specDetected = false; // Agent output goes to .automaker directory // Note: We use projectPath here, not workDir, because workDir might be a worktree path const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, "agent-output.md"); + const outputPath = path.join(featureDirForOutput, 'agent-output.md'); // Incremental file writing state let writeTimeout: ReturnType | null = null; @@ -1992,10 +1838,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. await secureFs.writeFile(outputPath, responseText); } catch (error) { // Log but don't crash - file write errors shouldn't stop execution - console.error( - `[AutoMode] Failed to write agent output for ${featureId}:`, - error - ); + console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error); } }; @@ -2010,28 +1853,28 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }; streamLoop: for await (const msg of stream) { - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === "text") { + if (block.type === 'text') { // Add separator before new text if we already have content and it doesn't end with newlines - if (responseText.length > 0 && !responseText.endsWith("\n\n")) { - if (responseText.endsWith("\n")) { - responseText += "\n"; + if (responseText.length > 0 && !responseText.endsWith('\n\n')) { + if (responseText.endsWith('\n')) { + responseText += '\n'; } else { - responseText += "\n\n"; + responseText += '\n\n'; } } - responseText += block.text || ""; + responseText += block.text || ''; // Check for authentication errors in the response if ( block.text && - (block.text.includes("Invalid API key") || - block.text.includes("authentication_failed") || - block.text.includes("Fix external API key")) + (block.text.includes('Invalid API key') || + block.text.includes('authentication_failed') || + block.text.includes('Fix external API key')) ) { throw new Error( - "Authentication failed: Invalid or expired API key. " + + 'Authentication failed: Invalid or expired API key. ' + "Please check your ANTHROPIC_API_KEY, or run 'claude login' to re-authenticate." ); } @@ -2043,12 +1886,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. if ( planningModeRequiresApproval && !specDetected && - responseText.includes("[SPEC_GENERATED]") + responseText.includes('[SPEC_GENERATED]') ) { specDetected = true; // Extract plan content (everything before the marker) - const markerIndex = responseText.indexOf("[SPEC_GENERATED]"); + const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); const planContent = responseText.substring(0, markerIndex).trim(); // Parse tasks from the generated spec (for spec and full modes) @@ -2060,14 +1903,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. `[AutoMode] Parsed ${tasksTotal} tasks from spec for feature ${featureId}` ); if (parsedTasks.length > 0) { - console.log( - `[AutoMode] Tasks: ${parsedTasks.map((t) => t.id).join(", ")}` - ); + console.log(`[AutoMode] Tasks: ${parsedTasks.map((t) => t.id).join(', ')}`); } // Update planSpec status to 'generated' and save content with parsed tasks await this.updateFeaturePlanSpec(projectPath, featureId, { - status: "generated", + status: 'generated', content: planContent, version: 1, generatedAt: new Date().toISOString(), @@ -2096,13 +1937,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); // CRITICAL: Register pending approval BEFORE emitting event - const approvalPromise = this.waitForPlanApproval( - featureId, - projectPath - ); + const approvalPromise = this.waitForPlanApproval(featureId, projectPath); // Emit plan_approval_required event - this.emitAutoModeEvent("plan_approval_required", { + this.emitAutoModeEvent('plan_approval_required', { featureId, projectPath, planContent: currentPlanContent, @@ -2124,13 +1962,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // If user provided edits, use the edited version if (approvalResult.editedPlan) { approvedPlanContent = approvalResult.editedPlan; - await this.updateFeaturePlanSpec( - projectPath, - featureId, - { - content: approvalResult.editedPlan, - } - ); + await this.updateFeaturePlanSpec(projectPath, featureId, { + content: approvalResult.editedPlan, + }); } else { approvedPlanContent = currentPlanContent; } @@ -2139,7 +1973,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. userFeedback = approvalResult.feedback; // Emit approval event - this.emitAutoModeEvent("plan_approved", { + this.emitAutoModeEvent('plan_approved', { featureId, projectPath, hasEdits: !!approvalResult.editedPlan, @@ -2148,18 +1982,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. } else { // User rejected - check if they provided feedback for revision const hasFeedback = - approvalResult.feedback && - approvalResult.feedback.trim().length > 0; + approvalResult.feedback && approvalResult.feedback.trim().length > 0; const hasEdits = - approvalResult.editedPlan && - approvalResult.editedPlan.trim().length > 0; + approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0; if (!hasFeedback && !hasEdits) { // No feedback or edits = explicit cancel console.log( `[AutoMode] Plan rejected without feedback for feature ${featureId}, cancelling` ); - throw new Error("Plan cancelled by user"); + throw new Error('Plan cancelled by user'); } // User wants revisions - regenerate the plan @@ -2169,7 +2001,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. planVersion++; // Emit revision event - this.emitAutoModeEvent("plan_revision_requested", { + this.emitAutoModeEvent('plan_revision_requested', { featureId, projectPath, feedback: approvalResult.feedback, @@ -2184,7 +2016,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ${hasEdits ? approvalResult.editedPlan : currentPlanContent} ## User Feedback -${approvalResult.feedback || "Please revise the plan based on the edits above."} +${approvalResult.feedback || 'Please revise the plan based on the edits above.'} ## Instructions Please regenerate the specification incorporating the user's feedback. @@ -2195,7 +2027,7 @@ After generating the revised spec, output: // Update status to regenerating await this.updateFeaturePlanSpec(projectPath, featureId, { - status: "generating", + status: 'generating', version: planVersion, }); @@ -2209,51 +2041,40 @@ After generating the revised spec, output: abortController, }); - let revisionText = ""; + let revisionText = ''; for await (const msg of revisionStream) { - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === "text") { - revisionText += block.text || ""; - this.emitAutoModeEvent("auto_mode_progress", { + if (block.type === 'text') { + revisionText += block.text || ''; + this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, }); } } - } else if (msg.type === "error") { - throw new Error( - msg.error || "Error during plan revision" - ); - } else if ( - msg.type === "result" && - msg.subtype === "success" - ) { - revisionText += msg.result || ""; + } else if (msg.type === 'error') { + throw new Error(msg.error || 'Error during plan revision'); + } else if (msg.type === 'result' && msg.subtype === 'success') { + revisionText += msg.result || ''; } } // Extract new plan content - const markerIndex = - revisionText.indexOf("[SPEC_GENERATED]"); + const markerIndex = revisionText.indexOf('[SPEC_GENERATED]'); if (markerIndex > 0) { - currentPlanContent = revisionText - .substring(0, markerIndex) - .trim(); + currentPlanContent = revisionText.substring(0, markerIndex).trim(); } else { currentPlanContent = revisionText.trim(); } // Re-parse tasks from revised plan - const revisedTasks = - parseTasksFromSpec(currentPlanContent); - console.log( - `[AutoMode] Revised plan has ${revisedTasks.length} tasks` - ); + const revisedTasks = parseTasksFromSpec(currentPlanContent); + console.log(`[AutoMode] Revised plan has ${revisedTasks.length} tasks`); // Update planSpec with revised content await this.updateFeaturePlanSpec(projectPath, featureId, { - status: "generated", + status: 'generated', content: currentPlanContent, version: planVersion, tasks: revisedTasks, @@ -2267,12 +2088,10 @@ After generating the revised spec, output: responseText += revisionText; } } catch (error) { - if ((error as Error).message.includes("cancelled")) { + if ((error as Error).message.includes('cancelled')) { throw error; } - throw new Error( - `Plan approval failed: ${(error as Error).message}` - ); + throw new Error(`Plan approval failed: ${(error as Error).message}`); } } } else { @@ -2282,7 +2101,7 @@ After generating the revised spec, output: ); // Emit info event for frontend - this.emitAutoModeEvent("plan_auto_approved", { + this.emitAutoModeEvent('plan_auto_approved', { featureId, projectPath, planContent, @@ -2300,7 +2119,7 @@ After generating the revised spec, output: // Update planSpec status to approved (handles both manual and auto-approval paths) await this.updateFeaturePlanSpec(projectPath, featureId, { - status: "approved", + status: 'approved', approvedAt: new Date().toISOString(), reviewedByUser: requiresApproval, }); @@ -2316,23 +2135,17 @@ After generating the revised spec, output: ); // Execute each task with a separate agent - for ( - let taskIndex = 0; - taskIndex < parsedTasks.length; - taskIndex++ - ) { + for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { const task = parsedTasks[taskIndex]; // Check for abort if (abortController.signal.aborted) { - throw new Error("Feature execution aborted"); + throw new Error('Feature execution aborted'); } // Emit task started - console.log( - `[AutoMode] Starting task ${task.id}: ${task.description}` - ); - this.emitAutoModeEvent("auto_mode_task_started", { + console.log(`[AutoMode] Starting task ${task.id}: ${task.description}`); + this.emitAutoModeEvent('auto_mode_task_started', { featureId, projectPath, taskId: task.id, @@ -2365,45 +2178,38 @@ After generating the revised spec, output: abortController, }); - let taskOutput = ""; + let taskOutput = ''; // Process task stream for await (const msg of taskStream) { - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === "text") { - taskOutput += block.text || ""; - responseText += block.text || ""; - this.emitAutoModeEvent("auto_mode_progress", { + if (block.type === 'text') { + taskOutput += block.text || ''; + responseText += block.text || ''; + this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, }); - } else if (block.type === "tool_use") { - this.emitAutoModeEvent("auto_mode_tool", { + } else if (block.type === 'tool_use') { + this.emitAutoModeEvent('auto_mode_tool', { featureId, tool: block.name, input: block.input, }); } } - } else if (msg.type === "error") { - throw new Error( - msg.error || `Error during task ${task.id}` - ); - } else if ( - msg.type === "result" && - msg.subtype === "success" - ) { - taskOutput += msg.result || ""; - responseText += msg.result || ""; + } else if (msg.type === 'error') { + throw new Error(msg.error || `Error during task ${task.id}`); + } else if (msg.type === 'result' && msg.subtype === 'success') { + taskOutput += msg.result || ''; + responseText += msg.result || ''; } } // Emit task completed - console.log( - `[AutoMode] Task ${task.id} completed for feature ${featureId}` - ); - this.emitAutoModeEvent("auto_mode_task_complete", { + console.log(`[AutoMode] Task ${task.id} completed for feature ${featureId}`); + this.emitAutoModeEvent('auto_mode_task_complete', { featureId, projectPath, taskId: task.id, @@ -2423,7 +2229,7 @@ After generating the revised spec, output: // Phase changed, emit phase complete const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); if (phaseMatch) { - this.emitAutoModeEvent("auto_mode_phase_complete", { + this.emitAutoModeEvent('auto_mode_phase_complete', { featureId, projectPath, phaseNumber: parseInt(phaseMatch[1], 10), @@ -2443,7 +2249,7 @@ After generating the revised spec, output: ); const continuationPrompt = `The plan/specification has been approved. Now implement it. -${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ""} +${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ''} ## Approved Plan ${approvedPlanContent} @@ -2462,76 +2268,65 @@ Implement all the changes described in the plan above.`; }); for await (const msg of continuationStream) { - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === "text") { - responseText += block.text || ""; - this.emitAutoModeEvent("auto_mode_progress", { + if (block.type === 'text') { + responseText += block.text || ''; + this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, }); - } else if (block.type === "tool_use") { - this.emitAutoModeEvent("auto_mode_tool", { + } else if (block.type === 'tool_use') { + this.emitAutoModeEvent('auto_mode_tool', { featureId, tool: block.name, input: block.input, }); } } - } else if (msg.type === "error") { - throw new Error( - msg.error || "Unknown error during implementation" - ); - } else if ( - msg.type === "result" && - msg.subtype === "success" - ) { - responseText += msg.result || ""; + } else if (msg.type === 'error') { + throw new Error(msg.error || 'Unknown error during implementation'); + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText += msg.result || ''; } } } - console.log( - `[AutoMode] Implementation completed for feature ${featureId}` - ); + console.log(`[AutoMode] Implementation completed for feature ${featureId}`); // Exit the original stream loop since continuation is done break streamLoop; } // Only emit progress for non-marker text (marker was already handled above) if (!specDetected) { - this.emitAutoModeEvent("auto_mode_progress", { + this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, }); } - } else if (block.type === "tool_use") { + } else if (block.type === 'tool_use') { // Emit event for real-time UI - this.emitAutoModeEvent("auto_mode_tool", { + this.emitAutoModeEvent('auto_mode_tool', { featureId, tool: block.name, input: block.input, }); // Also add to file output for persistence - if (responseText.length > 0 && !responseText.endsWith("\n")) { - responseText += "\n"; + if (responseText.length > 0 && !responseText.endsWith('\n')) { + responseText += '\n'; } responseText += `\n🔧 Tool: ${block.name}\n`; if (block.input) { - responseText += `Input: ${JSON.stringify( - block.input, - null, - 2 - )}\n`; + responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; } scheduleWrite(); } } - } else if (msg.type === "error") { + } else if (msg.type === 'error') { // Handle error messages - throw new Error(msg.error || "Unknown error"); - } else if (msg.type === "result" && msg.subtype === "success") { + throw new Error(msg.error || 'Unknown error'); + } else if (msg.type === 'result' && msg.subtype === 'success') { // Don't replace responseText - the accumulated content is the full history // The msg.result is just a summary which would lose all tool use details // Just ensure final write happens @@ -2570,16 +2365,9 @@ ${context} ## Instructions Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; - return this.executeFeature( - projectPath, - featureId, - useWorktrees, - false, - undefined, - { - continuationPrompt: prompt, - } - ); + return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { + continuationPrompt: prompt, + }); } /** @@ -2604,8 +2392,8 @@ You are executing a specific task as part of a larger feature implementation. **Task ID:** ${task.id} **Description:** ${task.description} -${task.filePath ? `**Primary File:** ${task.filePath}` : ""} -${task.phase ? `**Phase:** ${task.phase}` : ""} +${task.filePath ? `**Primary File:** ${task.filePath}` : ''} +${task.phase ? `**Phase:** ${task.phase}` : ''} ## Context @@ -2614,7 +2402,7 @@ ${task.phase ? `**Phase:** ${task.phase}` : ""} // Show what's already done if (completedTasks.length > 0) { prompt += `### Already Completed (${completedTasks.length} tasks) -${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join("\n")} +${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')} `; } @@ -2625,8 +2413,8 @@ ${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join("\n")} ${remainingTasks .slice(0, 3) .map((t) => `- [ ] ${t.id}: ${t.description}`) - .join("\n")} -${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ""} + .join('\n')} +${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''} `; } @@ -2662,12 +2450,9 @@ Begin implementing task ${task.id} now.`; * All auto-mode events are sent as type "auto-mode:event" with the actual * event type and data in the payload. */ - private emitAutoModeEvent( - eventType: string, - data: Record - ): void { + private emitAutoModeEvent(eventType: string, data: Record): void { // Wrap the event in auto-mode:event format expected by the client - this.events.emit("auto-mode:event", { + this.events.emit('auto-mode:event', { type: eventType, ...data, }); @@ -2680,17 +2465,17 @@ Begin implementing task ${task.id} now.`; // If signal is provided and already aborted, reject immediately if (signal?.aborted) { clearTimeout(timeout); - reject(new Error("Aborted")); + reject(new Error('Aborted')); return; } // Listen for abort signal if (signal) { signal.addEventListener( - "abort", + 'abort', () => { clearTimeout(timeout); - reject(new Error("Aborted")); + reject(new Error('Aborted')); }, { once: true } ); diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 40134530..1912fb8e 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -7,10 +7,10 @@ * Developers should configure their projects to use the PORT environment variable. */ -import { spawn, execSync, type ChildProcess } from "child_process"; -import { existsSync } from "fs"; -import path from "path"; -import net from "net"; +import { spawn, execSync, type ChildProcess } from 'child_process'; +import * as secureFs from '../lib/secure-fs.js'; +import path from 'path'; +import net from 'net'; export interface DevServerInfo { worktreePath: string; @@ -40,12 +40,12 @@ class DevServerService { // Then check if the system has it in use return new Promise((resolve) => { const server = net.createServer(); - server.once("error", () => resolve(false)); - server.once("listening", () => { + server.once('error', () => resolve(false)); + server.once('listening', () => { server.close(); resolve(true); }); - server.listen(port, "127.0.0.1"); + server.listen(port, '127.0.0.1'); }); } @@ -54,21 +54,21 @@ class DevServerService { */ private killProcessOnPort(port: number): void { try { - if (process.platform === "win32") { + if (process.platform === 'win32') { // Windows: find and kill process on port - const result = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf-8" }); - const lines = result.trim().split("\n"); + const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' }); + const lines = result.trim().split('\n'); const pids = new Set(); for (const line of lines) { const parts = line.trim().split(/\s+/); const pid = parts[parts.length - 1]; - if (pid && pid !== "0") { + if (pid && pid !== '0') { pids.add(pid); } } for (const pid of pids) { try { - execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); + execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); console.log(`[DevServerService] Killed process ${pid} on port ${port}`); } catch { // Process may have already exited @@ -77,11 +77,11 @@ class DevServerService { } else { // macOS/Linux: use lsof to find and kill process try { - const result = execSync(`lsof -ti:${port}`, { encoding: "utf-8" }); - const pids = result.trim().split("\n").filter(Boolean); + const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8' }); + const pids = result.trim().split('\n').filter(Boolean); for (const pid of pids) { try { - execSync(`kill -9 ${pid}`, { stdio: "ignore" }); + execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); console.log(`[DevServerService] Killed process ${pid} on port ${port}`); } catch { // Process may have already exited @@ -127,37 +127,47 @@ class DevServerService { throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`); } + /** + * Helper to check if a file exists using secureFs + */ + private async fileExists(filePath: string): Promise { + try { + await secureFs.access(filePath); + return true; + } catch { + return false; + } + } + /** * Detect the package manager used in a directory */ - private detectPackageManager( - dir: string - ): "npm" | "yarn" | "pnpm" | "bun" | null { - if (existsSync(path.join(dir, "bun.lockb"))) return "bun"; - if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm"; - if (existsSync(path.join(dir, "yarn.lock"))) return "yarn"; - if (existsSync(path.join(dir, "package-lock.json"))) return "npm"; - if (existsSync(path.join(dir, "package.json"))) return "npm"; // Default + private async detectPackageManager(dir: string): Promise<'npm' | 'yarn' | 'pnpm' | 'bun' | null> { + if (await this.fileExists(path.join(dir, 'bun.lockb'))) return 'bun'; + if (await this.fileExists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (await this.fileExists(path.join(dir, 'yarn.lock'))) return 'yarn'; + if (await this.fileExists(path.join(dir, 'package-lock.json'))) return 'npm'; + if (await this.fileExists(path.join(dir, 'package.json'))) return 'npm'; // Default return null; } /** * Get the dev script command for a directory */ - private getDevCommand(dir: string): { cmd: string; args: string[] } | null { - const pm = this.detectPackageManager(dir); + private async getDevCommand(dir: string): Promise<{ cmd: string; args: string[] } | null> { + const pm = await this.detectPackageManager(dir); if (!pm) return null; switch (pm) { - case "bun": - return { cmd: "bun", args: ["run", "dev"] }; - case "pnpm": - return { cmd: "pnpm", args: ["run", "dev"] }; - case "yarn": - return { cmd: "yarn", args: ["dev"] }; - case "npm": + case 'bun': + return { cmd: 'bun', args: ['run', 'dev'] }; + case 'pnpm': + return { cmd: 'pnpm', args: ['run', 'dev'] }; + case 'yarn': + return { cmd: 'yarn', args: ['dev'] }; + case 'npm': default: - return { cmd: "npm", args: ["run", "dev"] }; + return { cmd: 'npm', args: ['run', 'dev'] }; } } @@ -192,7 +202,7 @@ class DevServerService { } // Verify the worktree exists - if (!existsSync(worktreePath)) { + if (!(await this.fileExists(worktreePath))) { return { success: false, error: `Worktree path does not exist: ${worktreePath}`, @@ -200,8 +210,8 @@ class DevServerService { } // Check for package.json - const packageJsonPath = path.join(worktreePath, "package.json"); - if (!existsSync(packageJsonPath)) { + const packageJsonPath = path.join(worktreePath, 'package.json'); + if (!(await this.fileExists(packageJsonPath))) { return { success: false, error: `No package.json found in: ${worktreePath}`, @@ -209,7 +219,7 @@ class DevServerService { } // Get dev command - const devCommand = this.getDevCommand(worktreePath); + const devCommand = await this.getDevCommand(worktreePath); if (!devCommand) { return { success: false, @@ -224,7 +234,7 @@ class DevServerService { } catch (error) { return { success: false, - error: error instanceof Error ? error.message : "Port allocation failed", + error: error instanceof Error ? error.message : 'Port allocation failed', }; } @@ -241,14 +251,10 @@ class DevServerService { // Small delay to ensure related ports are freed await new Promise((resolve) => setTimeout(resolve, 100)); + console.log(`[DevServerService] Starting dev server on port ${port}`); + console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`); console.log( - `[DevServerService] Starting dev server on port ${port}` - ); - console.log( - `[DevServerService] Working directory (cwd): ${worktreePath}` - ); - console.log( - `[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(" ")} with PORT=${port}` + `[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}` ); // Spawn the dev process with PORT environment variable @@ -260,7 +266,7 @@ class DevServerService { const devProcess = spawn(devCommand.cmd, devCommand.args, { cwd: worktreePath, env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ['ignore', 'pipe', 'pipe'], detached: false, }); @@ -269,29 +275,27 @@ class DevServerService { // Log output for debugging if (devProcess.stdout) { - devProcess.stdout.on("data", (data: Buffer) => { + devProcess.stdout.on('data', (data: Buffer) => { console.log(`[DevServer:${port}] ${data.toString().trim()}`); }); } if (devProcess.stderr) { - devProcess.stderr.on("data", (data: Buffer) => { + devProcess.stderr.on('data', (data: Buffer) => { const msg = data.toString().trim(); console.error(`[DevServer:${port}] ${msg}`); }); } - devProcess.on("error", (error) => { + devProcess.on('error', (error) => { console.error(`[DevServerService] Process error:`, error); status.error = error.message; this.allocatedPorts.delete(port); this.runningServers.delete(worktreePath); }); - devProcess.on("exit", (code) => { - console.log( - `[DevServerService] Process for ${worktreePath} exited with code ${code}` - ); + devProcess.on('exit', (code) => { + console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`); status.exited = true; this.allocatedPorts.delete(port); this.runningServers.delete(worktreePath); @@ -348,7 +352,9 @@ class DevServerService { // If we don't have a record of this server, it may have crashed/exited on its own // Return success so the frontend can clear its state if (!server) { - console.log(`[DevServerService] No server record for ${worktreePath}, may have already stopped`); + console.log( + `[DevServerService] No server record for ${worktreePath}, may have already stopped` + ); return { success: true, result: { @@ -362,7 +368,7 @@ class DevServerService { // Kill the process if (server.process && !server.process.killed) { - server.process.kill("SIGTERM"); + server.process.kill('SIGTERM'); } // Free the port @@ -447,13 +453,13 @@ export function getDevServerService(): DevServerService { } // Cleanup on process exit -process.on("SIGTERM", async () => { +process.on('SIGTERM', async () => { if (devServerServiceInstance) { await devServerServiceInstance.stopAll(); } }); -process.on("SIGINT", async () => { +process.on('SIGINT', async () => { if (devServerServiceInstance) { await devServerServiceInstance.stopAll(); } diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 4dc52c72..cca6aa22 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -1,7 +1,7 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from "react"; -import { useNavigate, useLocation } from "@tanstack/react-router"; -import { cn } from "@/lib/utils"; -import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store"; +import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react'; +import { useNavigate, useLocation } from '@tanstack/react-router'; +import { cn } from '@/lib/utils'; +import { useAppStore, formatShortcut, type ThemeMode } from '@/store/app-store'; import { FolderOpen, Plus, @@ -36,7 +36,9 @@ import { Zap, CheckCircle2, ArrowRight, -} from "lucide-react"; + Moon, + Sun, +} from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -49,7 +51,7 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, -} from "@/components/ui/dropdown-menu"; +} from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, @@ -57,31 +59,22 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, KeyboardShortcut, -} from "@/hooks/use-keyboard-shortcuts"; -import { - getElectronAPI, - Project, - TrashedProject, - RunningAgent, -} from "@/lib/electron"; -import { - initializeProject, - hasAppSpec, - hasAutomakerDir, -} from "@/lib/project-init"; -import { toast } from "sonner"; -import { themeOptions } from "@/config/theme-options"; -import type { SpecRegenerationEvent } from "@/types/electron"; -import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog"; -import { NewProjectModal } from "@/components/new-project-modal"; -import { CreateSpecDialog } from "@/components/views/spec-view/dialogs"; -import type { FeatureCount } from "@/components/views/spec-view/types"; +} from '@/hooks/use-keyboard-shortcuts'; +import { getElectronAPI, Project, TrashedProject, RunningAgent } from '@/lib/electron'; +import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; +import { toast } from 'sonner'; +import { themeOptions } from '@/config/theme-options'; +import type { SpecRegenerationEvent } from '@/types/electron'; +import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog'; +import { NewProjectModal } from '@/components/new-project-modal'; +import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; +import type { FeatureCount } from '@/components/views/spec-view/types'; import { DndContext, DragEndEvent, @@ -89,15 +82,11 @@ import { useSensor, useSensors, closestCenter, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { getHttpApiClient } from "@/lib/http-api-client"; -import type { StarterTemplate } from "@/lib/templates"; +} from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import type { StarterTemplate } from '@/lib/templates'; interface NavSection { label?: string; @@ -125,14 +114,9 @@ function SortableProjectItem({ isHighlighted, onSelect, }: SortableProjectItemProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: project.id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: project.id, + }); const style = { transform: CSS.Transform.toString(transform), @@ -145,11 +129,10 @@ function SortableProjectItem({ ref={setNodeRef} style={style} className={cn( - "flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-200", - "text-muted-foreground hover:text-foreground hover:bg-accent/80", - isDragging && "bg-accent shadow-lg scale-[1.02]", - isHighlighted && - "bg-brand-500/10 text-foreground ring-1 ring-brand-500/20" + 'flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-200', + 'text-muted-foreground hover:text-foreground hover:bg-accent/80', + isDragging && 'bg-accent shadow-lg scale-[1.02]', + isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20' )} data-testid={`project-option-${project.id}`} > @@ -165,36 +148,72 @@ function SortableProjectItem({ {/* Project content - clickable area */} -
onSelect(project)} - > +
onSelect(project)}> - - {project.name} - - {currentProjectId === project.id && ( - - )} + {project.name} + {currentProjectId === project.id && }
); } // Theme options for project theme selector - derived from the shared config -const PROJECT_THEME_OPTIONS = [ - { value: "", label: "Use Global", icon: Monitor }, - ...themeOptions.map((opt) => ({ - value: opt.value, - label: opt.label, - icon: opt.Icon, - })), -] as const; +import { darkThemes, lightThemes } from '@/config/theme-options'; + +const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + color: opt.color, +})); + +const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({ + value: opt.value, + label: opt.label, + icon: opt.Icon, + color: opt.color, +})); + +// Memoized theme menu item to prevent re-renders during hover +interface ThemeMenuItemProps { + option: { + value: string; + label: string; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + color: string; + }; + onPreviewEnter: (value: string) => void; + onPreviewLeave: (e: React.PointerEvent) => void; +} + +const ThemeMenuItem = memo(function ThemeMenuItem({ + option, + onPreviewEnter, + onPreviewLeave, +}: ThemeMenuItemProps) { + const Icon = option.icon; + return ( +
onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + > + + + {option.label} + +
+ ); +}); // Reusable Bug Report Button Component const BugReportButton = ({ sidebarExpanded, - onClick + onClick, }: { sidebarExpanded: boolean; onClick: () => void; @@ -203,15 +222,15 @@ const BugReportButton = ({ @@ -248,20 +267,19 @@ export function Sidebar() { } = useAppStore(); // Environment variable flags for hiding sidebar items - const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === "true"; - const hideWiki = import.meta.env.VITE_HIDE_WIKI === "true"; - const hideRunningAgents = - import.meta.env.VITE_HIDE_RUNNING_AGENTS === "true"; - const hideContext = import.meta.env.VITE_HIDE_CONTEXT === "true"; - const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === "true"; - const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === "true"; + const hideTerminal = import.meta.env.VITE_HIDE_TERMINAL === 'true'; + const hideWiki = import.meta.env.VITE_HIDE_WIKI === 'true'; + const hideRunningAgents = import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true'; + const hideContext = import.meta.env.VITE_HIDE_CONTEXT === 'true'; + const hideSpecEditor = import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true'; + const hideAiProfiles = import.meta.env.VITE_HIDE_AI_PROFILES === 'true'; // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); // State for project picker dropdown const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); - const [projectSearchQuery, setProjectSearchQuery] = useState(""); + const [projectSearchQuery, setProjectSearchQuery] = useState(''); const [selectedProjectIndex, setSelectedProjectIndex] = useState(0); const [showTrashDialog, setShowTrashDialog] = useState(false); const [activeTrashId, setActiveTrashId] = useState(null); @@ -279,18 +297,58 @@ export function Sidebar() { // State for new project onboarding dialog const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); - const [newProjectName, setNewProjectName] = useState(""); - const [newProjectPath, setNewProjectPath] = useState(""); + const [newProjectName, setNewProjectName] = useState(''); + const [newProjectPath, setNewProjectPath] = useState(''); // State for new project setup dialog const [showSetupDialog, setShowSetupDialog] = useState(false); - const [setupProjectPath, setSetupProjectPath] = useState(""); - const [projectOverview, setProjectOverview] = useState(""); + const [setupProjectPath, setSetupProjectPath] = useState(''); + const [projectOverview, setProjectOverview] = useState(''); const [generateFeatures, setGenerateFeatures] = useState(true); const [analyzeProject, setAnalyzeProject] = useState(true); const [featureCount, setFeatureCount] = useState(50); const [showSpecIndicator, setShowSpecIndicator] = useState(true); + // Debounced preview theme handlers to prevent excessive re-renders + const previewTimeoutRef = useRef | null>(null); + + const handlePreviewEnter = useCallback( + (value: string) => { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + // Small delay to debounce rapid hover changes + previewTimeoutRef.current = setTimeout(() => { + setPreviewTheme(value as ThemeMode); + }, 16); // ~1 frame delay + }, + [setPreviewTheme] + ); + + const handlePreviewLeave = useCallback( + (e: React.PointerEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) { + // Clear any pending timeout + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + setPreviewTheme(null); + } + }, + [setPreviewTheme] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + } + }; + }, []); + // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; @@ -300,7 +358,7 @@ export function Sidebar() { // Auto-collapse sidebar on small screens useEffect(() => { - const mediaQuery = window.matchMedia("(max-width: 1024px)"); // lg breakpoint + const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint const handleResize = () => { if (mediaQuery.matches && sidebarOpen) { @@ -313,8 +371,8 @@ export function Sidebar() { handleResize(); // Listen for changes - mediaQuery.addEventListener("change", handleResize); - return () => mediaQuery.removeEventListener("change", handleResize); + mediaQuery.addEventListener('change', handleResize); + return () => mediaQuery.removeEventListener('change', handleResize); }, [sidebarOpen, toggleSidebar]); // Filtered projects based on search query @@ -323,9 +381,7 @@ export function Sidebar() { return projects; } const query = projectSearchQuery.toLowerCase(); - return projects.filter((project) => - project.name.toLowerCase().includes(query) - ); + return projects.filter((project) => project.name.toLowerCase().includes(query)); }, [projects, projectSearchQuery]); // Reset selection when filtered results change @@ -336,7 +392,7 @@ export function Sidebar() { // Reset search query when dropdown closes useEffect(() => { if (!isProjectPickerOpen) { - setProjectSearchQuery(""); + setProjectSearchQuery(''); setSelectedProjectIndex(0); } }, [isProjectPickerOpen]); @@ -382,54 +438,43 @@ export function Sidebar() { const api = getElectronAPI(); if (!api.specRegeneration) return; - const unsubscribe = api.specRegeneration.onEvent( - (event: SpecRegenerationEvent) => { - console.log( - "[Sidebar] Spec regeneration event:", - event.type, - "for project:", - event.projectPath - ); + const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { + console.log( + '[Sidebar] Spec regeneration event:', + event.type, + 'for project:', + event.projectPath + ); - // Only handle events for the project we're currently setting up - if ( - event.projectPath !== creatingSpecProjectPath && - event.projectPath !== setupProjectPath - ) { - console.log( - "[Sidebar] Ignoring event - not for project being set up" - ); - return; - } - - if (event.type === "spec_regeneration_complete") { - setSpecCreatingForProject(null); - setShowSetupDialog(false); - setProjectOverview(""); - setSetupProjectPath(""); - // Clear onboarding state if we came from onboarding - setNewProjectName(""); - setNewProjectPath(""); - toast.success("App specification created", { - description: "Your project is now set up and ready to go!", - }); - } else if (event.type === "spec_regeneration_error") { - setSpecCreatingForProject(null); - toast.error("Failed to create specification", { - description: event.error, - }); - } + // Only handle events for the project we're currently setting up + if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) { + console.log('[Sidebar] Ignoring event - not for project being set up'); + return; } - ); + + if (event.type === 'spec_regeneration_complete') { + setSpecCreatingForProject(null); + setShowSetupDialog(false); + setProjectOverview(''); + setSetupProjectPath(''); + // Clear onboarding state if we came from onboarding + setNewProjectName(''); + setNewProjectPath(''); + toast.success('App specification created', { + description: 'Your project is now set up and ready to go!', + }); + } else if (event.type === 'spec_regeneration_error') { + setSpecCreatingForProject(null); + toast.error('Failed to create specification', { + description: event.error, + }); + } + }); return () => { unsubscribe(); }; - }, [ - creatingSpecProjectPath, - setupProjectPath, - setSpecCreatingForProject, - ]); + }, [creatingSpecProjectPath, setupProjectPath, setSpecCreatingForProject]); // Fetch running agents count function - used for initial load and event-driven updates const fetchRunningAgentsCount = useCallback(async () => { @@ -442,7 +487,7 @@ export function Sidebar() { } } } catch (error) { - console.error("[Sidebar] Error fetching running agents count:", error); + console.error('[Sidebar] Error fetching running agents count:', error); } }, []); @@ -461,9 +506,9 @@ export function Sidebar() { const unsubscribe = api.autoMode.onEvent((event) => { // When a feature starts, completes, or errors, refresh the count if ( - event.type === "auto_mode_feature_complete" || - event.type === "auto_mode_error" || - event.type === "auto_mode_feature_start" + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'auto_mode_feature_start' ) { fetchRunningAgentsCount(); } @@ -486,7 +531,7 @@ export function Sidebar() { try { const api = getElectronAPI(); if (!api.specRegeneration) { - toast.error("Spec regeneration not available"); + toast.error('Spec regeneration not available'); setSpecCreatingForProject(null); return; } @@ -499,24 +544,23 @@ export function Sidebar() { ); if (!result.success) { - console.error("[Sidebar] Failed to start spec creation:", result.error); + console.error('[Sidebar] Failed to start spec creation:', result.error); setSpecCreatingForProject(null); - toast.error("Failed to create specification", { + toast.error('Failed to create specification', { description: result.error, }); } else { // Show processing toast to inform user - toast.info("Generating app specification...", { - description: - "This may take a minute. You'll be notified when complete.", + toast.info('Generating app specification...', { + description: "This may take a minute. You'll be notified when complete.", }); } // If successful, we'll wait for the events to update the state } catch (error) { - console.error("[Sidebar] Failed to create spec:", error); + console.error('[Sidebar] Failed to create spec:', error); setSpecCreatingForProject(null); - toast.error("Failed to create specification", { - description: error instanceof Error ? error.message : "Unknown error", + toast.error('Failed to create specification', { + description: error instanceof Error ? error.message : 'Unknown error', }); } }, [ @@ -531,15 +575,15 @@ export function Sidebar() { // Handle skipping setup const handleSkipSetup = useCallback(() => { setShowSetupDialog(false); - setProjectOverview(""); - setSetupProjectPath(""); + setProjectOverview(''); + setSetupProjectPath(''); // Clear onboarding state if we came from onboarding if (newProjectPath) { - setNewProjectName(""); - setNewProjectPath(""); + setNewProjectName(''); + setNewProjectPath(''); } - toast.info("Setup skipped", { - description: "You can set up your app_spec.txt later from the Spec view.", + toast.info('Setup skipped', { + description: 'You can set up your app_spec.txt later from the Spec view.', }); }, [newProjectPath]); @@ -548,21 +592,18 @@ export function Sidebar() { setShowOnboardingDialog(false); // Navigate to the setup dialog flow setSetupProjectPath(newProjectPath); - setProjectOverview(""); + setProjectOverview(''); setShowSetupDialog(true); }, [newProjectPath]); // Handle onboarding dialog - skip const handleOnboardingSkip = useCallback(() => { setShowOnboardingDialog(false); - setNewProjectName(""); - setNewProjectPath(""); - toast.info( - "You can generate your app_spec.txt anytime from the Spec view", - { - description: "Your project is ready to use!", - } - ); + setNewProjectName(''); + setNewProjectPath(''); + toast.info('You can generate your app_spec.txt anytime from the Spec view', { + description: 'Your project is ready to use!', + }); }, []); /** @@ -578,8 +619,8 @@ export function Sidebar() { // Create project directory const mkdirResult = await api.mkdir(projectPath); if (!mkdirResult.success) { - toast.error("Failed to create project directory", { - description: mkdirResult.error || "Unknown error occurred", + toast.error('Failed to create project directory', { + description: mkdirResult.error || 'Unknown error occurred', }); return; } @@ -588,8 +629,8 @@ export function Sidebar() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -620,18 +661,12 @@ export function Sidebar() {
` ); - const trashedProject = trashedProjects.find( - (p) => p.path === projectPath - ); + const trashedProject = trashedProjects.find((p) => p.path === projectPath); const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; - const project = upsertAndSetCurrentProject( - projectPath, - projectName, - effectiveTheme - ); + const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); setShowNewProjectModal(false); @@ -640,13 +675,13 @@ export function Sidebar() { setNewProjectPath(projectPath); setShowOnboardingDialog(true); - toast.success("Project created", { + toast.success('Project created', { description: `Created ${projectName} with .automaker directory`, }); } catch (error) { - console.error("[Sidebar] Failed to create project:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Sidebar] Failed to create project:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreatingProject(false); @@ -659,11 +694,7 @@ export function Sidebar() { * Create a project from a GitHub starter template */ const handleCreateFromTemplate = useCallback( - async ( - template: StarterTemplate, - projectName: string, - parentDir: string - ) => { + async (template: StarterTemplate, projectName: string, parentDir: string) => { setIsCreatingProject(true); try { const httpClient = getHttpApiClient(); @@ -677,8 +708,8 @@ export function Sidebar() { ); if (!cloneResult.success || !cloneResult.projectPath) { - toast.error("Failed to clone template", { - description: cloneResult.error || "Unknown error occurred", + toast.error('Failed to clone template', { + description: cloneResult.error || 'Unknown error occurred', }); return; } @@ -689,8 +720,8 @@ export function Sidebar() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -708,15 +739,11 @@ export function Sidebar() { - ${template.techStack - .map((tech) => `${tech}`) - .join("\n ")} + ${template.techStack.map((tech) => `${tech}`).join('\n ')} - ${template.features - .map((feature) => `${feature}`) - .join("\n ")} + ${template.features.map((feature) => `${feature}`).join('\n ')} @@ -725,18 +752,12 @@ export function Sidebar() {
` ); - const trashedProject = trashedProjects.find( - (p) => p.path === projectPath - ); + const trashedProject = trashedProjects.find((p) => p.path === projectPath); const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; - const project = upsertAndSetCurrentProject( - projectPath, - projectName, - effectiveTheme - ); + const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); setShowNewProjectModal(false); @@ -745,16 +766,13 @@ export function Sidebar() { setNewProjectPath(projectPath); setShowOnboardingDialog(true); - toast.success("Project created from template", { + toast.success('Project created from template', { description: `Created ${projectName} from ${template.name}`, }); } catch (error) { - console.error( - "[Sidebar] Failed to create project from template:", - error - ); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Sidebar] Failed to create project from template:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreatingProject(false); @@ -774,15 +792,11 @@ export function Sidebar() { const api = getElectronAPI(); // Clone the repository - const cloneResult = await httpClient.templates.clone( - repoUrl, - projectName, - parentDir - ); + const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir); if (!cloneResult.success || !cloneResult.projectPath) { - toast.error("Failed to clone repository", { - description: cloneResult.error || "Unknown error occurred", + toast.error('Failed to clone repository', { + description: cloneResult.error || 'Unknown error occurred', }); return; } @@ -793,8 +807,8 @@ export function Sidebar() { const initResult = await initializeProject(projectPath); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -825,18 +839,12 @@ export function Sidebar() {
` ); - const trashedProject = trashedProjects.find( - (p) => p.path === projectPath - ); + const trashedProject = trashedProjects.find((p) => p.path === projectPath); const effectiveTheme = (trashedProject?.theme as ThemeMode | undefined) || (currentProject?.theme as ThemeMode | undefined) || globalTheme; - const project = upsertAndSetCurrentProject( - projectPath, - projectName, - effectiveTheme - ); + const project = upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme); setShowNewProjectModal(false); @@ -845,13 +853,13 @@ export function Sidebar() { setNewProjectPath(projectPath); setShowOnboardingDialog(true); - toast.success("Project created from repository", { + toast.success('Project created from repository', { description: `Created ${projectName} from ${repoUrl}`, }); } catch (error) { - console.error("[Sidebar] Failed to create project from URL:", error); - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Sidebar] Failed to create project from URL:', error); + toast.error('Failed to create project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setIsCreatingProject(false); @@ -863,7 +871,7 @@ export function Sidebar() { // Handle bug report button click const handleBugReportClick = useCallback(() => { const api = getElectronAPI(); - api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues"); + api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); }, []); /** @@ -877,8 +885,7 @@ export function Sidebar() { if (!result.canceled && result.filePaths[0]) { const path = result.filePaths[0]; // Extract folder name from path (works on both Windows and Mac/Linux) - const name = - path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project"; + const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project'; try { // Check if this is a brand new project (no .automaker directory) @@ -888,8 +895,8 @@ export function Sidebar() { const initResult = await initializeProject(path); if (!initResult.success) { - toast.error("Failed to initialize project", { - description: initResult.error || "Unknown error occurred", + toast.error('Failed to initialize project', { + description: initResult.error || 'Unknown error occurred', }); return; } @@ -910,43 +917,32 @@ export function Sidebar() { // This is a brand new project - show setup dialog setSetupProjectPath(path); setShowSetupDialog(true); - toast.success("Project opened", { + toast.success('Project opened', { description: `Opened ${name}. Let's set up your app specification!`, }); - } else if ( - initResult.createdFiles && - initResult.createdFiles.length > 0 - ) { - toast.success( - initResult.isNewProject ? "Project initialized" : "Project updated", - { - description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, - } - ); + } else if (initResult.createdFiles && initResult.createdFiles.length > 0) { + toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', { + description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`, + }); } else { - toast.success("Project opened", { + toast.success('Project opened', { description: `Opened ${name}`, }); } } catch (error) { - console.error("[Sidebar] Failed to open project:", error); - toast.error("Failed to open project", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Sidebar] Failed to open project:', error); + toast.error('Failed to open project', { + description: error instanceof Error ? error.message : 'Unknown error', }); } } - }, [ - trashedProjects, - upsertAndSetCurrentProject, - currentProject, - globalTheme, - ]); + }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]); const handleRestoreProject = useCallback( (projectId: string) => { restoreTrashedProject(projectId); - toast.success("Project restored", { - description: "Added back to your project list.", + toast.success('Project restored', { + description: 'Added back to your project list.', }); setShowTrashDialog(false); }, @@ -964,22 +960,22 @@ export function Sidebar() { try { const api = getElectronAPI(); if (!api.trashItem) { - throw new Error("System Trash is not available in this build."); + throw new Error('System Trash is not available in this build.'); } const result = await api.trashItem(trashedProject.path); if (!result.success) { - throw new Error(result.error || "Failed to delete project folder"); + throw new Error(result.error || 'Failed to delete project folder'); } deleteTrashedProject(trashedProject.id); - toast.success("Project folder sent to system Trash", { + toast.success('Project folder sent to system Trash', { description: trashedProject.path, }); } catch (error) { - console.error("[Sidebar] Failed to delete project from disk:", error); - toast.error("Failed to delete project folder", { - description: error instanceof Error ? error.message : "Unknown error", + console.error('[Sidebar] Failed to delete project from disk:', error); + toast.error('Failed to delete project folder', { + description: error instanceof Error ? error.message : 'Unknown error', }); } finally { setActiveTrashId(null); @@ -995,14 +991,14 @@ export function Sidebar() { } const confirmed = window.confirm( - "Clear all projects from recycle bin? This does not delete folders from disk." + 'Clear all projects from recycle bin? This does not delete folders from disk.' ); if (!confirmed) return; setIsEmptyingTrash(true); try { emptyTrash(); - toast.success("Recycle bin cleared"); + toast.success('Recycle bin cleared'); setShowTrashDialog(false); } finally { setIsEmptyingTrash(false); @@ -1012,20 +1008,20 @@ export function Sidebar() { const navSections: NavSection[] = useMemo(() => { const allToolsItems: NavItem[] = [ { - id: "spec", - label: "Spec Editor", + id: 'spec', + label: 'Spec Editor', icon: FileText, shortcut: shortcuts.spec, }, { - id: "context", - label: "Context", + id: 'context', + label: 'Context', icon: BookOpen, shortcut: shortcuts.context, }, { - id: "profiles", - label: "AI Profiles", + id: 'profiles', + label: 'AI Profiles', icon: UserCircle, shortcut: shortcuts.profiles, }, @@ -1033,13 +1029,13 @@ export function Sidebar() { // Filter out hidden items const visibleToolsItems = allToolsItems.filter((item) => { - if (item.id === "spec" && hideSpecEditor) { + if (item.id === 'spec' && hideSpecEditor) { return false; } - if (item.id === "context" && hideContext) { + if (item.id === 'context' && hideContext) { return false; } - if (item.id === "profiles" && hideAiProfiles) { + if (item.id === 'profiles' && hideAiProfiles) { return false; } return true; @@ -1048,14 +1044,14 @@ export function Sidebar() { // Build project items - Terminal is conditionally included const projectItems: NavItem[] = [ { - id: "board", - label: "Kanban Board", + id: 'board', + label: 'Kanban Board', icon: LayoutGrid, shortcut: shortcuts.board, }, { - id: "agent", - label: "Agent Runner", + id: 'agent', + label: 'Agent Runner', icon: Bot, shortcut: shortcuts.agent, }, @@ -1064,8 +1060,8 @@ export function Sidebar() { // Add Terminal to Project section if not hidden if (!hideTerminal) { projectItems.push({ - id: "terminal", - label: "Terminal", + id: 'terminal', + label: 'Terminal', icon: Terminal, shortcut: shortcuts.terminal, }); @@ -1073,11 +1069,11 @@ export function Sidebar() { return [ { - label: "Project", + label: 'Project', items: projectItems, }, { - label: "Tools", + label: 'Tools', items: visibleToolsItems, }, ]; @@ -1085,10 +1081,7 @@ export function Sidebar() { // Handle selecting the currently highlighted project const selectHighlightedProject = useCallback(() => { - if ( - filteredProjects.length > 0 && - selectedProjectIndex < filteredProjects.length - ) { + if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { setCurrentProject(filteredProjects[selectedProjectIndex]); setIsProjectPickerOpen(false); } @@ -1099,24 +1092,18 @@ export function Sidebar() { if (!isProjectPickerOpen) return; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { + if (event.key === 'Escape') { setIsProjectPickerOpen(false); - } else if (event.key === "Enter") { + } else if (event.key === 'Enter') { event.preventDefault(); selectHighlightedProject(); - } else if (event.key === "ArrowDown") { + } else if (event.key === 'ArrowDown') { event.preventDefault(); - setSelectedProjectIndex((prev) => - prev < filteredProjects.length - 1 ? prev + 1 : prev - ); - } else if (event.key === "ArrowUp") { + setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); + } else if (event.key === 'ArrowUp') { event.preventDefault(); setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if ( - event.key.toLowerCase() === "p" && - !event.metaKey && - !event.ctrlKey - ) { + } else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) { // Toggle off when P is pressed (not with modifiers) while dropdown is open // Only if not typing in the search input if (document.activeElement !== projectSearchInputRef.current) { @@ -1126,8 +1113,8 @@ export function Sidebar() { } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [isProjectPickerOpen, selectHighlightedProject, filteredProjects.length]); // Build keyboard shortcuts for navigation @@ -1138,14 +1125,14 @@ export function Sidebar() { shortcutsList.push({ key: shortcuts.toggleSidebar, action: () => toggleSidebar(), - description: "Toggle sidebar", + description: 'Toggle sidebar', }); // Open project shortcut - opens the folder selection dialog directly shortcutsList.push({ key: shortcuts.openProject, action: () => handleOpenFolder(), - description: "Open folder selection dialog", + description: 'Open folder selection dialog', }); // Project picker shortcut - only when we have projects @@ -1153,7 +1140,7 @@ export function Sidebar() { shortcutsList.push({ key: shortcuts.projectPicker, action: () => setIsProjectPickerOpen((prev) => !prev), - description: "Toggle project picker", + description: 'Toggle project picker', }); } @@ -1162,12 +1149,12 @@ export function Sidebar() { shortcutsList.push({ key: shortcuts.cyclePrevProject, action: () => cyclePrevProject(), - description: "Cycle to previous project (MRU)", + description: 'Cycle to previous project (MRU)', }); shortcutsList.push({ key: shortcuts.cycleNextProject, action: () => cycleNextProject(), - description: "Cycle to next project (LRU)", + description: 'Cycle to next project (LRU)', }); } @@ -1188,8 +1175,8 @@ export function Sidebar() { // Add settings shortcut shortcutsList.push({ key: shortcuts.settings, - action: () => navigate({ to: "/settings" }), - description: "Navigate to Settings", + action: () => navigate({ to: '/settings' }), + description: 'Navigate to Settings', }); } @@ -1212,21 +1199,21 @@ export function Sidebar() { const isActiveRoute = (id: string) => { // Map view IDs to route paths - const routePath = id === "welcome" ? "/" : `/${id}`; + const routePath = id === 'welcome' ? '/' : `/${id}`; return location.pathname === routePath; }; return (