From 077a63b03bf887ad5993365b36ed25a532b56909 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Dec 2025 01:32:26 -0500 Subject: [PATCH] refactor: replace fs with secureFs for improved file handling This commit updates various modules to utilize the secure file system operations from the secureFs module instead of the native fs module. Key changes include: - Replaced fs imports with secureFs in multiple route handlers and services to enhance security and consistency in file operations. - Added centralized validation for working directories in the sdk-options module to ensure all AI model invocations are secure. These changes aim to improve the security and maintainability of file handling across the application. --- apps/server/src/lib/sdk-options.ts | 132 +- apps/server/src/lib/worktree-metadata.ts | 41 +- .../app-spec/generate-features-from-spec.ts | 108 +- .../src/routes/app-spec/generate-spec.ts | 197 ++- .../app-spec/parse-and-create-features.ts | 62 +- apps/server/src/routes/fs/routes/browse.ts | 47 +- .../fs/routes/delete-board-background.ts | 20 +- apps/server/src/routes/fs/routes/delete.ts | 15 +- apps/server/src/routes/fs/routes/exists.ts | 28 +- apps/server/src/routes/fs/routes/image.ts | 50 +- apps/server/src/routes/fs/routes/mkdir.ts | 33 +- apps/server/src/routes/fs/routes/read.ts | 26 +- apps/server/src/routes/fs/routes/readdir.ts | 15 +- .../src/routes/fs/routes/resolve-directory.ts | 40 +- .../routes/fs/routes/save-board-background.ts | 24 +- .../server/src/routes/fs/routes/save-image.ts | 24 +- apps/server/src/routes/fs/routes/stat.ts | 15 +- .../src/routes/fs/routes/validate-path.ts | 22 +- apps/server/src/routes/fs/routes/write.ts | 22 +- .../src/routes/templates/routes/clone.ts | 115 +- .../src/routes/workspace/routes/config.ts | 21 +- .../routes/workspace/routes/directories.ts | 22 +- apps/server/src/routes/worktree/common.ts | 80 +- .../routes/worktree/routes/branch-tracking.ts | 43 +- .../src/routes/worktree/routes/create.ts | 44 +- .../src/routes/worktree/routes/diffs.ts | 32 +- .../src/routes/worktree/routes/file-diff.ts | 46 +- .../server/src/routes/worktree/routes/info.ts | 30 +- .../src/routes/worktree/routes/init-git.ts | 35 +- .../server/src/routes/worktree/routes/list.ts | 58 +- .../src/routes/worktree/routes/status.ts | 45 +- apps/server/src/services/agent-service.ts | 133 +- apps/server/src/services/auto-mode-service.ts | 839 ++++------- .../server/src/services/dev-server-service.ts | 120 +- apps/ui/src/components/layout/sidebar.tsx | 1279 ++++++++--------- .../appearance/appearance-section.tsx | 93 +- .../views/settings-view/shared/types.ts | 24 +- .../views/setup-view/steps/theme-step.tsx | 62 +- apps/ui/src/config/terminal-themes.ts | 1107 +++++++++----- apps/ui/src/config/theme-options.ts | 325 ++++- apps/ui/src/routes/__root.tsx | 96 +- apps/ui/src/store/app-store.ts | 637 +++----- apps/ui/src/styles/global.css | 283 ++-- apps/ui/src/styles/theme-imports.ts | 50 +- apps/ui/src/styles/themes/blossom.css | 92 ++ apps/ui/src/styles/themes/forest.css | 98 ++ apps/ui/src/styles/themes/github.css | 87 ++ apps/ui/src/styles/themes/gruvboxlight.css | 92 ++ apps/ui/src/styles/themes/lavender.css | 92 ++ apps/ui/src/styles/themes/mint.css | 92 ++ apps/ui/src/styles/themes/nordlight.css | 92 ++ apps/ui/src/styles/themes/ocean.css | 98 ++ apps/ui/src/styles/themes/paper.css | 92 ++ apps/ui/src/styles/themes/peach.css | 92 ++ apps/ui/src/styles/themes/rose.css | 92 ++ apps/ui/src/styles/themes/sand.css | 92 ++ apps/ui/src/styles/themes/sepia.css | 92 ++ apps/ui/src/styles/themes/sky.css | 92 ++ apps/ui/src/styles/themes/snow.css | 92 ++ apps/ui/src/styles/themes/solarizedlight.css | 92 ++ libs/git-utils/src/diff.ts | 74 +- libs/types/src/settings.ts | 123 +- 62 files changed, 4866 insertions(+), 3350 deletions(-) create mode 100644 apps/ui/src/styles/themes/blossom.css create mode 100644 apps/ui/src/styles/themes/forest.css create mode 100644 apps/ui/src/styles/themes/github.css create mode 100644 apps/ui/src/styles/themes/gruvboxlight.css create mode 100644 apps/ui/src/styles/themes/lavender.css create mode 100644 apps/ui/src/styles/themes/mint.css create mode 100644 apps/ui/src/styles/themes/nordlight.css create mode 100644 apps/ui/src/styles/themes/ocean.css create mode 100644 apps/ui/src/styles/themes/paper.css create mode 100644 apps/ui/src/styles/themes/peach.css create mode 100644 apps/ui/src/styles/themes/rose.css create mode 100644 apps/ui/src/styles/themes/sand.css create mode 100644 apps/ui/src/styles/themes/sepia.css create mode 100644 apps/ui/src/styles/themes/sky.css create mode 100644 apps/ui/src/styles/themes/snow.css create mode 100644 apps/ui/src/styles/themes/solarizedlight.css 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 (