From 85dc6312506c8f0f3c892c68fa00fc732750756c Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 23 Dec 2025 00:31:07 +0100 Subject: [PATCH] refactor: consolidate shared packages and eliminate code duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update 26+ files to import secureFs from @automaker/platform - Create shared type files: github.ts, worktree.ts, claude.ts in libs/types - Create exec-utils.ts for cross-platform shell execution - Delete redundant wrapper files: secure-fs.ts, stream-processor.ts, enhancement-prompts.ts - Update GitHub routes to use createLogError pattern - Add isENOENT helper to routes/common.ts - Fix test imports to use @automaker/prompts and @automaker/platform 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 99 +++ apps/server/src/lib/enhancement-prompts.ts | 25 - apps/server/src/lib/exec-utils.ts | 69 ++ apps/server/src/lib/secure-fs.ts | 23 - apps/server/src/lib/stream-processor.ts | 190 ----- apps/server/src/lib/worktree-metadata.ts | 20 +- apps/server/src/providers/claude-provider.ts | 3 +- .../app-spec/generate-features-from-spec.ts | 3 +- .../src/routes/app-spec/generate-spec.ts | 3 +- .../app-spec/parse-and-create-features.ts | 3 +- apps/server/src/routes/claude/types.ts | 33 +- apps/server/src/routes/common.ts | 13 +- .../routes/context/routes/describe-file.ts | 3 +- .../routes/enhance-prompt/routes/enhance.ts | 2 +- apps/server/src/routes/fs/routes/browse.ts | 3 +- .../fs/routes/delete-board-background.ts | 3 +- apps/server/src/routes/fs/routes/delete.ts | 3 +- apps/server/src/routes/fs/routes/exists.ts | 3 +- apps/server/src/routes/fs/routes/image.ts | 3 +- apps/server/src/routes/fs/routes/mkdir.ts | 3 +- apps/server/src/routes/fs/routes/read.ts | 9 +- apps/server/src/routes/fs/routes/readdir.ts | 3 +- .../src/routes/fs/routes/resolve-directory.ts | 2 +- .../routes/fs/routes/save-board-background.ts | 3 +- .../server/src/routes/fs/routes/save-image.ts | 3 +- apps/server/src/routes/fs/routes/stat.ts | 3 +- .../src/routes/fs/routes/validate-path.ts | 3 +- apps/server/src/routes/fs/routes/write.ts | 3 +- .../github/routes/check-github-remote.ts | 9 +- .../server/src/routes/github/routes/common.ts | 36 +- .../src/routes/github/routes/list-issues.ts | 29 +- .../src/routes/github/routes/list-prs.ts | 33 +- .../src/routes/templates/routes/clone.ts | 3 +- .../src/routes/workspace/routes/config.ts | 3 +- .../routes/workspace/routes/directories.ts | 3 +- apps/server/src/routes/worktree/common.ts | 58 +- .../routes/worktree/routes/branch-tracking.ts | 11 +- .../src/routes/worktree/routes/create.ts | 2 +- .../src/routes/worktree/routes/diffs.ts | 2 +- .../src/routes/worktree/routes/file-diff.ts | 2 +- .../server/src/routes/worktree/routes/info.ts | 2 +- .../src/routes/worktree/routes/init-git.ts | 2 +- .../server/src/routes/worktree/routes/list.ts | 2 +- .../src/routes/worktree/routes/pr-info.ts | 23 +- .../src/routes/worktree/routes/status.ts | 2 +- apps/server/src/services/agent-service.ts | 3 +- apps/server/src/services/auto-mode-service.ts | 6 +- .../auto-mode/feature-verification.ts | 3 +- .../src/services/auto-mode/output-writer.ts | 2 +- .../services/auto-mode/project-analyzer.ts | 6 +- .../src/services/auto-mode/task-executor.ts | 3 +- .../server/src/services/dev-server-service.ts | 2 +- apps/server/src/services/feature-loader.ts | 2 +- apps/server/src/services/settings-service.ts | 3 +- .../unit/lib/enhancement-prompts.test.ts | 2 +- .../unit/services/dev-server-service.test.ts | 16 +- docs/auto-mode-refactoring-plan.md | 671 ++++++++++++++++++ libs/types/src/claude.ts | 35 + libs/types/src/github.ts | 59 ++ libs/types/src/index.ts | 25 + libs/types/src/worktree.ts | 61 ++ 61 files changed, 1117 insertions(+), 540 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 apps/server/src/lib/enhancement-prompts.ts create mode 100644 apps/server/src/lib/exec-utils.ts delete mode 100644 apps/server/src/lib/secure-fs.ts delete mode 100644 apps/server/src/lib/stream-processor.ts create mode 100644 docs/auto-mode-refactoring-plan.md create mode 100644 libs/types/src/claude.ts create mode 100644 libs/types/src/github.ts create mode 100644 libs/types/src/worktree.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6993000e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Automaker is an autonomous AI development studio - a Kanban-based application where users describe features and AI agents (powered by Claude Agent SDK) automatically implement them. It runs as an Electron desktop app or in web browser mode. + +## Commands + +```bash +# Install dependencies +npm install + +# Build shared packages (REQUIRED before running) +npm run build:packages + +# Development +npm run dev # Interactive mode selector +npm run dev:electron # Electron desktop app +npm run dev:web # Web browser mode (localhost:3007) +npm run dev:server # Backend server only + +# Testing +npm run test # UI E2E tests (Playwright) +npm run test:headed # UI tests with visible browser +npm run test:server # Server unit tests (Vitest) +npm run test:packages # Shared package tests + +# Linting & Formatting +npm run lint # ESLint +npm run format # Prettier +npm run format:check # Check formatting + +# Building +npm run build # Build Next.js app +npm run build:electron # Build Electron distribution +``` + +## Architecture + +### Monorepo Structure (npm workspaces) + +``` +apps/ +├── ui/ # Electron + Vite + React frontend (@automaker/ui) +└── server/ # Express + WebSocket backend (@automaker/server) + +libs/ # Shared packages (@automaker/*) +├── types/ # Shared TypeScript interfaces +├── utils/ # Common utilities +├── prompts/ # AI prompt templates +├── platform/ # Platform-specific code (paths, security) +├── git-utils/ # Git operations +├── model-resolver/ # AI model configuration +└── dependency-resolver/ # Dependency management +``` + +### Key Patterns + +**State Management (UI)**: Zustand stores in `apps/ui/src/store/` + +- `app-store.ts` - Main application state (features, settings, themes) +- `setup-store.ts` - Project setup wizard state + +**Routing (UI)**: TanStack Router with file-based routes in `apps/ui/src/routes/` + +**Backend Services**: Express + WebSocket in `apps/server/src/` + +- Services in `/services/` handle business logic +- Routes in `/routes/` define API endpoints +- Providers in `/providers/` abstract AI model integrations + +**Provider Architecture**: Model-based routing via `ProviderFactory` + +- `ClaudeProvider` wraps @anthropic-ai/claude-agent-sdk +- Designed for easy addition of other providers + +**Feature Storage**: Features stored in `.automaker/features/{id}/feature.json` + +**Communication**: + +- Electron: IPC via preload script (`apps/ui/src/preload.ts`) +- Web: HTTP API client (`apps/ui/src/lib/http-api-client.ts`) + +### Important Files + +- `apps/ui/src/main.ts` - Electron main process +- `apps/server/src/index.ts` - Server entry point +- `apps/ui/src/lib/electron.ts` - IPC type definitions +- `apps/server/src/services/agent-service.ts` - AI agent session management +- `apps/server/src/providers/provider-factory.ts` - Model routing + +## Development Notes + +- Always run `npm run build:packages` after modifying any `libs/*` package +- Server runs on port 3008 by default +- UI runs on port 3007 in web mode +- Authentication: Set `ANTHROPIC_API_KEY` env var or configure via Settings diff --git a/apps/server/src/lib/enhancement-prompts.ts b/apps/server/src/lib/enhancement-prompts.ts deleted file mode 100644 index 03f85f6e..00000000 --- a/apps/server/src/lib/enhancement-prompts.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Enhancement Prompts - Re-exported from @automaker/prompts - * - * This file now re-exports enhancement prompts from the shared @automaker/prompts package - * to maintain backward compatibility with existing imports in the server codebase. - */ - -export { - IMPROVE_SYSTEM_PROMPT, - TECHNICAL_SYSTEM_PROMPT, - SIMPLIFY_SYSTEM_PROMPT, - ACCEPTANCE_SYSTEM_PROMPT, - IMPROVE_EXAMPLES, - TECHNICAL_EXAMPLES, - SIMPLIFY_EXAMPLES, - ACCEPTANCE_EXAMPLES, - getEnhancementPrompt, - getSystemPrompt, - getExamples, - buildUserPrompt, - isValidEnhancementMode, - getAvailableEnhancementModes, -} from '@automaker/prompts'; - -export type { EnhancementMode, EnhancementExample } from '@automaker/prompts'; diff --git a/apps/server/src/lib/exec-utils.ts b/apps/server/src/lib/exec-utils.ts new file mode 100644 index 00000000..4e821b4a --- /dev/null +++ b/apps/server/src/lib/exec-utils.ts @@ -0,0 +1,69 @@ +/** + * Shell execution utilities + * + * Provides cross-platform shell execution with extended PATH + * to find tools like git and gh in Electron environments. + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +/** + * Promisified exec for async/await usage + */ +export const execAsync = promisify(exec); + +/** + * Path separator for the current platform + */ +const pathSeparator = process.platform === 'win32' ? ';' : ':'; + +/** + * Additional paths to search for executables. + * Electron apps don't inherit the user's shell PATH, so we need to add + * common tool installation locations. + */ +const additionalPaths: string[] = []; + +if (process.platform === 'win32') { + // Windows paths for Git and other tools + if (process.env.LOCALAPPDATA) { + additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + } + if (process.env.PROGRAMFILES) { + additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + } + if (process.env['ProgramFiles(x86)']) { + additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`); + } +} else { + // Unix/Mac paths + additionalPaths.push( + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/usr/local/bin', // Homebrew on Intel Mac, common Linux location + '/home/linuxbrew/.linuxbrew/bin', // Linuxbrew + `${process.env.HOME}/.local/bin` // pipx, other user installs + ); +} + +/** + * Extended PATH that includes common tool installation locations. + */ +export const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)] + .filter(Boolean) + .join(pathSeparator); + +/** + * Environment variables with extended PATH for executing shell commands. + */ +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +/** + * Check if an error is ENOENT (file/path not found or spawn failed) + */ +export function isENOENT(error: unknown): boolean { + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'; +} diff --git a/apps/server/src/lib/secure-fs.ts b/apps/server/src/lib/secure-fs.ts deleted file mode 100644 index cf927cbd..00000000 --- a/apps/server/src/lib/secure-fs.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Re-export secure file system utilities from @automaker/platform - * This file exists for backward compatibility with existing imports - */ - -import { secureFs } from '@automaker/platform'; - -export const { - access, - readFile, - writeFile, - mkdir, - readdir, - stat, - rm, - unlink, - copyFile, - appendFile, - rename, - lstat, - joinPath, - resolvePath, -} = secureFs; diff --git a/apps/server/src/lib/stream-processor.ts b/apps/server/src/lib/stream-processor.ts deleted file mode 100644 index 18d66f49..00000000 --- a/apps/server/src/lib/stream-processor.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Stream Processor - Unified stream handling for provider messages - * - * Eliminates duplication of the stream processing pattern that was - * repeated 4x in auto-mode-service.ts (main execution, revision, - * task execution, continuation). - */ - -import type { ProviderMessage, ContentBlock } from '@automaker/types'; - -/** - * Callbacks for handling different stream events - */ -export interface StreamHandlers { - /** Called for each text block in the stream */ - onText?: (text: string) => void | Promise; - /** Called for each tool use in the stream */ - onToolUse?: (name: string, input: unknown) => void | Promise; - /** Called when an error occurs in the stream */ - onError?: (error: string) => void | Promise; - /** Called when the stream completes successfully */ - onComplete?: (result: string) => void | Promise; - /** Called for thinking blocks (if present) */ - onThinking?: (thinking: string) => void | Promise; -} - -/** - * Result from processing a stream - */ -export interface StreamResult { - /** All accumulated text from the stream */ - text: string; - /** Whether the stream completed successfully */ - success: boolean; - /** Error message if stream failed */ - error?: string; - /** Final result message if stream completed */ - result?: string; -} - -/** - * Process a provider message stream with unified handling - * - * This eliminates the repeated pattern of: - * ``` - * for await (const msg of stream) { - * if (msg.type === 'assistant' && msg.message?.content) { - * for (const block of msg.message.content) { - * if (block.type === 'text') { ... } - * else if (block.type === 'tool_use') { ... } - * } - * } else if (msg.type === 'error') { ... } - * else if (msg.type === 'result') { ... } - * } - * ``` - * - * @param stream - The async generator from provider.executeQuery() - * @param handlers - Callbacks for different event types - * @returns Accumulated result with text and status - */ -export async function processStream( - stream: AsyncGenerator, - handlers: StreamHandlers -): Promise { - let accumulatedText = ''; - let success = true; - let errorMessage: string | undefined; - let resultMessage: string | undefined; - - try { - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - await processContentBlock(block, handlers, (text) => { - accumulatedText += text; - }); - } - } else if (msg.type === 'error') { - success = false; - errorMessage = msg.error || 'Unknown error'; - if (handlers.onError) { - await handlers.onError(errorMessage); - } - throw new Error(errorMessage); - } else if (msg.type === 'result' && msg.subtype === 'success') { - resultMessage = msg.result || ''; - if (handlers.onComplete) { - await handlers.onComplete(resultMessage); - } - } - } - } catch (error) { - if (!errorMessage) { - success = false; - errorMessage = error instanceof Error ? error.message : String(error); - } - throw error; - } - - return { - text: accumulatedText, - success, - error: errorMessage, - result: resultMessage, - }; -} - -/** - * Process a single content block - */ -async function processContentBlock( - block: ContentBlock, - handlers: StreamHandlers, - appendText: (text: string) => void -): Promise { - switch (block.type) { - case 'text': - if (block.text) { - appendText(block.text); - if (handlers.onText) { - await handlers.onText(block.text); - } - } - break; - - case 'tool_use': - if (block.name && handlers.onToolUse) { - await handlers.onToolUse(block.name, block.input); - } - break; - - case 'thinking': - if (block.thinking && handlers.onThinking) { - await handlers.onThinking(block.thinking); - } - break; - - // tool_result blocks are handled internally by the SDK - case 'tool_result': - break; - } -} - -/** - * Create a simple stream processor that just collects text - * - * Useful for cases where you just need the final text output - * without any side effects during streaming. - */ -export async function collectStreamText(stream: AsyncGenerator): Promise { - const result = await processStream(stream, {}); - return result.text; -} - -/** - * Process stream with progress callback - * - * Simplified interface for the common case of just wanting - * text updates during streaming. - */ -export async function processStreamWithProgress( - stream: AsyncGenerator, - onProgress: (text: string) => void -): Promise { - return processStream(stream, { - onText: onProgress, - }); -} - -/** - * Check if a stream result contains a specific marker - * - * Useful for detecting spec generation markers like [SPEC_GENERATED] - */ -export function hasMarker(result: StreamResult, marker: string): boolean { - return result.text.includes(marker); -} - -/** - * Extract content before a marker - * - * Useful for extracting spec content before [SPEC_GENERATED] marker - */ -export function extractBeforeMarker(text: string, marker: string): string | null { - const index = text.indexOf(marker); - if (index === -1) { - return null; - } - return text.substring(0, index).trim(); -} diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index edeadc5b..35e7f499 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -3,26 +3,16 @@ * Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json */ -import * as secureFs from './secure-fs.js'; +import { secureFs } from '@automaker/platform'; import * as path from 'path'; +import type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types'; + +// Re-export types for convenience +export type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types'; /** Maximum length for sanitized branch names in filesystem paths */ const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200; -export interface WorktreePRInfo { - number: number; - url: string; - title: string; - state: string; - createdAt: string; -} - -export interface WorktreeMetadata { - branch: string; - createdAt: string; - pr?: WorktreePRInfo; -} - /** * Sanitize branch name for cross-platform filesystem safety */ diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 2c8abdf7..6d43588e 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -22,6 +22,7 @@ import type { SimpleQueryResult, StreamingQueryOptions, StreamingQueryResult, + PromptContentBlock, } from './types.js'; export class ClaudeProvider extends BaseProvider { @@ -335,7 +336,7 @@ export class ClaudeProvider extends BaseProvider { /** * Create a multi-part prompt generator for content blocks */ - private createPromptGenerator(content: Array<{ type: string; text?: string; source?: object }>) { + private createPromptGenerator(content: PromptContentBlock[]) { // Return an async generator that yields SDK user messages // The SDK expects this format for multi-part prompts return (async function* () { 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 899795a5..202f39f6 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 @@ -4,12 +4,11 @@ * Uses ClaudeProvider.executeStreamingQuery() for SDK interaction. */ -import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js'; -import { getAppSpecPath } from '@automaker/platform'; +import { getAppSpecPath, secureFs } from '@automaker/platform'; const logger = createLogger('SpecRegeneration'); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 9ed1029e..d1f1d20f 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -4,7 +4,6 @@ * Uses ClaudeProvider.executeStreamingQuery() for SDK interaction. */ -import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { specOutputSchema, @@ -15,7 +14,7 @@ import { import { createLogger } from '@automaker/utils'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; -import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; +import { ensureAutomakerDir, getAppSpecPath, secureFs } from '@automaker/platform'; const logger = createLogger('SpecRegeneration'); 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 364f64ad..3404a5cb 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 @@ -3,10 +3,9 @@ */ 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'; +import { getFeaturesDir, secureFs } from '@automaker/platform'; const logger = createLogger('SpecRegeneration'); diff --git a/apps/server/src/routes/claude/types.ts b/apps/server/src/routes/claude/types.ts index bd892746..97cb8374 100644 --- a/apps/server/src/routes/claude/types.ts +++ b/apps/server/src/routes/claude/types.ts @@ -1,35 +1,6 @@ /** * Claude Usage types for CLI-based usage tracking + * Re-exported from @automaker/types for convenience */ -export type ClaudeUsage = { - sessionTokensUsed: number; - sessionLimit: number; - sessionPercentage: number; - sessionResetTime: string; // ISO date string - sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)" - - weeklyTokensUsed: number; - weeklyLimit: number; - weeklyPercentage: number; - weeklyResetTime: string; // ISO date string - weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" - - sonnetWeeklyTokensUsed: number; - sonnetWeeklyPercentage: number; - sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" - - costUsed: number | null; - costLimit: number | null; - costCurrency: string | null; - - lastUpdated: string; // ISO date string - userTimezone: string; -}; - -export type ClaudeStatus = { - indicator: { - color: 'green' | 'yellow' | 'orange' | 'red' | 'gray'; - }; - description: string; -}; +export type { ClaudeUsage, ClaudeStatus } from '@automaker/types'; diff --git a/apps/server/src/routes/common.ts b/apps/server/src/routes/common.ts index 14589ffd..0c5b4cf4 100644 --- a/apps/server/src/routes/common.ts +++ b/apps/server/src/routes/common.ts @@ -18,14 +18,13 @@ export { getGitRepositoryDiffs, } from '@automaker/git-utils'; -type Logger = ReturnType; +// Re-export error utilities from shared package +export { getErrorMessage } from '@automaker/utils'; -/** - * Get error message from error object - */ -export function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Unknown error'; -} +// Re-export exec utilities +export { execAsync, execEnv, isENOENT } from '../lib/exec-utils.js'; + +type Logger = ReturnType; /** * Create a logError function for a specific logger diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 96fe30ed..609162be 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -11,9 +11,8 @@ import type { Request, Response } from 'express'; import { createLogger } from '@automaker/utils'; -import { PathNotAllowedError } from '@automaker/platform'; +import { PathNotAllowedError, secureFs } from '@automaker/platform'; import { ProviderFactory } from '../../../providers/provider-factory.js'; -import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; const logger = createLogger('DescribeFile'); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index e023e6ee..3184068a 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -13,7 +13,7 @@ import { buildUserPrompt, isValidEnhancementMode, type EnhancementMode, -} from '../../../lib/enhancement-prompts.js'; +} from '@automaker/prompts'; const logger = createLogger('EnhancePrompt'); diff --git a/apps/server/src/routes/fs/routes/browse.ts b/apps/server/src/routes/fs/routes/browse.ts index c3cd4c65..1d6f768a 100644 --- a/apps/server/src/routes/fs/routes/browse.ts +++ b/apps/server/src/routes/fs/routes/browse.ts @@ -3,10 +3,9 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform'; import os from 'os'; import path from 'path'; -import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createBrowseHandler() { 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 3f053f2c..42e59c73 100644 --- a/apps/server/src/routes/fs/routes/delete-board-background.ts +++ b/apps/server/src/routes/fs/routes/delete-board-background.ts @@ -3,10 +3,9 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getBoardDir } from '@automaker/platform'; 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 => { diff --git a/apps/server/src/routes/fs/routes/delete.ts b/apps/server/src/routes/fs/routes/delete.ts index ffb40444..ab3ae884 100644 --- a/apps/server/src/routes/fs/routes/delete.ts +++ b/apps/server/src/routes/fs/routes/delete.ts @@ -3,8 +3,7 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; -import { PathNotAllowedError } from '@automaker/platform'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createDeleteHandler() { diff --git a/apps/server/src/routes/fs/routes/exists.ts b/apps/server/src/routes/fs/routes/exists.ts index 88050889..1cb0fe62 100644 --- a/apps/server/src/routes/fs/routes/exists.ts +++ b/apps/server/src/routes/fs/routes/exists.ts @@ -3,8 +3,7 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; -import { PathNotAllowedError } from '@automaker/platform'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createExistsHandler() { diff --git a/apps/server/src/routes/fs/routes/image.ts b/apps/server/src/routes/fs/routes/image.ts index b7e8c214..154d3fa2 100644 --- a/apps/server/src/routes/fs/routes/image.ts +++ b/apps/server/src/routes/fs/routes/image.ts @@ -3,9 +3,8 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import path from 'path'; -import { PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createImageHandler() { diff --git a/apps/server/src/routes/fs/routes/mkdir.ts b/apps/server/src/routes/fs/routes/mkdir.ts index 04d0a836..0daa9164 100644 --- a/apps/server/src/routes/fs/routes/mkdir.ts +++ b/apps/server/src/routes/fs/routes/mkdir.ts @@ -4,9 +4,8 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import path from 'path'; -import { PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createMkdirHandler() { diff --git a/apps/server/src/routes/fs/routes/read.ts b/apps/server/src/routes/fs/routes/read.ts index 27ce45b4..fdf2aa86 100644 --- a/apps/server/src/routes/fs/routes/read.ts +++ b/apps/server/src/routes/fs/routes/read.ts @@ -3,9 +3,8 @@ */ 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'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError, isENOENT } from '../common.js'; // Optional files that are expected to not exist in new projects // Don't log ENOENT errors for these to reduce noise @@ -15,10 +14,6 @@ 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'; -} - export function createReadHandler() { return async (req: Request, res: Response): Promise => { try { diff --git a/apps/server/src/routes/fs/routes/readdir.ts b/apps/server/src/routes/fs/routes/readdir.ts index 43932778..8f16eec2 100644 --- a/apps/server/src/routes/fs/routes/readdir.ts +++ b/apps/server/src/routes/fs/routes/readdir.ts @@ -3,8 +3,7 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; -import { PathNotAllowedError } from '@automaker/platform'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createReaddirHandler() { diff --git a/apps/server/src/routes/fs/routes/resolve-directory.ts b/apps/server/src/routes/fs/routes/resolve-directory.ts index 5e4147db..43f21fc1 100644 --- a/apps/server/src/routes/fs/routes/resolve-directory.ts +++ b/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -3,7 +3,7 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import path from 'path'; import { getErrorMessage, logError } from '../common.js'; 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 e8988c6c..6c62f31f 100644 --- a/apps/server/src/routes/fs/routes/save-board-background.ts +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -3,10 +3,9 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getBoardDir } from '@automaker/platform'; 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 => { diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts index 059abfaf..14691698 100644 --- a/apps/server/src/routes/fs/routes/save-image.ts +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -3,10 +3,9 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getImagesDir } from '@automaker/platform'; 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 => { diff --git a/apps/server/src/routes/fs/routes/stat.ts b/apps/server/src/routes/fs/routes/stat.ts index f7df8109..69827c2e 100644 --- a/apps/server/src/routes/fs/routes/stat.ts +++ b/apps/server/src/routes/fs/routes/stat.ts @@ -3,8 +3,7 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; -import { PathNotAllowedError } from '@automaker/platform'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createStatHandler() { diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 374fe18f..0d914b24 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -3,9 +3,8 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, isPathAllowed } from '@automaker/platform'; import path from 'path'; -import { isPathAllowed } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createValidatePathHandler() { diff --git a/apps/server/src/routes/fs/routes/write.ts b/apps/server/src/routes/fs/routes/write.ts index ad70cc9e..b5d7a9fe 100644 --- a/apps/server/src/routes/fs/routes/write.ts +++ b/apps/server/src/routes/fs/routes/write.ts @@ -3,9 +3,8 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, PathNotAllowedError } from '@automaker/platform'; import path from 'path'; -import { PathNotAllowedError } from '@automaker/platform'; import { mkdirSafe } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; diff --git a/apps/server/src/routes/github/routes/check-github-remote.ts b/apps/server/src/routes/github/routes/check-github-remote.ts index 34a07198..4d51f5ba 100644 --- a/apps/server/src/routes/github/routes/check-github-remote.ts +++ b/apps/server/src/routes/github/routes/check-github-remote.ts @@ -3,14 +3,11 @@ */ import type { Request, Response } from 'express'; +import type { GitHubRemoteStatus } from '@automaker/types'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; -export interface GitHubRemoteStatus { - hasGitHubRemote: boolean; - remoteUrl: string | null; - owner: string | null; - repo: string | null; -} +// Re-export type for convenience +export type { GitHubRemoteStatus } from '@automaker/types'; export async function checkGitHubRemote(projectPath: string): Promise { const status: GitHubRemoteStatus = { diff --git a/apps/server/src/routes/github/routes/common.ts b/apps/server/src/routes/github/routes/common.ts index 790f92c3..d863fd55 100644 --- a/apps/server/src/routes/github/routes/common.ts +++ b/apps/server/src/routes/github/routes/common.ts @@ -2,34 +2,16 @@ * Common utilities for GitHub routes */ -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; +import { createLogError, getErrorMessage } from '../../common.js'; +import { execAsync, execEnv } from '../../../lib/exec-utils.js'; -export const execAsync = promisify(exec); +const logger = createLogger('GitHub'); -// Extended PATH to include common tool installation locations -export const extendedPath = [ - process.env.PATH, - '/opt/homebrew/bin', - '/usr/local/bin', - '/home/linuxbrew/.linuxbrew/bin', - `${process.env.HOME}/.local/bin`, -] - .filter(Boolean) - .join(':'); +// Re-export exec utilities for convenience +export { execAsync, execEnv } from '../../../lib/exec-utils.js'; -export const execEnv = { - ...process.env, - PATH: extendedPath, -}; +// Re-export error utilities +export { getErrorMessage } from '../../common.js'; -export function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -export function logError(error: unknown, context: string): void { - console.error(`[GitHub] ${context}:`, error); -} +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 08f94135..9b05a87e 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -3,35 +3,12 @@ */ import type { Request, Response } from 'express'; +import type { GitHubIssue, ListIssuesResult } from '@automaker/types'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { checkGitHubRemote } from './check-github-remote.js'; -export interface GitHubLabel { - name: string; - color: string; -} - -export interface GitHubAuthor { - login: string; -} - -export interface GitHubIssue { - number: number; - title: string; - state: string; - author: GitHubAuthor; - createdAt: string; - labels: GitHubLabel[]; - url: string; - body: string; -} - -export interface ListIssuesResult { - success: boolean; - openIssues?: GitHubIssue[]; - closedIssues?: GitHubIssue[]; - error?: string; -} +// Re-export types for convenience +export type { GitHubLabel, GitHubAuthor, GitHubIssue, ListIssuesResult } from '@automaker/types'; export function createListIssuesHandler() { return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/github/routes/list-prs.ts b/apps/server/src/routes/github/routes/list-prs.ts index 87f42a38..4b2c9bfc 100644 --- a/apps/server/src/routes/github/routes/list-prs.ts +++ b/apps/server/src/routes/github/routes/list-prs.ts @@ -3,39 +3,12 @@ */ import type { Request, Response } from 'express'; +import type { GitHubPR, ListPRsResult } from '@automaker/types'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { checkGitHubRemote } from './check-github-remote.js'; -export interface GitHubLabel { - name: string; - color: string; -} - -export interface GitHubAuthor { - login: string; -} - -export interface GitHubPR { - number: number; - title: string; - state: string; - author: GitHubAuthor; - createdAt: string; - labels: GitHubLabel[]; - url: string; - isDraft: boolean; - headRefName: string; - reviewDecision: string | null; - mergeable: string; - body: string; -} - -export interface ListPRsResult { - success: boolean; - openPRs?: GitHubPR[]; - mergedPRs?: GitHubPR[]; - error?: string; -} +// Re-export types for convenience +export type { GitHubLabel, GitHubAuthor, GitHubPR, ListPRsResult } from '@automaker/types'; export function createListPRsHandler() { return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/templates/routes/clone.ts b/apps/server/src/routes/templates/routes/clone.ts index 5874a3ef..b221ab58 100644 --- a/apps/server/src/routes/templates/routes/clone.ts +++ b/apps/server/src/routes/templates/routes/clone.ts @@ -5,8 +5,7 @@ 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 { secureFs, PathNotAllowedError } from '@automaker/platform'; import { logger, getErrorMessage, logError } from '../common.js'; export function createCloneHandler() { diff --git a/apps/server/src/routes/workspace/routes/config.ts b/apps/server/src/routes/workspace/routes/config.ts index 5ea5cbee..d749fcd5 100644 --- a/apps/server/src/routes/workspace/routes/config.ts +++ b/apps/server/src/routes/workspace/routes/config.ts @@ -3,9 +3,8 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getAllowedRootDirectory, getDataDirectory } from '@automaker/platform'; import path from 'path'; -import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createConfigHandler() { diff --git a/apps/server/src/routes/workspace/routes/directories.ts b/apps/server/src/routes/workspace/routes/directories.ts index 09a66e1b..4339f3f8 100644 --- a/apps/server/src/routes/workspace/routes/directories.ts +++ b/apps/server/src/routes/workspace/routes/directories.ts @@ -3,9 +3,8 @@ */ import type { Request, Response } from 'express'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getAllowedRootDirectory } from '@automaker/platform'; import path from 'path'; -import { getAllowedRootDirectory } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createDirectoriesHandler() { diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index bc6e59ba..4e7076d8 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -3,16 +3,16 @@ */ 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 { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js'; import { FeatureLoader } from '../../services/feature-loader.js'; const logger = createLogger('Worktree'); -export const execAsync = promisify(exec); const featureLoader = new FeatureLoader(); +// Re-export exec utilities for convenience +export { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js'; + // ============================================================================ // Constants // ============================================================================ @@ -20,48 +20,6 @@ const featureLoader = new FeatureLoader(); /** Maximum allowed length for git branch names */ export const MAX_BRANCH_NAME_LENGTH = 250; -// ============================================================================ -// Extended PATH configuration for Electron apps -// ============================================================================ - -const pathSeparator = process.platform === 'win32' ? ';' : ':'; -const additionalPaths: string[] = []; - -if (process.platform === 'win32') { - // Windows paths - if (process.env.LOCALAPPDATA) { - additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); - } - if (process.env.PROGRAMFILES) { - additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); - } - if (process.env['ProgramFiles(x86)']) { - additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`); - } -} else { - // Unix/Mac paths - additionalPaths.push( - '/opt/homebrew/bin', // Homebrew on Apple Silicon - '/usr/local/bin', // Homebrew on Intel Mac, common Linux location - '/home/linuxbrew/.linuxbrew/bin', // Linuxbrew - `${process.env.HOME}/.local/bin` // pipx, other user installs - ); -} - -const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)] - .filter(Boolean) - .join(pathSeparator); - -/** - * Environment variables with extended PATH for executing shell commands. - * Electron apps don't inherit the user's shell PATH, so we need to add - * common tool installation locations. - */ -export const execEnv = { - ...process.env, - PATH: extendedPath, -}; - // ============================================================================ // Validation utilities // ============================================================================ @@ -111,14 +69,6 @@ export async function isGitRepo(repoPath: string): Promise { } } -/** - * Check if an error is ENOENT (file/path not found or spawn failed) - * 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'; -} - /** * Check if a path is a mock/test path that doesn't exist */ diff --git a/apps/server/src/routes/worktree/routes/branch-tracking.ts b/apps/server/src/routes/worktree/routes/branch-tracking.ts index 478ebc06..087e5780 100644 --- a/apps/server/src/routes/worktree/routes/branch-tracking.ts +++ b/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -5,15 +5,12 @@ * can switch between branches even after worktrees are removed. */ -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs, getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform'; +import type { TrackedBranch } from '@automaker/types'; import path from 'path'; -import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform'; -export interface TrackedBranch { - name: string; - createdAt: string; - lastActivatedAt?: string; -} +// Re-export type for convenience +export type { TrackedBranch } from '@automaker/types'; interface BranchTrackingData { branches: TrackedBranch[]; diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 943d3bdd..0ea987c4 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -11,7 +11,7 @@ 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 { secureFs } from '@automaker/platform'; import { isGitRepo, getErrorMessage, diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 801dd514..1b0a9dac 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -4,7 +4,7 @@ import type { Request, Response } from 'express'; import path from 'path'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; import { getGitRepositoryDiffs } from '../../common.js'; diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 82ed79bd..d8380649 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -6,7 +6,7 @@ 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 { secureFs } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; import { generateSyntheticDiffForNewFile } from '../../common.js'; diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts index 3d512452..29575746 100644 --- a/apps/server/src/routes/worktree/routes/info.ts +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -6,7 +6,7 @@ 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 { secureFs } from '@automaker/platform'; import { getErrorMessage, logError, normalizePath } from '../common.js'; const execAsync = promisify(exec); diff --git a/apps/server/src/routes/worktree/routes/init-git.ts b/apps/server/src/routes/worktree/routes/init-git.ts index 0a5c1a0b..e5da98f7 100644 --- a/apps/server/src/routes/worktree/routes/init-git.ts +++ b/apps/server/src/routes/worktree/routes/init-git.ts @@ -5,7 +5,7 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import { join } from 'path'; import { getErrorMessage, logError } from '../common.js'; diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 93d93dad..82caea91 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -8,7 +8,7 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import * as secureFs from '../../../lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; diff --git a/apps/server/src/routes/worktree/routes/pr-info.ts b/apps/server/src/routes/worktree/routes/pr-info.ts index cb64ccd9..2acd5a45 100644 --- a/apps/server/src/routes/worktree/routes/pr-info.ts +++ b/apps/server/src/routes/worktree/routes/pr-info.ts @@ -3,6 +3,7 @@ */ import type { Request, Response } from 'express'; +import type { PRComment, PRInfo } from '@automaker/types'; import { getErrorMessage, logError, @@ -12,26 +13,8 @@ import { isGhCliAvailable, } from '../common.js'; -export interface PRComment { - id: number; - author: string; - body: string; - path?: string; - line?: number; - createdAt: string; - isReviewComment: boolean; -} - -export interface PRInfo { - number: number; - title: string; - url: string; - state: string; - author: string; - body: string; - comments: PRComment[]; - reviewComments: PRComment[]; -} +// Re-export types for convenience +export type { PRComment, PRInfo } from '@automaker/types'; export function createPRInfoHandler() { return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts index f9d6bf88..96a54d3c 100644 --- a/apps/server/src/routes/worktree/routes/status.ts +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -6,7 +6,7 @@ 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 { secureFs } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 93df5566..e13ec9bc 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -4,7 +4,6 @@ */ 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 { @@ -15,7 +14,7 @@ import { } from '@automaker/utils'; import { ProviderFactory } from '../providers/provider-factory.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; -import { PathNotAllowedError } from '@automaker/platform'; +import { PathNotAllowedError, secureFs } from '@automaker/platform'; interface Message { id: string; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 66dd5fdb..fa5ebf19 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -20,9 +20,11 @@ import { loadContextFiles, createLogger, sleep, + processStream, + extractBeforeMarker, } from '@automaker/utils'; +import { secureFs, getFeatureDir } from '@automaker/platform'; import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; -import { getFeatureDir } from '@automaker/platform'; import { getPlanningPromptPrefix, parseTasksFromSpec, @@ -31,10 +33,8 @@ import { buildContinuationPrompt, } from '@automaker/prompts'; 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 { processStream, extractBeforeMarker } from '../lib/stream-processor.js'; import { FeatureLoader } from './feature-loader.js'; import { diff --git a/apps/server/src/services/auto-mode/feature-verification.ts b/apps/server/src/services/auto-mode/feature-verification.ts index 6158671c..bca89504 100644 --- a/apps/server/src/services/auto-mode/feature-verification.ts +++ b/apps/server/src/services/auto-mode/feature-verification.ts @@ -13,8 +13,7 @@ import { shortHash, } from '@automaker/git-utils'; import { extractTitleFromDescription } from '@automaker/prompts'; -import { getFeatureDir } from '@automaker/platform'; -import * as secureFs from '../../lib/secure-fs.js'; +import { getFeatureDir, secureFs } from '@automaker/platform'; import path from 'path'; import type { EventEmitter } from '../../lib/events.js'; import type { Feature } from '@automaker/types'; diff --git a/apps/server/src/services/auto-mode/output-writer.ts b/apps/server/src/services/auto-mode/output-writer.ts index d010195c..0347893f 100644 --- a/apps/server/src/services/auto-mode/output-writer.ts +++ b/apps/server/src/services/auto-mode/output-writer.ts @@ -5,7 +5,7 @@ * Used to persist agent output to agent-output.md in the feature directory. */ -import * as secureFs from '../../lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import path from 'path'; import { createLogger } from '@automaker/utils'; diff --git a/apps/server/src/services/auto-mode/project-analyzer.ts b/apps/server/src/services/auto-mode/project-analyzer.ts index 2ed68585..3f3bb03f 100644 --- a/apps/server/src/services/auto-mode/project-analyzer.ts +++ b/apps/server/src/services/auto-mode/project-analyzer.ts @@ -6,13 +6,11 @@ */ import type { ExecuteOptions } from '@automaker/types'; -import { createLogger, classifyError } from '@automaker/utils'; +import { createLogger, classifyError, processStream } from '@automaker/utils'; import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; -import { getAutomakerDir } from '@automaker/platform'; +import { getAutomakerDir, secureFs } from '@automaker/platform'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { validateWorkingDirectory } from '../../lib/sdk-options.js'; -import { processStream } from '../../lib/stream-processor.js'; -import * as secureFs from '../../lib/secure-fs.js'; import path from 'path'; import type { EventEmitter } from '../../lib/events.js'; diff --git a/apps/server/src/services/auto-mode/task-executor.ts b/apps/server/src/services/auto-mode/task-executor.ts index d2155cd2..2deed523 100644 --- a/apps/server/src/services/auto-mode/task-executor.ts +++ b/apps/server/src/services/auto-mode/task-executor.ts @@ -9,8 +9,7 @@ import type { ExecuteOptions, ParsedTask } from '@automaker/types'; import type { EventEmitter } from '../../lib/events.js'; import type { BaseProvider } from '../../providers/base-provider.js'; import { buildTaskPrompt } from '@automaker/prompts'; -import { createLogger } from '@automaker/utils'; -import { processStream } from '../../lib/stream-processor.js'; +import { createLogger, processStream } from '@automaker/utils'; import type { TaskExecutionContext, TaskProgress } from './types.js'; const logger = createLogger('TaskExecutor'); diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 1912fb8e..f695203b 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -8,7 +8,7 @@ */ import { spawn, execSync, type ChildProcess } from 'child_process'; -import * as secureFs from '../lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import path from 'path'; import net from 'net'; diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 2896bd58..98ff24dc 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -7,12 +7,12 @@ import path from 'path'; import type { Feature, PlanSpec, FeatureStatus } from '@automaker/types'; import { createLogger } from '@automaker/utils'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import * as secureFs from '../lib/secure-fs.js'; import { getFeaturesDir, getFeatureDir, getFeatureImagesDir, ensureAutomakerDir, + secureFs, } from '@automaker/platform'; const logger = createLogger('FeatureLoader'); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 288bde18..2d4484bc 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -8,14 +8,13 @@ */ import { createLogger } from '@automaker/utils'; -import * as secureFs from '../lib/secure-fs.js'; - import { getGlobalSettingsPath, getCredentialsPath, getProjectSettingsPath, ensureDataDir, ensureAutomakerDir, + secureFs, } from '@automaker/platform'; import type { GlobalSettings, diff --git a/apps/server/tests/unit/lib/enhancement-prompts.test.ts b/apps/server/tests/unit/lib/enhancement-prompts.test.ts index ab139861..869fd60e 100644 --- a/apps/server/tests/unit/lib/enhancement-prompts.test.ts +++ b/apps/server/tests/unit/lib/enhancement-prompts.test.ts @@ -15,7 +15,7 @@ import { SIMPLIFY_EXAMPLES, ACCEPTANCE_EXAMPLES, type EnhancementMode, -} from '@/lib/enhancement-prompts.js'; +} from '@automaker/prompts'; describe('enhancement-prompts.ts', () => { describe('System Prompt Constants', () => { diff --git a/apps/server/tests/unit/services/dev-server-service.test.ts b/apps/server/tests/unit/services/dev-server-service.test.ts index b6bae863..f4a2e1f7 100644 --- a/apps/server/tests/unit/services/dev-server-service.test.ts +++ b/apps/server/tests/unit/services/dev-server-service.test.ts @@ -10,10 +10,16 @@ vi.mock('child_process', () => ({ execSync: vi.fn(), })); -// Mock secure-fs -vi.mock('@/lib/secure-fs.js', () => ({ - access: vi.fn(), -})); +// Mock secure-fs from @automaker/platform +vi.mock('@automaker/platform', async () => { + const actual = await vi.importActual('@automaker/platform'); + return { + ...actual, + secureFs: { + access: vi.fn(), + }, + }; +}); // Mock net vi.mock('net', () => ({ @@ -24,7 +30,7 @@ vi.mock('net', () => ({ })); import { spawn, execSync } from 'child_process'; -import * as secureFs from '@/lib/secure-fs.js'; +import { secureFs } from '@automaker/platform'; import net from 'net'; describe('dev-server-service.ts', () => { diff --git a/docs/auto-mode-refactoring-plan.md b/docs/auto-mode-refactoring-plan.md new file mode 100644 index 00000000..a3e75e58 --- /dev/null +++ b/docs/auto-mode-refactoring-plan.md @@ -0,0 +1,671 @@ +# Auto Mode Service Refactoring Plan + +## Overview + +This document proposes a refactoring of `apps/server/src/services/auto-mode-service.ts` (2,497 lines) into smaller, focused modules while leveraging shared packages per `docs/llm-shared-packages.md`. + +## Current Problems + +1. **Monolithic Service** - 2,497 lines with 16+ public methods mixing concerns +2. **Giant Method** - `runAgent()` is 658 lines handling planning, approval, task execution, file I/O +3. **Code Duplication** - Stream processing repeated 4x, types duplicated in 3 files +4. **Missing Shared Package Usage** - Local types that exist in `@automaker/types`, prompts not in `@automaker/prompts` +5. **No Structured Logging** - Uses `console.log` instead of `createLogger` +6. **Untyped Events** - Event strings scattered, no type safety + +## Proposed Architecture + +``` +apps/server/src/services/ +├── auto-mode/ +│ ├── index.ts # Re-exports AutoModeService (facade) +│ ├── auto-mode-service.ts # Orchestrator (~300 lines) +│ ├── feature-executor.ts # Feature execution logic +│ ├── plan-approval-service.ts # Plan approval flow +│ ├── task-executor.ts # Multi-task execution +│ ├── worktree-manager.ts # Git worktree operations +│ ├── output-writer.ts # Incremental file writing +│ └── types.ts # Internal types (RunningFeature, etc.) +├── feature-loader.ts # Existing - extend with status updates +└── ... + +libs/types/src/ +├── planning.ts # NEW: ParsedTask, PlanSpec, AutoModeEventType +├── feature.ts # Update: use PlanSpec from planning.ts +└── index.ts # Export planning types + +libs/prompts/src/ +├── planning.ts # NEW: PLANNING_PROMPTS, task parsing, buildTaskPrompt +├── enhancement.ts # Existing +└── index.ts # Export planning functions + +apps/server/src/lib/ +├── stream-processor.ts # NEW: Reusable stream processing utility +└── ... +``` + +## Phase 1: Shared Package Updates (No Breaking Changes) + +### 1.1 Add Planning Types to `@automaker/types` + +Create `libs/types/src/planning.ts`: + +```typescript +// Task and plan status types +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; +export type PlanSpecStatus = 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; + +export 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" + status: TaskStatus; +} + +export interface PlanSpec { + status: PlanSpecStatus; + content?: string; + version: number; + generatedAt?: string; + approvedAt?: string; + reviewedByUser: boolean; + tasksCompleted?: number; + tasksTotal?: number; + currentTaskId?: string; + tasks?: ParsedTask[]; +} + +// Auto-mode event types for type safety +export type AutoModeEventType = + | 'auto_mode_started' + | 'auto_mode_stopped' + | 'auto_mode_idle' + | 'auto_mode_feature_start' + | 'auto_mode_feature_complete' + | 'auto_mode_progress' + | 'auto_mode_tool' + | 'auto_mode_error' + | 'auto_mode_task_started' + | 'auto_mode_task_complete' + | 'auto_mode_phase_complete' + | 'planning_started' + | 'plan_approval_required' + | 'plan_approved' + | 'plan_rejected' + | 'plan_auto_approved' + | 'plan_revision_requested'; +``` + +Update `libs/types/src/feature.ts` to import `PlanSpec`: + +```typescript +import type { PlanSpec } from './planning.js'; + +export interface Feature { + // ... existing fields ... + planSpec?: PlanSpec; // Now references shared type +} +``` + +### 1.2 Add Planning Prompts to `@automaker/prompts` + +Create `libs/prompts/src/planning.ts`: + +````typescript +import type { PlanningMode, ParsedTask } from '@automaker/types'; + +// Planning mode prompts (moved from auto-mode-service.ts lines 57-211) +export const PLANNING_PROMPTS = { + lite: `## Planning Phase (Lite Mode)...`, + lite_with_approval: `## Planning Phase (Lite Mode)...`, + spec: `## Specification Phase (Spec Mode)...`, + full: `## Full Specification Phase (Full SDD Mode)...`, +}; + +/** + * Get planning prompt for a mode + */ +export function getPlanningPrompt(mode: PlanningMode, requireApproval?: boolean): string { + if (mode === 'skip') return ''; + if (mode === 'lite' && requireApproval) return PLANNING_PROMPTS.lite_with_approval; + return PLANNING_PROMPTS[mode] || ''; +} + +/** + * Parse tasks from generated spec content + * Looks for ```tasks code block and extracts task lines + */ +export function parseTasksFromSpec(specContent: string): ParsedTask[] { + // ... moved from auto-mode-service.ts lines 218-265 +} + +/** + * Parse a single task line + * Format: - [ ] T###: Description | File: path/to/file + */ +export function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { + // ... moved from auto-mode-service.ts lines 271-295 +} + +/** + * Build a focused prompt for executing a single task + */ +export function buildTaskPrompt( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number, + planContent: string, + userFeedback?: string +): string { + // ... moved from auto-mode-service.ts lines 2389-2458 +} +```` + +## Phase 2: Extract Utility Classes + +### 2.1 Create Stream Processor + +Create `apps/server/src/lib/stream-processor.ts`: + +```typescript +import type { ProviderMessage } from '../providers/types.js'; + +export interface StreamHandlers { + onText?: (text: string) => void; + onToolUse?: (name: string, input: unknown) => void; + onError?: (error: string) => void; + onComplete?: (result: string) => void; +} + +/** + * Process provider message stream with unified handling + * Eliminates the 4x duplicated stream processing pattern + */ +export async function* processStream( + stream: AsyncGenerator, + handlers: StreamHandlers +): AsyncGenerator<{ text: string; isComplete: boolean }> { + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + handlers.onText?.(block.text || ''); + yield { text: block.text || '', isComplete: false }; + } else if (block.type === 'tool_use') { + handlers.onToolUse?.(block.name, block.input); + } + } + } else if (msg.type === 'error') { + handlers.onError?.(msg.error || 'Unknown error'); + throw new Error(msg.error || 'Unknown error'); + } else if (msg.type === 'result' && msg.subtype === 'success') { + handlers.onComplete?.(msg.result || ''); + yield { text: msg.result || '', isComplete: true }; + } + } +} +``` + +### 2.2 Create Output Writer + +Create `apps/server/src/services/auto-mode/output-writer.ts`: + +```typescript +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; + +/** + * Handles incremental, debounced file writing for agent output + */ +export class OutputWriter { + private content = ''; + private writeTimeout: ReturnType | null = null; + private readonly debounceMs: number; + + constructor( + private readonly outputPath: string, + debounceMs = 500 + ) { + this.debounceMs = debounceMs; + } + + append(text: string): void { + this.content += text; + this.scheduleWrite(); + } + + getContent(): string { + return this.content; + } + + private scheduleWrite(): void { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + } + this.writeTimeout = setTimeout(() => this.flush(), this.debounceMs); + } + + async flush(): Promise { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + this.writeTimeout = null; + } + try { + await secureFs.mkdir(path.dirname(this.outputPath), { recursive: true }); + await secureFs.writeFile(this.outputPath, this.content); + } catch (error) { + console.error(`[OutputWriter] Failed to write: ${error}`); + } + } +} +``` + +## Phase 3: Extract Service Classes + +### 3.1 Plan Approval Service + +Create `apps/server/src/services/auto-mode/plan-approval-service.ts`: + +```typescript +import type { EventEmitter } from '../../lib/events.js'; +import type { PlanSpec, AutoModeEventType } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +interface PendingApproval { + resolve: (result: ApprovalResult) => void; + reject: (error: Error) => void; + featureId: string; + projectPath: string; +} + +interface ApprovalResult { + approved: boolean; + editedPlan?: string; + feedback?: string; +} + +const logger = createLogger('PlanApprovalService'); + +export class PlanApprovalService { + private pendingApprovals = new Map(); + + constructor(private events: EventEmitter) {} + + waitForApproval(featureId: string, projectPath: string): Promise { + logger.debug(`Registering pending approval for feature ${featureId}`); + return new Promise((resolve, reject) => { + this.pendingApprovals.set(featureId, { resolve, reject, featureId, projectPath }); + }); + } + + async resolve( + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const pending = this.pendingApprovals.get(featureId); + if (!pending) { + return { success: false, error: `No pending approval for ${featureId}` }; + } + + pending.resolve({ approved, editedPlan, feedback }); + this.pendingApprovals.delete(featureId); + return { success: true }; + } + + cancel(featureId: string): void { + const pending = this.pendingApprovals.get(featureId); + if (pending) { + pending.reject(new Error('Plan approval cancelled')); + this.pendingApprovals.delete(featureId); + } + } + + hasPending(featureId: string): boolean { + return this.pendingApprovals.has(featureId); + } +} +``` + +### 3.2 Task Executor + +Create `apps/server/src/services/auto-mode/task-executor.ts`: + +```typescript +import type { ExecuteOptions, ParsedTask } from '@automaker/types'; +import type { EventEmitter } from '../../lib/events.js'; +import type { BaseProvider } from '../../providers/base-provider.js'; +import { buildTaskPrompt } from '@automaker/prompts'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('TaskExecutor'); + +interface TaskExecutionContext { + workDir: string; + featureId: string; + projectPath: string; + provider: BaseProvider; + model: string; + maxTurns: number; + allowedTools?: string[]; + abortController: AbortController; + planContent: string; + userFeedback?: string; +} + +interface TaskProgress { + taskId: string; + taskIndex: number; + tasksTotal: number; + status: 'started' | 'completed' | 'failed'; + output?: string; + phaseComplete?: number; +} + +export class TaskExecutor { + constructor(private events: EventEmitter) {} + + async *executeAll( + tasks: ParsedTask[], + context: TaskExecutionContext + ): AsyncGenerator { + for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + const task = tasks[taskIndex]; + + if (context.abortController.signal.aborted) { + throw new Error('Feature execution aborted'); + } + + logger.info(`Starting task ${task.id}: ${task.description}`); + yield { + taskId: task.id, + taskIndex, + tasksTotal: tasks.length, + status: 'started', + }; + + const taskPrompt = buildTaskPrompt( + task, + tasks, + taskIndex, + context.planContent, + context.userFeedback + ); + + const taskStream = context.provider.executeQuery({ + prompt: taskPrompt, + model: context.model, + maxTurns: Math.min(context.maxTurns, 50), + cwd: context.workDir, + allowedTools: context.allowedTools, + abortController: context.abortController, + }); + + let taskOutput = ''; + for await (const msg of taskStream) { + // Process stream messages... + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + taskOutput += block.text || ''; + this.events.emit('auto-mode:event', { + type: 'auto_mode_progress', + featureId: context.featureId, + content: block.text, + }); + } + } + } + } + + logger.info(`Task ${task.id} completed`); + yield { + taskId: task.id, + taskIndex, + tasksTotal: tasks.length, + status: 'completed', + output: taskOutput, + phaseComplete: this.checkPhaseComplete(task, tasks, taskIndex), + }; + } + } + + private checkPhaseComplete( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number + ): number | undefined { + if (!task.phase) return undefined; + + const nextTask = allTasks[taskIndex + 1]; + if (!nextTask || nextTask.phase !== task.phase) { + const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); + return phaseMatch ? parseInt(phaseMatch[1], 10) : undefined; + } + return undefined; + } +} +``` + +### 3.3 Worktree Manager + +Create `apps/server/src/services/auto-mode/worktree-manager.ts`: + +```typescript +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { createLogger } from '@automaker/utils'; + +const execAsync = promisify(exec); +const logger = createLogger('WorktreeManager'); + +export class WorktreeManager { + /** + * Find existing worktree path for a branch + */ + async findWorktreeForBranch(projectPath: string, branchName: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + + for (const line of lines) { + 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) { + if (currentBranch === branchName) { + return path.isAbsolute(currentPath) + ? path.resolve(currentPath) + : path.resolve(projectPath, currentPath); + } + currentPath = null; + currentBranch = null; + } + } + + // Check last entry + if (currentPath && currentBranch === branchName) { + return path.isAbsolute(currentPath) + ? path.resolve(currentPath) + : path.resolve(projectPath, currentPath); + } + + return null; + } catch (error) { + logger.warn(`Failed to find worktree for branch ${branchName}: ${error}`); + return null; + } + } + + /** + * Resolve working directory for feature execution + */ + async resolveWorkDir( + projectPath: string, + branchName: string | undefined, + useWorktrees: boolean + ): Promise<{ workDir: string; worktreePath: string | null }> { + let worktreePath: string | null = null; + + if (useWorktrees && branchName) { + worktreePath = await this.findWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + } else { + logger.warn(`Worktree for branch "${branchName}" not found, using project path`); + } + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + return { workDir, worktreePath }; + } +} +``` + +## Phase 4: Refactor AutoModeService + +### 4.1 Simplified AutoModeService + +The refactored `auto-mode-service.ts` (~300-400 lines) becomes an orchestrator: + +```typescript +import type { Feature, ExecuteOptions, PlanSpec, ParsedTask } from '@automaker/types'; +import { createLogger, classifyError, buildPromptWithImages, loadContextFiles } from '@automaker/utils'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { getPlanningPrompt, parseTasksFromSpec } from '@automaker/prompts'; +import { getFeatureDir } from '@automaker/platform'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import { validateWorkingDirectory } from '../lib/sdk-options.js'; +import type { EventEmitter } from '../lib/events.js'; + +import { PlanApprovalService } from './auto-mode/plan-approval-service.js'; +import { TaskExecutor } from './auto-mode/task-executor.js'; +import { WorktreeManager } from './auto-mode/worktree-manager.js'; +import { OutputWriter } from './auto-mode/output-writer.js'; +import { FeatureLoader } from './feature-loader.js'; + +const logger = createLogger('AutoModeService'); + +export class AutoModeService { + private events: EventEmitter; + private runningFeatures = new Map(); + private featureLoader = new FeatureLoader(); + private planApproval: PlanApprovalService; + private taskExecutor: TaskExecutor; + private worktreeManager: WorktreeManager; + + constructor(events: EventEmitter) { + this.events = events; + this.planApproval = new PlanApprovalService(events); + this.taskExecutor = new TaskExecutor(events); + this.worktreeManager = new WorktreeManager(); + } + + // Public methods remain the same API, but delegate to sub-services + async executeFeature(...): Promise { + // Validation + validateWorkingDirectory(projectPath); + + // Resolve work directory via WorktreeManager + const { workDir, worktreePath } = await this.worktreeManager.resolveWorkDir( + projectPath, feature.branchName, useWorktrees + ); + + // Build prompt (simplified) + const prompt = getPlanningPrompt(feature.planningMode, feature.requirePlanApproval) + + this.buildFeaturePrompt(feature); + + // Execute agent (delegated to runAgent which uses TaskExecutor) + await this.runAgent(workDir, featureId, prompt, ...); + } + + // Plan approval delegated to PlanApprovalService + waitForPlanApproval = this.planApproval.waitForApproval.bind(this.planApproval); + resolvePlanApproval = this.planApproval.resolve.bind(this.planApproval); + cancelPlanApproval = this.planApproval.cancel.bind(this.planApproval); + hasPendingApproval = this.planApproval.hasPending.bind(this.planApproval); +} +``` + +## File Changes Summary + +### New Files to Create + +| File | Purpose | Est. Lines | +| ------------------------------------------------------------- | -------------------------- | ---------- | +| `libs/types/src/planning.ts` | Planning types | ~50 | +| `libs/prompts/src/planning.ts` | Planning prompts & parsing | ~200 | +| `apps/server/src/lib/stream-processor.ts` | Stream utility | ~50 | +| `apps/server/src/services/auto-mode/index.ts` | Re-exports | ~10 | +| `apps/server/src/services/auto-mode/plan-approval-service.ts` | Approval logic | ~100 | +| `apps/server/src/services/auto-mode/task-executor.ts` | Task execution | ~150 | +| `apps/server/src/services/auto-mode/worktree-manager.ts` | Git worktrees | ~80 | +| `apps/server/src/services/auto-mode/output-writer.ts` | File I/O | ~60 | +| `apps/server/src/services/auto-mode/types.ts` | Internal types | ~40 | + +### Files to Modify + +| File | Changes | +| ------------------------------------------------------- | ---------------------------------- | +| `libs/types/src/index.ts` | Export planning types | +| `libs/types/src/feature.ts` | Import PlanSpec | +| `libs/prompts/src/index.ts` | Export planning functions | +| `apps/server/src/services/auto-mode-service.ts` | Refactor to orchestrator | +| `apps/ui/src/store/app-store.ts` | Import types from @automaker/types | +| `apps/ui/src/components/.../planning-mode-selector.tsx` | Import PlanningMode | +| `apps/server/tests/.../auto-mode-task-parsing.test.ts` | Import from @automaker/prompts | + +### Files to Delete (after refactoring) + +None - old file becomes the slim orchestrator. + +## Verification Checklist + +After refactoring, verify: + +- [ ] `npm run build:packages` succeeds +- [ ] `npm run lint` passes +- [ ] `npm run test:packages` passes +- [ ] `npm run test:server` passes +- [ ] `npm run test` (E2E) passes +- [ ] Feature execution works in UI +- [ ] Plan approval flow works (spec/full modes) +- [ ] Task progress events appear correctly +- [ ] Resume feature works +- [ ] Follow-up feature works + +## Migration Strategy + +1. **Phase 1**: Add shared package updates (non-breaking) +2. **Phase 2**: Extract utilities (stream-processor, output-writer) +3. **Phase 3**: Extract services one at a time, keeping old code as fallback +4. **Phase 4**: Wire up orchestrator, remove old code +5. **Phase 5**: Update tests to use new imports + +Each phase should be a separate PR for easier review. + +## Benefits + +| Metric | Before | After | +| ---------------- | -------------------- | -------------------- | +| Main file lines | 2,497 | ~400 | +| Largest method | 658 lines | ~100 lines | +| Code duplication | 4x stream processing | 1 utility | +| Type safety | None for events | Full | +| Testability | Hard (monolith) | Easy (focused units) | +| Logging | console.log | createLogger | + +## Open Questions + +1. Should `startAutoLoop`/`stopAutoLoop` remain in AutoModeService or become a separate `AutoLoopService`? +2. Should we add a `FeatureRepository` class to consolidate all feature file operations? +3. Is the recovery mechanism in `resolvePlanApproval` still needed with the refactored architecture? diff --git a/libs/types/src/claude.ts b/libs/types/src/claude.ts new file mode 100644 index 00000000..e865e586 --- /dev/null +++ b/libs/types/src/claude.ts @@ -0,0 +1,35 @@ +/** + * Claude Usage types for CLI-based usage tracking + */ + +export interface ClaudeUsage { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; // ISO date string + sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)" + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; // ISO date string + weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" + + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; + + lastUpdated: string; // ISO date string + userTimezone: string; +} + +export interface ClaudeStatus { + indicator: { + color: 'green' | 'yellow' | 'orange' | 'red' | 'gray'; + }; + description: string; +} diff --git a/libs/types/src/github.ts b/libs/types/src/github.ts new file mode 100644 index 00000000..8b87f53f --- /dev/null +++ b/libs/types/src/github.ts @@ -0,0 +1,59 @@ +/** + * GitHub-related types shared across server and UI + */ + +export interface GitHubLabel { + name: string; + color: string; +} + +export interface GitHubAuthor { + login: string; +} + +export interface GitHubIssue { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + body: string; +} + +export interface GitHubPR { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + isDraft: boolean; + headRefName: string; + reviewDecision: string | null; + mergeable: string; + body: string; +} + +export interface GitHubRemoteStatus { + hasGitHubRemote: boolean; + remoteUrl: string | null; + owner: string | null; + repo: string | null; +} + +export interface ListPRsResult { + success: boolean; + openPRs?: GitHubPR[]; + mergedPRs?: GitHubPR[]; + error?: string; +} + +export interface ListIssuesResult { + success: boolean; + openIssues?: GitHubIssue[]; + closedIssues?: GitHubIssue[]; + error?: string; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 592adf7e..31c48fb1 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -93,3 +93,28 @@ export type { TaskProgressPayload, PlanApprovalPayload, } from './planning.js'; + +// GitHub types +export type { + GitHubLabel, + GitHubAuthor, + GitHubIssue, + GitHubPR, + GitHubRemoteStatus, + ListPRsResult, + ListIssuesResult, +} from './github.js'; + +// Worktree types +export type { + WorktreePRInfo, + WorktreeMetadata, + WorktreeListItem, + PRComment, + PRInfo, + DevServerInfo, + TrackedBranch, +} from './worktree.js'; + +// Claude usage types +export type { ClaudeUsage, ClaudeStatus } from './claude.js'; diff --git a/libs/types/src/worktree.ts b/libs/types/src/worktree.ts new file mode 100644 index 00000000..aae0b247 --- /dev/null +++ b/libs/types/src/worktree.ts @@ -0,0 +1,61 @@ +/** + * Worktree-related types shared across server and UI + */ + +export interface WorktreePRInfo { + number: number; + url: string; + title: string; + state: string; + createdAt: string; +} + +export interface WorktreeMetadata { + branch: string; + createdAt: string; + pr?: WorktreePRInfo; +} + +export interface WorktreeListItem { + path: string; + branch: string; + isMain: boolean; + isCurrent: boolean; + hasWorktree: boolean; + hasChanges?: boolean; + changedFilesCount?: number; + pr?: WorktreePRInfo; +} + +export interface PRComment { + id: number; + author: string; + body: string; + path?: string; + line?: number; + createdAt: string; + isReviewComment: boolean; +} + +export interface PRInfo { + number: number; + title: string; + url: string; + state: string; + author: string; + body: string; + comments: PRComment[]; + reviewComments: PRComment[]; +} + +export interface DevServerInfo { + worktreePath: string; + port: number; + url: string; +} + +export interface TrackedBranch { + name: string; + createdAt: string; + lastActivatedAt?: string; +}