From 944e2f5ffe84d6ea3bb980171b944317438eeb3f Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 31 Dec 2025 03:22:25 +0100 Subject: [PATCH 01/24] chore: remove unused files from codebase --- .claude_settings.json | 24 - CHANGELOG_RATE_LIMIT_HANDLING.md | 134 -- docs/migration-plan-nextjs-to-vite.md | 1829 ----------------- ...025-12-29-api-security-hardening-design.md | 94 - 4 files changed, 2081 deletions(-) delete mode 100644 .claude_settings.json delete mode 100644 CHANGELOG_RATE_LIMIT_HANDLING.md delete mode 100644 docs/migration-plan-nextjs-to-vite.md delete mode 100644 docs/plans/2025-12-29-api-security-hardening-design.md diff --git a/.claude_settings.json b/.claude_settings.json deleted file mode 100644 index 969f1214..00000000 --- a/.claude_settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "defaultMode": "acceptEdits", - "allow": [ - "Read(./**)", - "Write(./**)", - "Edit(./**)", - "Glob(./**)", - "Grep(./**)", - "Bash(*)", - "mcp__puppeteer__puppeteer_navigate", - "mcp__puppeteer__puppeteer_screenshot", - "mcp__puppeteer__puppeteer_click", - "mcp__puppeteer__puppeteer_fill", - "mcp__puppeteer__puppeteer_select", - "mcp__puppeteer__puppeteer_hover", - "mcp__puppeteer__puppeteer_evaluate" - ] - } -} diff --git a/CHANGELOG_RATE_LIMIT_HANDLING.md b/CHANGELOG_RATE_LIMIT_HANDLING.md deleted file mode 100644 index 35e73b0e..00000000 --- a/CHANGELOG_RATE_LIMIT_HANDLING.md +++ /dev/null @@ -1,134 +0,0 @@ -# Improved Error Handling for Rate Limiting - -## Problem - -When running multiple features concurrently in auto-mode, the Claude API rate limits were being exceeded, resulting in cryptic error messages: - -``` -Error: Claude Code process exited with code 1 -``` - -This error provided no actionable information to users about: - -- What went wrong (rate limit exceeded) -- How long to wait before retrying -- How to prevent it in the future - -## Root Cause - -The Claude Agent SDK was terminating with exit code 1 when hitting rate limits (HTTP 429), but the error details were not being properly surfaced to the user. The error handling code only showed the generic exit code message instead of the actual API error. - -## Solution - -Implemented comprehensive rate limit error handling across the stack: - -### 1. Enhanced Error Classification (libs/utils) - -Added new error type and detection functions: - -- **New error type**: `'rate_limit'` added to `ErrorType` union -- **`isRateLimitError()`**: Detects 429 and rate_limit errors -- **`extractRetryAfter()`**: Extracts retry duration from error messages -- **Updated `classifyError()`**: Includes rate limit classification with retry-after metadata -- **Updated `getUserFriendlyErrorMessage()`**: Provides clear, actionable messages for rate limit errors - -### 2. Improved Claude Provider Error Handling (apps/server) - -Enhanced `ClaudeProvider.executeQuery()` to: - -- Classify all errors using the enhanced error utilities -- Detect rate limit errors specifically -- Provide user-friendly error messages with: - - Clear explanation of the problem (rate limit exceeded) - - Retry-after duration when available - - Actionable tip: reduce `maxConcurrency` in auto-mode -- Preserve original error details for debugging - -### 3. Comprehensive Test Coverage - -Added 8 new tests covering: - -- Rate limit error detection (429, rate_limit keywords) -- Retry-after extraction from various message formats -- Error classification with retry metadata -- User-friendly message generation -- Edge cases (null/undefined, non-rate-limit errors) - -**Total test suite**: 162 tests passing ✅ - -## User-Facing Changes - -### Before - -``` -[AutoMode] Feature touch-gesture-support failed: Error: Claude Code process exited with code 1 -``` - -### After - -``` -[AutoMode] Feature touch-gesture-support failed: Rate limit exceeded (429). Please wait 60 seconds before retrying. - -Tip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits. -``` - -## Benefits - -1. **Clear communication**: Users understand exactly what went wrong -2. **Actionable guidance**: Users know how long to wait and how to prevent future errors -3. **Better debugging**: Original error details preserved for technical investigation -4. **Type safety**: New `isRateLimit` and `retryAfter` fields properly typed in `ErrorInfo` -5. **Comprehensive testing**: All edge cases covered with automated tests - -## Technical Details - -### Files Modified - -- `libs/types/src/error.ts` - Added `'rate_limit'` type and `retryAfter` field -- `libs/utils/src/error-handler.ts` - Added rate limit detection and extraction logic -- `libs/utils/src/index.ts` - Exported new utility functions -- `libs/utils/tests/error-handler.test.ts` - Added 8 new test cases -- `apps/server/src/providers/claude-provider.ts` - Enhanced error handling with user-friendly messages - -### API Changes - -**ErrorInfo interface** (backwards compatible): - -```typescript -interface ErrorInfo { - type: ErrorType; // Now includes 'rate_limit' - message: string; - isAbort: boolean; - isAuth: boolean; - isCancellation: boolean; - isRateLimit: boolean; // NEW - retryAfter?: number; // NEW (seconds to wait) - originalError: unknown; -} -``` - -**New utility functions**: - -```typescript -isRateLimitError(error: unknown): boolean -extractRetryAfter(error: unknown): number | undefined -``` - -## Future Improvements - -This PR lays the groundwork for future enhancements: - -1. **Automatic retry with exponential backoff**: Use `retryAfter` to implement smart retry logic -2. **Global rate limiter**: Track requests to prevent hitting limits proactively -3. **Concurrency auto-adjustment**: Dynamically reduce concurrency when rate limits are detected -4. **User notifications**: Show toast/banner when rate limits are approaching - -## Testing - -Run tests with: - -```bash -npm run test -w @automaker/utils -``` - -All 162 tests pass, including 8 new rate limit tests. diff --git a/docs/migration-plan-nextjs-to-vite.md b/docs/migration-plan-nextjs-to-vite.md deleted file mode 100644 index 7211e1ec..00000000 --- a/docs/migration-plan-nextjs-to-vite.md +++ /dev/null @@ -1,1829 +0,0 @@ -# Migration Plan: Next.js to Vite + Electron + TanStack - -> **Document Version**: 1.1 -> **Date**: December 2025 -> **Status**: Phase 2 Complete - Core Migration Done -> **Branch**: refactor/frontend - ---- - -## Table of Contents - -1. [Executive Summary](#executive-summary) -2. [Current Architecture Assessment](#current-architecture-assessment) -3. [Proposed New Architecture](#proposed-new-architecture) -4. [Folder Structure](#folder-structure) -5. [Shared Packages (libs/)](#shared-packages-libs) -6. [Type-Safe Electron Implementation](#type-safe-electron-implementation) -7. [Components Refactoring](#components-refactoring) -8. [Web + Electron Dual Support](#web--electron-dual-support) -9. [Migration Phases](#migration-phases) -10. [Expected Benefits](#expected-benefits) -11. [Risk Mitigation](#risk-mitigation) - ---- - -## Executive Summary - -### Why Migrate? - -Our current Next.js implementation uses **less than 5%** of the framework's capabilities. We're essentially running a static SPA with unnecessary overhead: - -| Next.js Feature | Our Usage | -| ---------------------- | ---------------------------- | -| Server-Side Rendering | ❌ Not used | -| Static Site Generation | ❌ Not used | -| API Routes | ⚠️ Only 2 test endpoints | -| Image Optimization | ❌ Not used | -| Dynamic Routing | ❌ Not used | -| App Router | ⚠️ File structure only | -| Metadata API | ⚠️ Title/description only | -| Static Export | ✅ Used (`output: "export"`) | - -### Migration Benefits - -| Metric | Current (Next.js) | Expected (Vite) | -| ---------------------- | ----------------- | ------------------ | -| Dev server startup | ~8-15s | ~1-3s | -| HMR speed | ~500ms-2s | ~50-100ms | -| Production build | ~45-90s | ~15-30s | -| Bundle overhead | Next.js runtime | None | -| Type safety (Electron) | 0% | 100% | -| Debug capabilities | Limited | Full debug console | - -### Target Stack - -- **Bundler**: Vite -- **Framework**: React 19 -- **Routing**: TanStack Router (file-based) -- **Data Fetching**: TanStack Query -- **State**: Zustand (unchanged) -- **Styling**: Tailwind CSS 4 (unchanged) -- **Desktop**: Electron (TypeScript rewrite) - ---- - -## Current Architecture Assessment - -### Data Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ELECTRON APP │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ HTTP/WS ┌─────────────────┐ │ -│ │ React SPA │ ←──────────────────→ │ Backend Server │ │ -│ │ (Next.js) │ localhost:3008 │ (Express) │ │ -│ │ │ │ │ │ -│ │ • Zustand Store │ │ • AI Providers │ │ -│ │ • 16 Views │ │ • Git/FS Ops │ │ -│ │ • 180+ Comps │ │ • Terminal │ │ -│ └────────┬────────┘ └─────────────────┘ │ -│ │ │ -│ │ IPC (minimal - dialogs/shell only) │ -│ ↓ │ -│ ┌─────────────────┐ │ -│ │ Electron Main │ • File dialogs │ -│ │ (main.js) │ • Shell operations │ -│ │ **NO TYPES** │ • App paths │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Current Electron Layer Issues - -| Issue | Impact | Solution | -| ----------------------- | --------------------------- | ------------------------ | -| Pure JavaScript | No compile-time safety | Migrate to TypeScript | -| Untyped IPC handlers | Runtime errors | IPC Schema with generics | -| String literal channels | Typos cause silent failures | Const enums | -| No debug tooling | Hard to diagnose issues | Debug console feature | -| Monolithic main.js | Hard to maintain | Modular IPC organization | - -### Current Component Structure Issues - -| View File | Lines | Issue | -| ------------------ | ----- | ---------------------------------- | -| spec-view.tsx | 1,230 | Exceeds 500-line threshold | -| analysis-view.tsx | 1,134 | Exceeds 500-line threshold | -| agent-view.tsx | 916 | Exceeds 500-line threshold | -| welcome-view.tsx | 815 | Exceeds 500-line threshold | -| context-view.tsx | 735 | Exceeds 500-line threshold | -| terminal-view.tsx | 697 | Exceeds 500-line threshold | -| interview-view.tsx | 637 | Exceeds 500-line threshold | -| board-view.tsx | 685 | ✅ Already has subfolder structure | - ---- - -## Proposed New Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ MIGRATED ARCHITECTURE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @automaker/app (Vite + React) │ │ -│ ├──────────────────────────────────────────────────────────────────┤ │ -│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ │ -│ │ │ TanStack │ │ TanStack │ │ Zustand │ │ │ -│ │ │ Router │ │ Query │ │ Store │ │ │ -│ │ │ (file-based) │ │ (data fetch) │ │ (UI state) │ │ │ -│ │ └────────────────┘ └────────────────┘ └────────────────────┘ │ │ -│ │ │ │ -│ │ src/ │ │ -│ │ ├── routes/ # TanStack file-based routes │ │ -│ │ ├── components/ # Refactored per folder-pattern.md │ │ -│ │ ├── hooks/ # React hooks │ │ -│ │ ├── store/ # Zustand stores │ │ -│ │ ├── lib/ # Utilities │ │ -│ │ └── config/ # Configuration │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ HTTP/WS (unchanged) │ Type-Safe IPC │ -│ ↓ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Electron Layer (TypeScript) │ │ -│ ├──────────────────────────────────────────────────────────────────┤ │ -│ │ electron/ │ │ -│ │ ├── main.ts # Main process entry │ │ -│ │ ├── preload.ts # Context bridge exposure │ │ -│ │ ├── debug-console/ # Debug console feature │ │ -│ │ └── ipc/ # Modular IPC handlers │ │ -│ │ ├── ipc-schema.ts # Type definitions │ │ -│ │ ├── dialog/ # File dialogs │ │ -│ │ ├── shell/ # Shell operations │ │ -│ │ └── server/ # Server management │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @automaker/server (unchanged) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────┐ -│ SHARED PACKAGES (libs/) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ @automaker/types # API contracts, model definitions │ -│ @automaker/utils # Shared utilities (error handling, etc.) │ -│ @automaker/platform # OS-specific utilities, path handling │ -│ @automaker/model-resolver # Model string resolution │ -│ @automaker/ipc-types # IPC channel type definitions │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Folder Structure - -### apps/ui/ (After Migration) - -``` -apps/ui/ -├── electron/ # Electron main process (TypeScript) -│ ├── main.ts # Main entry point -│ ├── preload.ts # Context bridge -│ ├── tsconfig.json # Electron-specific TS config -│ ├── debug-console/ -│ │ ├── debug-console.html -│ │ ├── debug-console-preload.ts -│ │ └── debug-mode.ts -│ ├── ipc/ -│ │ ├── ipc-schema.ts # Central type definitions -│ │ ├── context-exposer.ts # Exposes all contexts to renderer -│ │ ├── listeners-register.ts # Registers all main process handlers -│ │ ├── dialog/ -│ │ │ ├── dialog-channels.ts # Channel constants -│ │ │ ├── dialog-context.ts # Preload exposure -│ │ │ └── dialog-listeners.ts # Main process handlers -│ │ ├── shell/ -│ │ │ ├── shell-channels.ts -│ │ │ ├── shell-context.ts -│ │ │ └── shell-listeners.ts -│ │ ├── app-info/ -│ │ │ ├── app-info-channels.ts -│ │ │ ├── app-info-context.ts -│ │ │ └── app-info-listeners.ts -│ │ └── server/ -│ │ ├── server-channels.ts -│ │ ├── server-context.ts -│ │ └── server-listeners.ts -│ └── helpers/ -│ ├── server-manager.ts # Backend server spawn/health -│ ├── static-server.ts # Production static file server -│ ├── window-helpers.ts # Window utilities -│ └── window-registry.ts # Multi-window tracking -│ -├── src/ -│ ├── routes/ # TanStack Router (file-based) -│ │ ├── __root.tsx # Root layout -│ │ ├── index.tsx # Welcome/home (default route) -│ │ ├── board.tsx # Board view -│ │ ├── agent.tsx # Agent view -│ │ ├── settings.tsx # Settings view -│ │ ├── setup.tsx # Setup view -│ │ ├── terminal.tsx # Terminal view -│ │ ├── spec.tsx # Spec view -│ │ ├── context.tsx # Context view -│ │ ├── profiles.tsx # Profiles view -│ │ ├── interview.tsx # Interview view -│ │ ├── wiki.tsx # Wiki view -│ │ ├── analysis.tsx # Analysis view -│ │ └── agent-tools.tsx # Agent tools view -│ │ -│ ├── components/ # Refactored per folder-pattern.md -│ │ ├── ui/ # Global UI primitives (unchanged) -│ │ ├── layout/ -│ │ │ ├── sidebar.tsx -│ │ │ ├── base-layout.tsx -│ │ │ └── index.ts -│ │ ├── dialogs/ # Global dialogs -│ │ │ ├── index.ts -│ │ │ ├── new-project-modal.tsx -│ │ │ ├── workspace-picker-modal.tsx -│ │ │ └── file-browser-dialog.tsx -│ │ └── views/ # Complex view components -│ │ ├── board-view/ # ✅ Already structured -│ │ ├── settings-view/ # Needs dialogs reorganization -│ │ ├── setup-view/ # ✅ Already structured -│ │ ├── profiles-view/ # ✅ Already structured -│ │ ├── agent-view/ # NEW: needs subfolder -│ │ │ ├── components/ -│ │ │ │ ├── index.ts -│ │ │ │ ├── message-list.tsx -│ │ │ │ ├── message-input.tsx -│ │ │ │ └── session-sidebar.tsx -│ │ │ ├── dialogs/ -│ │ │ │ ├── index.ts -│ │ │ │ ├── delete-session-dialog.tsx -│ │ │ │ └── delete-all-archived-dialog.tsx -│ │ │ └── hooks/ -│ │ │ ├── index.ts -│ │ │ └── use-agent-state.ts -│ │ ├── spec-view/ # NEW: needs subfolder (1230 lines!) -│ │ ├── analysis-view/ # NEW: needs subfolder (1134 lines!) -│ │ ├── context-view/ # NEW: needs subfolder -│ │ ├── welcome-view/ # NEW: needs subfolder -│ │ ├── interview-view/ # NEW: needs subfolder -│ │ └── terminal-view/ # Expand existing -│ │ -│ ├── hooks/ # Global hooks -│ ├── store/ # Zustand stores -│ ├── lib/ # Utilities -│ ├── config/ # Configuration -│ ├── contexts/ # React contexts -│ ├── types/ # Type definitions -│ ├── App.tsx # Root component -│ ├── renderer.ts # Vite entry point -│ └── routeTree.gen.ts # Generated by TanStack Router -│ -├── index.html # Vite HTML entry -├── vite.config.mts # Vite configuration -├── tsconfig.json # TypeScript config (renderer) -├── package.json -└── tailwind.config.ts -``` - ---- - -## Shared Packages (libs/) - -### Package Overview - -``` -libs/ -├── @automaker/types # API contracts, model definitions -├── @automaker/utils # General utilities (error handling, logger) -├── @automaker/platform # OS-specific utilities, path handling -├── @automaker/model-resolver # Model string resolution -└── @automaker/ipc-types # IPC channel type definitions -``` - -### @automaker/types - -Shared type definitions for API contracts between frontend and backend. - -``` -libs/types/ -├── src/ -│ ├── api.ts # API response types -│ ├── models.ts # ModelDefinition, ProviderStatus -│ ├── features.ts # Feature, FeatureStatus, Priority -│ ├── sessions.ts # Session, Message types -│ ├── agent.ts # Agent types -│ ├── git.ts # Git operation types -│ ├── worktree.ts # Worktree types -│ └── index.ts # Barrel export -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/types/src/models.ts -export interface ModelDefinition { - id: string; - name: string; - provider: ProviderType; - contextWindow: number; - maxOutputTokens: number; - capabilities: ModelCapabilities; -} - -export interface ModelCapabilities { - vision: boolean; - toolUse: boolean; - streaming: boolean; - computerUse: boolean; -} - -export type ProviderType = 'claude' | 'openai' | 'gemini' | 'ollama'; -``` - -### @automaker/utils - -General utilities shared between frontend and backend. - -``` -libs/utils/ -├── src/ -│ ├── error-handler.ts # Error classification & user-friendly messages -│ ├── logger.ts # Logging utilities -│ ├── conversation-utils.ts # Message formatting & history -│ ├── image-utils.ts # Image processing utilities -│ ├── string-utils.ts # String manipulation helpers -│ └── index.ts -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/utils/src/error-handler.ts -export type ErrorType = - | 'authentication' - | 'rate_limit' - | 'network' - | 'validation' - | 'not_found' - | 'server' - | 'unknown'; - -export interface ErrorInfo { - type: ErrorType; - message: string; - userMessage: string; - retryable: boolean; - statusCode?: number; -} - -export function classifyError(error: unknown): ErrorInfo; -export function getUserFriendlyErrorMessage(error: unknown): string; -export function isAbortError(error: unknown): boolean; -export function isAuthenticationError(error: unknown): boolean; -export function isRateLimitError(error: unknown): boolean; -``` - -### @automaker/platform - -**OS-specific utilities, path handling, and cross-platform helpers.** - -``` -libs/platform/ -├── src/ -│ ├── paths/ -│ │ ├── index.ts # Path utilities barrel export -│ │ ├── path-resolver.ts # Cross-platform path resolution -│ │ ├── path-constants.ts # Common path constants -│ │ └── path-validator.ts # Path validation utilities -│ ├── os/ -│ │ ├── index.ts # OS utilities barrel export -│ │ ├── platform-info.ts # Platform detection & info -│ │ ├── shell-commands.ts # OS-specific shell commands -│ │ └── env-utils.ts # Environment variable utilities -│ ├── fs/ -│ │ ├── index.ts # FS utilities barrel export -│ │ ├── safe-fs.ts # Symlink-safe file operations -│ │ ├── temp-files.ts # Temporary file handling -│ │ └── permissions.ts # File permission utilities -│ └── index.ts # Main barrel export -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/platform/src/paths/path-resolver.ts -import path from 'path'; - -/** - * Platform-aware path separator - */ -export const SEP = path.sep; - -/** - * Normalizes a path to use the correct separator for the current OS - */ -export function normalizePath(inputPath: string): string { - return inputPath.replace(/[/\\]/g, SEP); -} - -/** - * Converts a path to POSIX format (forward slashes) - * Useful for consistent storage/comparison - */ -export function toPosixPath(inputPath: string): string { - return inputPath.replace(/\\/g, '/'); -} - -/** - * Converts a path to Windows format (backslashes) - */ -export function toWindowsPath(inputPath: string): string { - return inputPath.replace(/\//g, '\\'); -} - -/** - * Resolves a path relative to a base, handling platform differences - */ -export function resolvePath(basePath: string, ...segments: string[]): string { - return path.resolve(basePath, ...segments); -} - -/** - * Gets the relative path from one location to another - */ -export function getRelativePath(from: string, to: string): string { - return path.relative(from, to); -} - -/** - * Joins path segments with proper platform separator - */ -export function joinPath(...segments: string[]): string { - return path.join(...segments); -} - -/** - * Extracts directory name from a path - */ -export function getDirname(filePath: string): string { - return path.dirname(filePath); -} - -/** - * Extracts filename from a path - */ -export function getBasename(filePath: string, ext?: string): string { - return path.basename(filePath, ext); -} - -/** - * Extracts file extension from a path - */ -export function getExtension(filePath: string): string { - return path.extname(filePath); -} - -/** - * Checks if a path is absolute - */ -export function isAbsolutePath(inputPath: string): boolean { - return path.isAbsolute(inputPath); -} - -/** - * Ensures a path is absolute, resolving relative to cwd if needed - */ -export function ensureAbsolutePath(inputPath: string, basePath?: string): string { - if (isAbsolutePath(inputPath)) { - return inputPath; - } - return resolvePath(basePath || process.cwd(), inputPath); -} -``` - -```typescript -// libs/platform/src/paths/path-constants.ts -import path from 'path'; -import os from 'os'; - -/** - * Common system paths - */ -export const SYSTEM_PATHS = { - /** User's home directory */ - home: os.homedir(), - - /** System temporary directory */ - temp: os.tmpdir(), - - /** Current working directory */ - cwd: process.cwd(), -} as const; - -/** - * Gets the appropriate app data directory for the current platform - */ -export function getAppDataPath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - appName - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', appName); - default: // Linux and others - return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName); - } -} - -/** - * Gets the appropriate cache directory for the current platform - */ -export function getCachePath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), - appName, - 'Cache' - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Caches', appName); - default: - return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), appName); - } -} - -/** - * Gets the appropriate logs directory for the current platform - */ -export function getLogsPath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), - appName, - 'Logs' - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Logs', appName); - default: - return path.join( - process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), - appName, - 'logs' - ); - } -} - -/** - * Gets the user's Documents directory - */ -export function getDocumentsPath(): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, 'Documents') - : path.join(os.homedir(), 'Documents'); - case 'darwin': - return path.join(os.homedir(), 'Documents'); - default: - return process.env.XDG_DOCUMENTS_DIR || path.join(os.homedir(), 'Documents'); - } -} - -/** - * Gets the user's Desktop directory - */ -export function getDesktopPath(): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, 'Desktop') - : path.join(os.homedir(), 'Desktop'); - case 'darwin': - return path.join(os.homedir(), 'Desktop'); - default: - return process.env.XDG_DESKTOP_DIR || path.join(os.homedir(), 'Desktop'); - } -} -``` - -```typescript -// libs/platform/src/paths/path-validator.ts -import path from 'path'; -import { isAbsolutePath } from './path-resolver'; - -/** - * Characters that are invalid in file/directory names on Windows - */ -const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g; - -/** - * Reserved names on Windows (case-insensitive) - */ -const WINDOWS_RESERVED_NAMES = [ - 'CON', - 'PRN', - 'AUX', - 'NUL', - 'COM1', - 'COM2', - 'COM3', - 'COM4', - 'COM5', - 'COM6', - 'COM7', - 'COM8', - 'COM9', - 'LPT1', - 'LPT2', - 'LPT3', - 'LPT4', - 'LPT5', - 'LPT6', - 'LPT7', - 'LPT8', - 'LPT9', -]; - -export interface PathValidationResult { - valid: boolean; - errors: string[]; - sanitized?: string; -} - -/** - * Validates a filename for the current platform - */ -export function validateFilename(filename: string): PathValidationResult { - const errors: string[] = []; - - if (!filename || filename.trim().length === 0) { - return { valid: false, errors: ['Filename cannot be empty'] }; - } - - // Check for path separators (filename shouldn't be a path) - if (filename.includes('/') || filename.includes('\\')) { - errors.push('Filename cannot contain path separators'); - } - - // Platform-specific checks - if (process.platform === 'win32') { - if (WINDOWS_INVALID_CHARS.test(filename)) { - errors.push('Filename contains invalid characters for Windows'); - } - - const nameWithoutExt = filename.split('.')[0].toUpperCase(); - if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - errors.push(`"${nameWithoutExt}" is a reserved name on Windows`); - } - - if (filename.endsWith(' ') || filename.endsWith('.')) { - errors.push('Filename cannot end with a space or period on Windows'); - } - } - - // Check length - if (filename.length > 255) { - errors.push('Filename exceeds maximum length of 255 characters'); - } - - return { - valid: errors.length === 0, - errors, - sanitized: errors.length > 0 ? sanitizeFilename(filename) : filename, - }; -} - -/** - * Sanitizes a filename for cross-platform compatibility - */ -export function sanitizeFilename(filename: string): string { - let sanitized = filename.replace(WINDOWS_INVALID_CHARS, '_').replace(/[/\\]/g, '_').trim(); - - // Handle Windows reserved names - const nameWithoutExt = sanitized.split('.')[0].toUpperCase(); - if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - sanitized = '_' + sanitized; - } - - // Remove trailing spaces and periods (Windows) - sanitized = sanitized.replace(/[\s.]+$/, ''); - - // Ensure not empty - if (!sanitized) { - sanitized = 'unnamed'; - } - - // Truncate if too long - if (sanitized.length > 255) { - const ext = path.extname(sanitized); - const name = path.basename(sanitized, ext); - sanitized = name.slice(0, 255 - ext.length) + ext; - } - - return sanitized; -} - -/** - * Validates a full path for the current platform - */ -export function validatePath(inputPath: string): PathValidationResult { - const errors: string[] = []; - - if (!inputPath || inputPath.trim().length === 0) { - return { valid: false, errors: ['Path cannot be empty'] }; - } - - // Check total path length - const maxPathLength = process.platform === 'win32' ? 260 : 4096; - if (inputPath.length > maxPathLength) { - errors.push(`Path exceeds maximum length of ${maxPathLength} characters`); - } - - // Validate each segment - const segments = inputPath.split(/[/\\]/).filter(Boolean); - for (const segment of segments) { - // Skip drive letters on Windows - if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(segment)) { - continue; - } - - const segmentValidation = validateFilename(segment); - if (!segmentValidation.valid) { - errors.push(...segmentValidation.errors.map((e) => `Segment "${segment}": ${e}`)); - } - } - - return { - valid: errors.length === 0, - errors, - }; -} - -/** - * Checks if a path is within a base directory (prevents directory traversal) - */ -export function isPathWithin(childPath: string, parentPath: string): boolean { - const resolvedChild = path.resolve(childPath); - const resolvedParent = path.resolve(parentPath); - - return resolvedChild.startsWith(resolvedParent + path.sep) || resolvedChild === resolvedParent; -} -``` - -```typescript -// libs/platform/src/os/platform-info.ts -import os from 'os'; - -export type Platform = 'windows' | 'macos' | 'linux' | 'unknown'; -export type Architecture = 'x64' | 'arm64' | 'ia32' | 'unknown'; - -export interface PlatformInfo { - platform: Platform; - arch: Architecture; - release: string; - hostname: string; - username: string; - cpus: number; - totalMemory: number; - freeMemory: number; - isWsl: boolean; - isDocker: boolean; -} - -/** - * Gets the normalized platform name - */ -export function getPlatform(): Platform { - switch (process.platform) { - case 'win32': - return 'windows'; - case 'darwin': - return 'macos'; - case 'linux': - return 'linux'; - default: - return 'unknown'; - } -} - -/** - * Gets the normalized architecture - */ -export function getArchitecture(): Architecture { - switch (process.arch) { - case 'x64': - return 'x64'; - case 'arm64': - return 'arm64'; - case 'ia32': - return 'ia32'; - default: - return 'unknown'; - } -} - -/** - * Checks if running on Windows - */ -export function isWindows(): boolean { - return process.platform === 'win32'; -} - -/** - * Checks if running on macOS - */ -export function isMacOS(): boolean { - return process.platform === 'darwin'; -} - -/** - * Checks if running on Linux - */ -export function isLinux(): boolean { - return process.platform === 'linux'; -} - -/** - * Checks if running in WSL (Windows Subsystem for Linux) - */ -export function isWsl(): boolean { - if (process.platform !== 'linux') return false; - - try { - const release = os.release().toLowerCase(); - return release.includes('microsoft') || release.includes('wsl'); - } catch { - return false; - } -} - -/** - * Checks if running in Docker container - */ -export function isDocker(): boolean { - try { - const fs = require('fs'); - return ( - fs.existsSync('/.dockerenv') || - (fs.existsSync('/proc/1/cgroup') && - fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')) - ); - } catch { - return false; - } -} - -/** - * Gets comprehensive platform information - */ -export function getPlatformInfo(): PlatformInfo { - return { - platform: getPlatform(), - arch: getArchitecture(), - release: os.release(), - hostname: os.hostname(), - username: os.userInfo().username, - cpus: os.cpus().length, - totalMemory: os.totalmem(), - freeMemory: os.freemem(), - isWsl: isWsl(), - isDocker: isDocker(), - }; -} - -/** - * Gets the appropriate line ending for the current platform - */ -export function getLineEnding(): string { - return isWindows() ? '\r\n' : '\n'; -} - -/** - * Normalizes line endings to the current platform - */ -export function normalizeLineEndings(text: string): string { - const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - return isWindows() ? normalized.replace(/\n/g, '\r\n') : normalized; -} -``` - -```typescript -// libs/platform/src/os/shell-commands.ts -import { isWindows, isMacOS } from './platform-info'; - -export interface ShellCommand { - command: string; - args: string[]; -} - -/** - * Gets the appropriate command to open a file/URL with default application - */ -export function getOpenCommand(target: string): ShellCommand { - if (isWindows()) { - return { command: 'cmd', args: ['/c', 'start', '', target] }; - } else if (isMacOS()) { - return { command: 'open', args: [target] }; - } else { - return { command: 'xdg-open', args: [target] }; - } -} - -/** - * Gets the appropriate command to reveal a file in file manager - */ -export function getRevealCommand(filePath: string): ShellCommand { - if (isWindows()) { - return { command: 'explorer', args: ['/select,', filePath] }; - } else if (isMacOS()) { - return { command: 'open', args: ['-R', filePath] }; - } else { - // Linux: try multiple file managers - return { command: 'xdg-open', args: [require('path').dirname(filePath)] }; - } -} - -/** - * Gets the default shell for the current platform - */ -export function getDefaultShell(): string { - if (isWindows()) { - return process.env.COMSPEC || 'cmd.exe'; - } - return process.env.SHELL || '/bin/sh'; -} - -/** - * Gets shell-specific arguments for running a command - */ -export function getShellArgs(command: string): ShellCommand { - if (isWindows()) { - return { command: 'cmd.exe', args: ['/c', command] }; - } - return { command: '/bin/sh', args: ['-c', command] }; -} - -/** - * Escapes a string for safe use in shell commands - */ -export function escapeShellArg(arg: string): string { - if (isWindows()) { - // Windows cmd.exe escaping - return `"${arg.replace(/"/g, '""')}"`; - } - // POSIX shell escaping - return `'${arg.replace(/'/g, "'\\''")}'`; -} -``` - -```typescript -// libs/platform/src/os/env-utils.ts -import { isWindows } from './platform-info'; - -/** - * Gets an environment variable with a fallback - */ -export function getEnv(key: string, fallback?: string): string | undefined { - return process.env[key] ?? fallback; -} - -/** - * Gets an environment variable, throwing if not set - */ -export function requireEnv(key: string): string { - const value = process.env[key]; - if (value === undefined) { - throw new Error(`Required environment variable "${key}" is not set`); - } - return value; -} - -/** - * Parses a boolean environment variable - */ -export function getBoolEnv(key: string, fallback = false): boolean { - const value = process.env[key]; - if (value === undefined) return fallback; - return ['true', '1', 'yes', 'on'].includes(value.toLowerCase()); -} - -/** - * Parses a numeric environment variable - */ -export function getNumericEnv(key: string, fallback: number): number { - const value = process.env[key]; - if (value === undefined) return fallback; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? fallback : parsed; -} - -/** - * Expands environment variables in a string - * Supports both $VAR and ${VAR} syntax, plus %VAR% on Windows - */ -export function expandEnvVars(input: string): string { - let result = input; - - // Expand ${VAR} syntax - result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ''); - - // Expand $VAR syntax (not followed by another word char) - result = result.replace( - /\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])/g, - (_, name) => process.env[name] || '' - ); - - // Expand %VAR% syntax (Windows) - if (isWindows()) { - result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ''); - } - - return result; -} - -/** - * Gets the PATH environment variable as an array - */ -export function getPathEntries(): string[] { - const pathVar = process.env.PATH || process.env.Path || ''; - const separator = isWindows() ? ';' : ':'; - return pathVar.split(separator).filter(Boolean); -} - -/** - * Checks if a command is available in PATH - */ -export function isCommandInPath(command: string): boolean { - const pathEntries = getPathEntries(); - const extensions = isWindows() ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : ['']; - const path = require('path'); - const fs = require('fs'); - - for (const dir of pathEntries) { - for (const ext of extensions) { - const fullPath = path.join(dir, command + ext); - try { - fs.accessSync(fullPath, fs.constants.X_OK); - return true; - } catch { - // Continue searching - } - } - } - - return false; -} -``` - -```typescript -// libs/platform/src/fs/safe-fs.ts -import fs from 'fs'; -import path from 'path'; - -/** - * Safely reads a file, following symlinks but preventing escape from base directory - */ -export async function safeReadFile( - filePath: string, - basePath: string, - encoding: BufferEncoding = 'utf8' -): Promise { - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(basePath); - - // Resolve symlinks - const realPath = await fs.promises.realpath(resolvedPath); - const realBase = await fs.promises.realpath(resolvedBase); - - // Ensure resolved path is within base - if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) { - throw new Error(`Path "${filePath}" resolves outside of allowed directory`); - } - - return fs.promises.readFile(realPath, encoding); -} - -/** - * Safely writes a file, preventing writes outside base directory - */ -export async function safeWriteFile( - filePath: string, - basePath: string, - content: string -): Promise { - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(basePath); - - // Ensure path is within base before any symlink resolution - if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { - throw new Error(`Path "${filePath}" is outside of allowed directory`); - } - - // Check parent directory exists and is within base - const parentDir = path.dirname(resolvedPath); - - try { - const realParent = await fs.promises.realpath(parentDir); - const realBase = await fs.promises.realpath(resolvedBase); - - if (!realParent.startsWith(realBase + path.sep) && realParent !== realBase) { - throw new Error(`Parent directory resolves outside of allowed directory`); - } - } catch (error) { - // Parent doesn't exist, that's OK - we'll create it - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - - await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true }); - await fs.promises.writeFile(resolvedPath, content, 'utf8'); -} - -/** - * Checks if a path exists and is accessible - */ -export async function pathExists(filePath: string): Promise { - try { - await fs.promises.access(filePath); - return true; - } catch { - return false; - } -} - -/** - * Gets file stats, returning null if file doesn't exist - */ -export async function safeStat(filePath: string): Promise { - try { - return await fs.promises.stat(filePath); - } catch { - return null; - } -} - -/** - * Recursively removes a directory - */ -export async function removeDirectory(dirPath: string): Promise { - await fs.promises.rm(dirPath, { recursive: true, force: true }); -} - -/** - * Copies a file or directory - */ -export async function copy(src: string, dest: string): Promise { - const stats = await fs.promises.stat(src); - - if (stats.isDirectory()) { - await fs.promises.mkdir(dest, { recursive: true }); - const entries = await fs.promises.readdir(src, { withFileTypes: true }); - - for (const entry of entries) { - await copy(path.join(src, entry.name), path.join(dest, entry.name)); - } - } else { - await fs.promises.copyFile(src, dest); - } -} -``` - -```typescript -// libs/platform/src/index.ts -// Main barrel export - -// Path utilities -export * from './paths/path-resolver'; -export * from './paths/path-constants'; -export * from './paths/path-validator'; - -// OS utilities -export * from './os/platform-info'; -export * from './os/shell-commands'; -export * from './os/env-utils'; - -// File system utilities -export * from './fs/safe-fs'; -``` - -### @automaker/model-resolver - -Model string resolution shared between frontend and backend. - -``` -libs/model-resolver/ -├── src/ -│ ├── model-map.ts # CLAUDE_MODEL_MAP, DEFAULT_MODELS -│ ├── resolver.ts # resolveModelString, getEffectiveModel -│ └── index.ts -├── package.json -└── tsconfig.json -``` - -### @automaker/ipc-types - -IPC channel type definitions for type-safe Electron communication. - -``` -libs/ipc-types/ -├── src/ -│ ├── schema.ts # IPCSchema interface -│ ├── channels.ts # Channel constant enums -│ ├── helpers.ts # Type helper functions -│ └── index.ts -├── package.json -└── tsconfig.json -``` - ---- - -## Type-Safe Electron Implementation - -### IPC Schema Definition - -```typescript -// electron/ipc/ipc-schema.ts -import type { OpenDialogOptions, SaveDialogOptions } from 'electron'; - -// Dialog result types -export interface DialogResult { - canceled: boolean; - filePaths?: string[]; - filePath?: string; - data?: T; -} - -// App path names (from Electron) -export type AppPathName = - | 'home' - | 'appData' - | 'userData' - | 'sessionData' - | 'temp' - | 'exe' - | 'module' - | 'desktop' - | 'documents' - | 'downloads' - | 'music' - | 'pictures' - | 'videos' - | 'recent' - | 'logs' - | 'crashDumps'; - -// Complete IPC Schema with request/response types -export interface IPCSchema { - // Dialog operations - 'dialog:openDirectory': { - request: Partial; - response: DialogResult; - }; - 'dialog:openFile': { - request: Partial; - response: DialogResult; - }; - 'dialog:saveFile': { - request: Partial; - response: DialogResult; - }; - - // Shell operations - 'shell:openExternal': { - request: { url: string }; - response: { success: boolean; error?: string }; - }; - 'shell:openPath': { - request: { path: string }; - response: { success: boolean; error?: string }; - }; - - // App info - 'app:getPath': { - request: { name: AppPathName }; - response: string; - }; - 'app:getVersion': { - request: void; - response: string; - }; - 'app:isPackaged': { - request: void; - response: boolean; - }; - - // Server management - 'server:getUrl': { - request: void; - response: string; - }; - - // Connection test - ping: { - request: void; - response: 'pong'; - }; - - // Debug console - 'debug:log': { - request: { - level: DebugLogLevel; - category: DebugCategory; - message: string; - args: unknown[]; - }; - response: void; - }; -} - -export type DebugLogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success'; -export type DebugCategory = - | 'general' - | 'ipc' - | 'route' - | 'network' - | 'perf' - | 'state' - | 'lifecycle' - | 'updater'; - -// Type extractors -export type IPCChannel = keyof IPCSchema; -export type IPCRequest = IPCSchema[T]['request']; -export type IPCResponse = IPCSchema[T]['response']; -``` - -### Modular IPC Organization - -```typescript -// electron/ipc/dialog/dialog-channels.ts -export const DIALOG_CHANNELS = { - OPEN_DIRECTORY: 'dialog:openDirectory', - OPEN_FILE: 'dialog:openFile', - SAVE_FILE: 'dialog:saveFile', -} as const; - -// electron/ipc/dialog/dialog-context.ts -import { contextBridge, ipcRenderer } from 'electron'; -import { DIALOG_CHANNELS } from './dialog-channels'; -import type { IPCRequest, IPCResponse } from '../ipc-schema'; - -export function exposeDialogContext(): void { - contextBridge.exposeInMainWorld('dialogAPI', { - openDirectory: (options?: IPCRequest<'dialog:openDirectory'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_DIRECTORY, options), - - openFile: (options?: IPCRequest<'dialog:openFile'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_FILE, options), - - saveFile: (options?: IPCRequest<'dialog:saveFile'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.SAVE_FILE, options), - }); -} - -// electron/ipc/dialog/dialog-listeners.ts -import { ipcMain, dialog, BrowserWindow } from 'electron'; -import { DIALOG_CHANNELS } from './dialog-channels'; -import type { IPCRequest, IPCResponse } from '../ipc-schema'; -import { debugLog } from '../../helpers/debug-mode'; - -export function addDialogEventListeners(mainWindow: BrowserWindow): void { - ipcMain.handle( - DIALOG_CHANNELS.OPEN_DIRECTORY, - async (_, options: IPCRequest<'dialog:openDirectory'> = {}) => { - debugLog.ipc(`OPEN_DIRECTORY called with options: ${JSON.stringify(options)}`); - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory', 'createDirectory'], - ...options, - }); - - debugLog.ipc( - `OPEN_DIRECTORY result: canceled=${result.canceled}, paths=${result.filePaths.length}` - ); - - return { - canceled: result.canceled, - filePaths: result.filePaths, - } satisfies IPCResponse<'dialog:openDirectory'>; - } - ); - - ipcMain.handle( - DIALOG_CHANNELS.OPEN_FILE, - async (_, options: IPCRequest<'dialog:openFile'> = {}) => { - debugLog.ipc(`OPEN_FILE called`); - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openFile'], - ...options, - }); - - return { - canceled: result.canceled, - filePaths: result.filePaths, - } satisfies IPCResponse<'dialog:openFile'>; - } - ); - - ipcMain.handle( - DIALOG_CHANNELS.SAVE_FILE, - async (_, options: IPCRequest<'dialog:saveFile'> = {}) => { - debugLog.ipc(`SAVE_FILE called`); - - const result = await dialog.showSaveDialog(mainWindow, options); - - return { - canceled: result.canceled, - filePath: result.filePath, - } satisfies IPCResponse<'dialog:saveFile'>; - } - ); -} -``` - ---- - -## Components Refactoring - -### Priority Matrix - -| Priority | View | Lines | Action Required | -| -------- | -------------- | ----- | ------------------------------------------------------ | -| 🔴 P0 | spec-view | 1,230 | Create subfolder with components/, dialogs/, hooks/ | -| 🔴 P0 | analysis-view | 1,134 | Create subfolder with components/, dialogs/, hooks/ | -| 🔴 P0 | agent-view | 916 | Create subfolder, extract message list, input, sidebar | -| 🟡 P1 | welcome-view | 815 | Create subfolder, extract sections | -| 🟡 P1 | context-view | 735 | Create subfolder, extract components | -| 🟡 P1 | terminal-view | 697 | Expand existing subfolder | -| 🟡 P1 | interview-view | 637 | Create subfolder | -| 🟢 P2 | settings-view | 178 | Move dialogs from components/ to dialogs/ | -| ✅ Done | board-view | 685 | Already properly structured | -| ✅ Done | setup-view | 144 | Already properly structured | -| ✅ Done | profiles-view | 300 | Already properly structured | - -### Immediate Dialog Reorganization - -```bash -# Settings-view: Move dialogs to proper location -mv settings-view/components/keyboard-map-dialog.tsx → settings-view/dialogs/ -mv settings-view/components/delete-project-dialog.tsx → settings-view/dialogs/ - -# Root components: Organize global dialogs -mv components/dialogs/board-background-modal.tsx → board-view/dialogs/ - -# Agent-related dialogs: Move to agent-view -mv components/delete-session-dialog.tsx → agent-view/dialogs/ -mv components/delete-all-archived-sessions-dialog.tsx → agent-view/dialogs/ -``` - ---- - -## Web + Electron Dual Support - -### Platform Detection - -```typescript -// src/lib/platform.ts -export const isElectron = typeof window !== 'undefined' && 'electronAPI' in window; - -export const platform = { - isElectron, - isWeb: !isElectron, - isMac: isElectron ? window.electronAPI.platform === 'darwin' : false, - isWindows: isElectron ? window.electronAPI.platform === 'win32' : false, - isLinux: isElectron ? window.electronAPI.platform === 'linux' : false, -}; -``` - -### API Abstraction Layer - -```typescript -// src/lib/api/file-picker.ts -import { platform } from '../platform'; - -export interface FilePickerResult { - canceled: boolean; - paths: string[]; -} - -export async function pickDirectory(): Promise { - if (platform.isElectron) { - const result = await window.dialogAPI.openDirectory(); - return { canceled: result.canceled, paths: result.filePaths || [] }; - } - - // Web fallback using File System Access API - try { - const handle = await window.showDirectoryPicker(); - return { canceled: false, paths: [handle.name] }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return { canceled: true, paths: [] }; - } - throw error; - } -} - -export async function pickFile(options?: { - accept?: Record; -}): Promise { - if (platform.isElectron) { - const result = await window.dialogAPI.openFile({ - filters: options?.accept - ? Object.entries(options.accept).map(([name, extensions]) => ({ - name, - extensions, - })) - : undefined, - }); - return { canceled: result.canceled, paths: result.filePaths || [] }; - } - - // Web fallback - try { - const [handle] = await window.showOpenFilePicker({ - types: options?.accept - ? Object.entries(options.accept).map(([description, accept]) => ({ - description, - accept: { 'application/*': accept }, - })) - : undefined, - }); - return { canceled: false, paths: [handle.name] }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return { canceled: true, paths: [] }; - } - throw error; - } -} -``` - ---- - -## Migration Phases - -### Phase 1: Foundation (Week 1-2) - -**Goal**: Set up new build infrastructure without breaking existing functionality. - -- [x] Create `vite.config.mts` with electron plugins -- [x] Create `electron/tsconfig.json` for Electron TypeScript -- [x] Convert `electron/main.js` → `electron/main.ts` -- [x] Convert `electron/preload.js` → `electron/preload.ts` -- [ ] Implement IPC schema and type-safe handlers (deferred - using existing HTTP API) -- [x] Set up TanStack Router configuration -- [ ] Port debug console from starter template (deferred) -- [x] Create `index.html` for Vite entry - -**Deliverables**: - -- [x] Working Vite dev server -- [x] TypeScript Electron main process -- [ ] Debug console functional (deferred) - -### Phase 2: Core Migration (Week 3-4) - -**Goal**: Replace Next.js with Vite while maintaining feature parity. - -- [x] Create `src/renderer.tsx` entry point -- [x] Create `src/App.tsx` root component -- [x] Set up TanStack Router with file-based routes -- [x] Port all views to route files -- [x] Update environment variables (`NEXT_PUBLIC_*` → `VITE_*`) -- [x] Verify Zustand stores work unchanged -- [x] Verify HTTP API client works unchanged -- [x] Test Electron build -- [ ] Test Web build (needs verification) - -**Additional completed tasks**: - -- [x] Remove all "use client" directives (not needed in Vite) -- [x] Replace all `setCurrentView()` calls with TanStack Router `navigate()` -- [x] Rename `apps/app` to `apps/ui` -- [x] Update package.json scripts -- [x] Configure memory history for Electron (no URL bar) -- [x] Fix ES module imports (replace `require()` with `import`) -- [x] Remove PostCSS config (using `@tailwindcss/vite` plugin) - -**Deliverables**: - -- [x] All views accessible via TanStack Router -- [x] Electron build functional -- [ ] Web build functional (needs testing) -- [x] No regression in existing functionality - -### Phase 3: Component Refactoring (Week 5-7) - -**Goal**: Refactor large view files to follow folder-pattern.md. - -- [ ] Refactor `spec-view.tsx` (1,230 lines) -- [ ] Refactor `analysis-view.tsx` (1,134 lines) -- [ ] Refactor `agent-view.tsx` (916 lines) -- [ ] Refactor `welcome-view.tsx` (815 lines) -- [ ] Refactor `context-view.tsx` (735 lines) -- [ ] Refactor `terminal-view.tsx` (697 lines) -- [ ] Refactor `interview-view.tsx` (637 lines) -- [ ] Reorganize `settings-view` dialogs - -**Deliverables**: - -- All views under 500 lines -- Consistent folder structure across all views -- Barrel exports for all component folders - -### Phase 4: Package Extraction (Week 8) - -**Goal**: Create shared packages for better modularity. - -- [ ] Create `libs/types/` package -- [ ] Create `libs/utils/` package -- [ ] Create `libs/platform/` package -- [ ] Create `libs/model-resolver/` package -- [ ] Create `libs/ipc-types/` package -- [ ] Update imports across apps - -**Deliverables**: - -- 5 new shared packages -- No code duplication between apps -- Clean dependency graph - -### Phase 5: Polish & Testing (Week 9-10) - -**Goal**: Ensure production readiness. - -- [ ] Write E2E tests with Playwright -- [ ] Performance benchmarking -- [ ] Bundle size optimization -- [ ] Documentation updates -- [ ] CI/CD pipeline updates -- [ ] Remove Next.js dependencies - -**Deliverables**: - -- Comprehensive test coverage -- Performance metrics documentation -- Updated CI/CD configuration -- Clean package.json (no Next.js) - ---- - -## Expected Benefits - -### Developer Experience - -| Aspect | Before | After | -| ---------------------- | ------------- | ------------------ | -| Dev server startup | 8-15 seconds | 1-3 seconds | -| Hot Module Replacement | 500ms-2s | 50-100ms | -| TypeScript in Electron | Not supported | Full support | -| Debug tooling | Limited | Full debug console | -| Build times | 45-90 seconds | 15-30 seconds | - -### Code Quality - -| Aspect | Before | After | -| ---------------------- | ------------ | --------------------- | -| Electron type safety | 0% | 100% | -| Component organization | Inconsistent | Standardized | -| Code sharing | None | 5 shared packages | -| Path handling | Ad-hoc | Centralized utilities | - -### Bundle Size - -| Aspect | Before | After | -| ------------------ | ------- | ------- | -| Next.js runtime | ~200KB | 0KB | -| Framework overhead | High | Minimal | -| Tree shaking | Limited | Full | - ---- - -## Risk Mitigation - -### Rollback Strategy - -1. **Branch-based development**: All work on feature branch -2. **Parallel running**: Keep Next.js functional until migration complete -3. **Feature flags**: Toggle between old/new implementations -4. **Comprehensive testing**: E2E tests before/after comparison - -### Known Challenges - -| Challenge | Mitigation | -| --------------------- | ------------------------------------------------ | -| Route migration | TanStack Router has similar file-based routing | -| Environment variables | Simple search/replace (`NEXT_PUBLIC_` → `VITE_`) | -| Build configuration | Reference electron-starter-template | -| SSR considerations | N/A - we don't use SSR | - -### Testing Strategy - -1. **Unit tests**: Vitest for component/utility testing -2. **Integration tests**: Test IPC communication -3. **E2E tests**: Playwright for full application testing -4. **Manual testing**: QA checklist for each view - ---- - -## Appendix: Vite Configuration Reference - -```typescript -// vite.config.mts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import electron from 'vite-plugin-electron'; -import renderer from 'vite-plugin-electron-renderer'; -import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; -import tailwindcss from '@tailwindcss/vite'; -import path from 'path'; - -export default defineConfig({ - plugins: [ - react({ - babel: { - plugins: [['babel-plugin-react-compiler', {}]], - }, - }), - TanStackRouterVite({ - routesDirectory: './src/routes', - generatedRouteTree: './src/routeTree.gen.ts', - autoCodeSplitting: true, - }), - tailwindcss(), - electron([ - { - entry: 'electron/main.ts', - vite: { - build: { - outDir: 'dist-electron', - rollupOptions: { - external: ['electron'], - }, - }, - }, - }, - { - entry: 'electron/preload.ts', - onstart: ({ reload }) => reload(), - vite: { - build: { - outDir: 'dist-electron', - rollupOptions: { - external: ['electron'], - }, - }, - }, - }, - ]), - renderer(), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - '@electron': path.resolve(__dirname, 'electron'), - }, - }, - build: { - outDir: 'dist', - }, -}); -``` - ---- - -## Document History - -| Version | Date | Author | Changes | -| ------- | -------- | ------ | ------------------------------------------------------------------------------------- | -| 1.0 | Dec 2025 | Team | Initial migration plan | -| 1.1 | Dec 2025 | Team | Phase 1 & 2 complete. Updated checkboxes, added completed tasks, noted deferred items | - ---- - -**Next Steps**: - -1. ~~Review and approve this plan~~ ✅ -2. ~~Wait for `feature/worktrees` branch merge~~ ✅ -3. ~~Create migration branch~~ ✅ (refactor/frontend) -4. ~~Complete Phase 1 implementation~~ ✅ -5. ~~Complete Phase 2 implementation~~ ✅ -6. **Current**: Verify web build works, then begin Phase 3 (Component Refactoring) -7. Consider implementing deferred items: Debug console, IPC schema diff --git a/docs/plans/2025-12-29-api-security-hardening-design.md b/docs/plans/2025-12-29-api-security-hardening-design.md deleted file mode 100644 index 54c0ca67..00000000 --- a/docs/plans/2025-12-29-api-security-hardening-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# API Security Hardening Design - -**Date:** 2025-12-29 -**Branch:** protect-api-with-api-key -**Status:** Approved - -## Overview - -Security improvements for the API authentication system before merging the PR. These changes harden the existing implementation for production deployment scenarios (local, Docker, internet-exposed). - -## Fixes to Implement - -### 1. Use Short-Lived wsToken for WebSocket Authentication - -**Problem:** The client currently passes `sessionToken` in WebSocket URL query parameters. Query params get logged and can leak credentials. - -**Solution:** Update the client to: - -1. Fetch a wsToken from `/api/auth/token` before each WebSocket connection -2. Use `wsToken` query param instead of `sessionToken` -3. Never put session tokens in URLs - -**Files to modify:** - -- `apps/ui/src/lib/http-api-client.ts` - Update `connectWebSocket()` to fetch wsToken first - ---- - -### 2. Add Environment Variable to Hide API Key from Logs - -**Problem:** The API key is printed to console on startup, which gets captured by logging systems in production. - -**Solution:** Add `AUTOMAKER_HIDE_API_KEY=true` env var to suppress the banner. - -**Files to modify:** - -- `apps/server/src/lib/auth.ts` - Wrap console.log banner in env var check - ---- - -### 3. Add Rate Limiting to Login Endpoint - -**Problem:** No brute force protection on `/api/auth/login`. Attackers could attempt many API keys. - -**Solution:** Add basic in-memory rate limiting: - -- ~5 attempts per minute per IP -- In-memory Map tracking (resets on server restart) -- Return 429 Too Many Requests when exceeded - -**Files to modify:** - -- `apps/server/src/routes/auth/index.ts` - Add rate limiting logic to login handler - ---- - -### 4. Use Timing-Safe Comparison for API Key - -**Problem:** Using `===` for API key comparison is vulnerable to timing attacks. - -**Solution:** Use `crypto.timingSafeEqual()` for constant-time comparison. - -**Files to modify:** - -- `apps/server/src/lib/auth.ts` - Update `validateApiKey()` function - ---- - -### 5. Make WebSocket Tokens Single-Use - -**Problem:** wsTokens can be reused within the 5-minute window. If intercepted, attackers have time to use them. - -**Solution:** Delete the token after first successful validation. - -**Files to modify:** - -- `apps/server/src/lib/auth.ts` - Update `validateWsConnectionToken()` to delete after use - ---- - -## Implementation Order - -1. Fix #4 (timing-safe comparison) - Simple, isolated change -2. Fix #5 (single-use wsToken) - Simple, isolated change -3. Fix #2 (hide API key env var) - Simple, isolated change -4. Fix #3 (rate limiting) - Moderate complexity -5. Fix #1 (client wsToken usage) - Requires coordination with server - -## Testing Notes - -- Test login with rate limiting (verify 429 after 5 attempts) -- Test WebSocket connection with new wsToken flow -- Test wsToken is invalidated after first use -- Verify `AUTOMAKER_HIDE_API_KEY=true` suppresses banner From 04aac7ec079781027d287ac27c710a83ea8fe689 Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 31 Dec 2025 03:34:41 +0100 Subject: [PATCH 02/24] chore: update package-lock.json to add peer dependencies and update package versions --- package-lock.json | 97 ++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8190a03..075e5fc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -455,6 +455,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1038,6 +1039,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1080,6 +1082,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1900,7 +1903,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1922,7 +1924,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1939,7 +1940,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1954,7 +1954,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2722,7 +2721,6 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -2847,7 +2845,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2864,7 +2861,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2881,7 +2877,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2990,7 +2985,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3013,7 +3007,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3036,7 +3029,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3122,7 +3114,6 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -3145,7 +3136,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3165,7 +3155,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3565,8 +3554,7 @@ "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { "version": "16.0.10", @@ -3580,7 +3568,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3597,7 +3584,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3614,7 +3600,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3631,7 +3616,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3648,7 +3632,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3665,7 +3648,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3682,7 +3664,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3699,7 +3680,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3790,6 +3770,7 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -5230,7 +5211,6 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -5564,6 +5544,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -5990,6 +5971,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -6132,6 +6114,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6142,6 +6125,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6247,6 +6231,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -6740,7 +6725,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -6838,6 +6824,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6898,6 +6885,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7496,6 +7484,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8027,8 +8016,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cliui": { "version": "8.0.1", @@ -8333,8 +8321,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8431,6 +8418,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8732,6 +8720,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -9058,7 +9047,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9079,7 +9067,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9330,6 +9317,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9644,6 +9632,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11311,7 +11300,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11373,7 +11361,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13801,7 +13788,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13818,7 +13804,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13836,7 +13821,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13953,9 +13937,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -14025,6 +14009,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14034,6 +14019,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14392,7 +14378,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14581,6 +14566,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -14629,7 +14615,6 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14680,7 +14665,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14703,7 +14687,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14726,7 +14709,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14743,7 +14725,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14760,7 +14741,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14777,7 +14757,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14794,7 +14773,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14811,7 +14789,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14828,7 +14805,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14845,7 +14821,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14868,7 +14843,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14891,7 +14865,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14914,7 +14887,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14937,7 +14909,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14960,7 +14931,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15429,7 +15399,6 @@ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", - "peer": true, "dependencies": { "client-only": "0.0.1" }, @@ -15599,7 +15568,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15663,7 +15631,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15761,6 +15728,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15965,6 +15933,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16336,6 +16305,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16425,7 +16395,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -16451,6 +16422,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16493,6 +16465,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -16750,6 +16723,7 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16818,6 +16792,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 07327e48b48109b68423e9fae8765435fcc2870e Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 31 Dec 2025 10:41:04 +0100 Subject: [PATCH 03/24] chore: remove unused pipeline feature documentation --- docs/pipeline-feature.md | 156 --------------------------------------- 1 file changed, 156 deletions(-) delete mode 100644 docs/pipeline-feature.md diff --git a/docs/pipeline-feature.md b/docs/pipeline-feature.md deleted file mode 100644 index 59238776..00000000 --- a/docs/pipeline-feature.md +++ /dev/null @@ -1,156 +0,0 @@ -# Pipeline Feature - -Custom pipeline steps that run automatically after a feature completes "In Progress", creating a sequential workflow for code review, security audits, testing, and more. - -## Overview - -The pipeline feature allows users to define custom workflow steps that execute automatically after the main implementation phase. Each step prompts the agent with specific instructions while maintaining the full conversation context. - -## How It Works - -1. **Feature completes "In Progress"** - When the agent finishes implementing a feature -2. **Pipeline steps execute sequentially** - Each configured step runs in order -3. **Agent receives instructions** - The step's instructions are sent to the agent -4. **Context preserved** - Full chat history is maintained between steps -5. **Final status** - After all steps complete, the feature moves to "Waiting Approval" or "Verified" - -## Configuration - -### Accessing Pipeline Settings - -- Click the **gear icon** on the "In Progress" column header -- Or click the gear icon on any pipeline step column - -### Adding Pipeline Steps - -1. Click **"Add Pipeline Step"** -2. Optionally select a **pre-built template** from the dropdown: - - Code Review - - Security Review - - Testing - - Documentation - - Performance Optimization -3. Customize the **Step Name** -4. Choose a **Color** for the column -5. Write or modify the **Agent Instructions** -6. Click **"Add Step"** - -### Managing Steps - -- **Reorder**: Use the up/down arrows to change step order -- **Edit**: Click the pencil icon to modify a step -- **Delete**: Click the trash icon to remove a step -- **Load from file**: Upload a `.md` or `.txt` file for instructions - -## Storage - -Pipeline configuration is stored per-project at: - -``` -{project}/.automaker/pipeline.json -``` - -## Pre-built Templates - -### Code Review - -Comprehensive code quality review covering: - -- Readability and maintainability -- DRY principle and single responsibility -- Best practices and conventions -- Performance considerations -- Test coverage - -### Security Review - -OWASP-focused security audit including: - -- Input validation and sanitization -- SQL injection and XSS prevention -- Authentication and authorization -- Data protection -- Common vulnerability checks (OWASP Top 10) - -### Testing - -Test coverage verification: - -- Unit test requirements -- Integration testing -- Test quality standards -- Running and validating tests - -### Documentation - -Documentation requirements: - -- Code documentation (JSDoc/docstrings) -- API documentation -- README updates -- Changelog entries - -### Performance Optimization - -Performance review covering: - -- Algorithm optimization -- Memory usage -- Database/API optimization -- Frontend performance (if applicable) - -## UI Changes - -### Kanban Board - -- Pipeline columns appear between "In Progress" and "Waiting Approval" -- Each pipeline column shows features currently in that step -- Gear icon on columns opens pipeline settings - -### Horizontal Scrolling - -- Board supports horizontal scrolling when many columns exist -- Minimum window width reduced to 600px to accommodate various screen sizes - -## Technical Details - -### Files Modified - -**Types:** - -- `libs/types/src/pipeline.ts` - PipelineStep, PipelineConfig types -- `libs/types/src/index.ts` - Export pipeline types - -**Server:** - -- `apps/server/src/services/pipeline-service.ts` - CRUD operations, status transitions -- `apps/server/src/routes/pipeline/` - API endpoints -- `apps/server/src/services/auto-mode-service.ts` - Pipeline execution integration - -**UI:** - -- `apps/ui/src/store/app-store.ts` - Pipeline state management -- `apps/ui/src/lib/http-api-client.ts` - Pipeline API client -- `apps/ui/src/components/views/board-view/constants.ts` - Dynamic column generation -- `apps/ui/src/components/views/board-view/kanban-board.tsx` - Pipeline props, scrolling -- `apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx` - Settings UI -- `apps/ui/src/hooks/use-responsive-kanban.ts` - Scroll support - -### API Endpoints - -``` -POST /api/pipeline/config - Get pipeline config -POST /api/pipeline/config/save - Save pipeline config -POST /api/pipeline/steps/add - Add a step -POST /api/pipeline/steps/update - Update a step -POST /api/pipeline/steps/delete - Delete a step -POST /api/pipeline/steps/reorder - Reorder steps -``` - -### Status Flow - -``` -backlog → in_progress → pipeline_step1 → pipeline_step2 → ... → verified/waiting_approval -``` - -Pipeline statuses use the format `pipeline_{stepId}` to support unlimited dynamic steps. From 2b89b0606ca0563f50f963796b2d8374defa6d65 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 18:03:01 -0500 Subject: [PATCH 04/24] feat: implement secure file system access and path validation - Introduced a restricted file system wrapper to ensure all file operations are confined to the script's directory, enhancing security. - Updated various modules to utilize the new secure file system methods, replacing direct fs calls with validated operations. - Enhanced path validation in the server routes and context loaders to prevent unauthorized access to the file system. - Adjusted environment variable handling to use centralized methods for reading and writing API keys, ensuring consistent security practices. This change improves the overall security posture of the application by enforcing strict file access controls and validating paths before any operations are performed. --- apps/server/.env.example | 2 +- apps/server/src/lib/auth.ts | 21 +- apps/server/src/lib/secure-fs.ts | 11 + .../routes/context/routes/describe-image.ts | 14 +- apps/server/src/routes/fs/routes/browse.ts | 17 +- .../src/routes/fs/routes/validate-path.ts | 18 +- apps/server/src/routes/setup/common.ts | 29 +- .../src/routes/setup/get-claude-status.ts | 151 +- .../src/routes/setup/routes/delete-api-key.ts | 24 +- .../src/routes/setup/routes/gh-status.ts | 40 +- .../src/routes/terminal/routes/sessions.ts | 4 +- apps/server/src/services/terminal-service.ts | 44 +- apps/ui/src/lib/http-api-client.ts | 5 +- apps/ui/src/main.ts | 171 +- docs/migration-plan-nextjs-to-vite.md | 1829 ----------------- init.mjs | 43 +- libs/platform/src/index.ts | 60 + libs/platform/src/node-finder.ts | 151 +- libs/platform/src/secure-fs.ts | 322 ++- libs/platform/src/system-paths.ts | 787 +++++++ libs/utils/src/context-loader.ts | 32 +- 21 files changed, 1577 insertions(+), 2198 deletions(-) delete mode 100644 docs/migration-plan-nextjs-to-vite.md create mode 100644 libs/platform/src/system-paths.ts diff --git a/apps/server/.env.example b/apps/server/.env.example index 9fbb4cbd..3afb5d4e 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY= # CORS origin - which domains can access the API # Use "*" for development, set specific origin for production -CORS_ORIGIN=* +CORS_ORIGIN=http://localhost:3007 # ============================================ # OPTIONAL - Server diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index d8629d61..acf8bb26 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -10,8 +10,8 @@ import type { Request, Response, NextFunction } from 'express'; import crypto from 'crypto'; -import fs from 'fs'; import path from 'path'; +import * as secureFs from './secure-fs.js'; const DATA_DIR = process.env.DATA_DIR || './data'; const API_KEY_FILE = path.join(DATA_DIR, '.api-key'); @@ -41,8 +41,8 @@ setInterval(() => { */ function loadSessions(): void { try { - if (fs.existsSync(SESSIONS_FILE)) { - const data = fs.readFileSync(SESSIONS_FILE, 'utf-8'); + if (secureFs.existsSync(SESSIONS_FILE)) { + const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string; const sessions = JSON.parse(data) as Array< [string, { createdAt: number; expiresAt: number }] >; @@ -74,12 +74,9 @@ function loadSessions(): void { */ async function saveSessions(): Promise { try { - await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); + await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); const sessions = Array.from(validSessions.entries()); - await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { - encoding: 'utf-8', - mode: 0o600, - }); + await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), 'utf-8'); } catch (error) { console.error('[Auth] Failed to save sessions:', error); } @@ -101,8 +98,8 @@ function ensureApiKey(): string { // Try to read from file try { - if (fs.existsSync(API_KEY_FILE)) { - const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim(); + if (secureFs.existsSync(API_KEY_FILE)) { + const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim(); if (key) { console.log('[Auth] Loaded API key from file'); return key; @@ -115,8 +112,8 @@ function ensureApiKey(): string { // Generate new key const newKey = crypto.randomUUID(); try { - fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); - fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); + secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); + secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8' }); console.log('[Auth] Generated new API key'); } catch (error) { console.error('[Auth] Failed to save API key:', error); diff --git a/apps/server/src/lib/secure-fs.ts b/apps/server/src/lib/secure-fs.ts index 30095285..de8dba26 100644 --- a/apps/server/src/lib/secure-fs.ts +++ b/apps/server/src/lib/secure-fs.ts @@ -6,6 +6,7 @@ import { secureFs } from '@automaker/platform'; export const { + // Async methods access, readFile, writeFile, @@ -20,6 +21,16 @@ export const { lstat, joinPath, resolvePath, + // Sync methods + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + accessSync, + unlinkSync, + rmSync, // Throttling configuration and monitoring configureThrottling, getThrottlingConfig, diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index e4821b4a..bce87740 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger, readImageAsBase64 } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/types'; import { createCustomOptions } from '../../../lib/sdk-options.js'; -import * as fs from 'fs'; +import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; @@ -57,13 +57,13 @@ function filterSafeHeaders(headers: Record): Record | null = null; try { - stat = fs.statSync(actualPath); + stat = secureFs.statSync(actualPath); logger.info( `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}` ); diff --git a/apps/server/src/routes/fs/routes/browse.ts b/apps/server/src/routes/fs/routes/browse.ts index c3cd4c65..68259291 100644 --- a/apps/server/src/routes/fs/routes/browse.ts +++ b/apps/server/src/routes/fs/routes/browse.ts @@ -6,7 +6,7 @@ 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 { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createBrowseHandler() { @@ -40,9 +40,16 @@ export function createBrowseHandler() { return drives; }; - // Get parent directory + // Get parent directory - only if it's within the allowed root const parentPath = path.dirname(targetPath); - const hasParent = parentPath !== targetPath; + + // Determine if parent navigation should be allowed: + // 1. Must have a different parent (not at filesystem root) + // 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it + const hasParent = parentPath !== targetPath && isPathAllowed(parentPath); + + // Security: Don't expose parent path outside allowed root + const safeParentPath = hasParent ? parentPath : null; // Get available drives const drives = await detectDrives(); @@ -70,7 +77,7 @@ export function createBrowseHandler() { res.json({ success: true, currentPath: targetPath, - parentPath: hasParent ? parentPath : null, + parentPath: safeParentPath, directories, drives, }); @@ -84,7 +91,7 @@ export function createBrowseHandler() { res.json({ success: true, currentPath: targetPath, - parentPath: hasParent ? parentPath : null, + parentPath: safeParentPath, directories: [], drives, warning: diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 374fe18f..8659eb5a 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -5,7 +5,7 @@ import type { Request, Response } from 'express'; import * as secureFs from '../../../lib/secure-fs.js'; import path from 'path'; -import { isPathAllowed } from '@automaker/platform'; +import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createValidatePathHandler() { @@ -20,6 +20,20 @@ export function createValidatePathHandler() { const resolvedPath = path.resolve(filePath); + // Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists + if (!isPathAllowed(resolvedPath)) { + const allowedRoot = getAllowedRootDirectory(); + const errorMessage = allowedRoot + ? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` + : `Path not allowed: ${filePath}`; + res.status(403).json({ + success: false, + error: errorMessage, + isAllowed: false, + }); + return; + } + // Check if path exists try { const stats = await secureFs.stat(resolvedPath); @@ -32,7 +46,7 @@ export function createValidatePathHandler() { res.json({ success: true, path: resolvedPath, - isAllowed: isPathAllowed(resolvedPath), + isAllowed: true, }); } catch { res.status(400).json({ success: false, error: 'Path does not exist' }); diff --git a/apps/server/src/routes/setup/common.ts b/apps/server/src/routes/setup/common.ts index 097d7a6c..ebac7644 100644 --- a/apps/server/src/routes/setup/common.ts +++ b/apps/server/src/routes/setup/common.ts @@ -4,7 +4,7 @@ import { createLogger } from '@automaker/utils'; import path from 'path'; -import fs from 'fs/promises'; +import { secureFs } from '@automaker/platform'; import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; const logger = createLogger('Setup'); @@ -35,36 +35,13 @@ export function getAllApiKeys(): Record { /** * Helper to persist API keys to .env file + * Uses centralized secureFs.writeEnvKey for path validation */ export async function persistApiKeyToEnv(key: string, value: string): Promise { const envPath = path.join(process.cwd(), '.env'); try { - let envContent = ''; - try { - envContent = await fs.readFile(envPath, 'utf-8'); - } catch { - // .env file doesn't exist, we'll create it - } - - // Parse existing env content - const lines = envContent.split('\n'); - const keyRegex = new RegExp(`^${key}=`); - let found = false; - const newLines = lines.map((line) => { - if (keyRegex.test(line)) { - found = true; - return `${key}=${value}`; - } - return line; - }); - - if (!found) { - // Add the key at the end - newLines.push(`${key}=${value}`); - } - - await fs.writeFile(envPath, newLines.join('\n')); + await secureFs.writeEnvKey(envPath, key, value); logger.info(`[Setup] Persisted ${key} to .env file`); } catch (error) { logger.error(`[Setup] Failed to persist ${key} to .env:`, error); diff --git a/apps/server/src/routes/setup/get-claude-status.ts b/apps/server/src/routes/setup/get-claude-status.ts index 922d363f..3ddd8ed4 100644 --- a/apps/server/src/routes/setup/get-claude-status.ts +++ b/apps/server/src/routes/setup/get-claude-status.ts @@ -4,9 +4,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; -import os from 'os'; -import path from 'path'; -import fs from 'fs/promises'; +import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform'; import { getApiKey } from './common.js'; const execAsync = promisify(exec); @@ -37,42 +35,25 @@ export async function getClaudeStatus() { // Version command might not be available } } catch { - // Not in PATH, try common locations based on platform - const commonPaths = isWindows - ? (() => { - const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return [ - // Windows-specific paths - path.join(os.homedir(), '.local', 'bin', 'claude.exe'), - path.join(appData, 'npm', 'claude.cmd'), - path.join(appData, 'npm', 'claude'), - path.join(appData, '.npm-global', 'bin', 'claude.cmd'), - path.join(appData, '.npm-global', 'bin', 'claude'), - ]; - })() - : [ - // Unix (Linux/macOS) paths - path.join(os.homedir(), '.local', 'bin', 'claude'), - path.join(os.homedir(), '.claude', 'local', 'claude'), - '/usr/local/bin/claude', - path.join(os.homedir(), '.npm-global', 'bin', 'claude'), - ]; + // Not in PATH, try common locations from centralized system paths + const commonPaths = getClaudeCliPaths(); for (const p of commonPaths) { try { - await fs.access(p); - cliPath = p; - installed = true; - method = 'local'; + if (await systemPathAccess(p)) { + cliPath = p; + installed = true; + method = 'local'; - // Get version from this path - try { - const { stdout: versionOut } = await execAsync(`"${p}" --version`); - version = versionOut.trim(); - } catch { - // Version command might not be available + // Get version from this path + try { + const { stdout: versionOut } = await execAsync(`"${p}" --version`); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + break; } - break; } catch { // Not found at this path } @@ -82,7 +63,7 @@ export async function getClaudeStatus() { // Check authentication - detect all possible auth methods // Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth // apiKeys.anthropic stores direct API keys for pay-per-use - let auth = { + const auth = { authenticated: false, method: 'none' as string, hasCredentialsFile: false, @@ -97,76 +78,36 @@ export async function getClaudeStatus() { hasRecentActivity: false, }; - const claudeDir = path.join(os.homedir(), '.claude'); + // Use centralized system paths to check Claude authentication indicators + const indicators = await getClaudeAuthIndicators(); - // Check for recent Claude CLI activity - indicates working authentication - // The stats-cache.json file is only populated when the CLI is working properly - const statsCachePath = path.join(claudeDir, 'stats-cache.json'); - try { - const statsContent = await fs.readFile(statsCachePath, 'utf-8'); - const stats = JSON.parse(statsContent); + // Check for recent activity (indicates working authentication) + if (indicators.hasStatsCacheWithActivity) { + auth.hasRecentActivity = true; + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = 'cli_authenticated'; + } - // Check if there's any activity (which means the CLI is authenticated and working) - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - auth.hasRecentActivity = true; - auth.hasCliAuth = true; + // Check for settings + sessions (indicates CLI is set up) + if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) { + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = 'cli_authenticated'; + } + + // Check credentials file + if (indicators.hasCredentialsFile && indicators.credentials) { + auth.hasCredentialsFile = true; + if (indicators.credentials.hasOAuthToken) { + auth.hasStoredOAuthToken = true; + auth.oauthTokenValid = true; auth.authenticated = true; - auth.method = 'cli_authenticated'; - } - } catch { - // Stats file doesn't exist or is invalid - } - - // Check for settings.json - indicates CLI has been set up - const settingsPath = path.join(claudeDir, 'settings.json'); - try { - await fs.access(settingsPath); - // If settings exist but no activity, CLI might be set up but not authenticated - if (!auth.hasCliAuth) { - // Try to check for other indicators of auth - const sessionsDir = path.join(claudeDir, 'projects'); - try { - const sessions = await fs.readdir(sessionsDir); - if (sessions.length > 0) { - auth.hasCliAuth = true; - auth.authenticated = true; - auth.method = 'cli_authenticated'; - } - } catch { - // Sessions directory doesn't exist - } - } - } catch { - // Settings file doesn't exist - } - - // Check for credentials file (OAuth tokens from claude login) - // Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform - const credentialsPaths = [ - path.join(claudeDir, '.credentials.json'), - path.join(claudeDir, 'credentials.json'), - ]; - - for (const credentialsPath of credentialsPaths) { - try { - const credentialsContent = await fs.readFile(credentialsPath, 'utf-8'); - const credentials = JSON.parse(credentialsContent); - auth.hasCredentialsFile = true; - - // Check what type of token is in credentials - if (credentials.oauth_token || credentials.access_token) { - auth.hasStoredOAuthToken = true; - auth.oauthTokenValid = true; - auth.authenticated = true; - auth.method = 'oauth_token'; // Stored OAuth token from credentials file - } else if (credentials.api_key) { - auth.apiKeyValid = true; - auth.authenticated = true; - auth.method = 'api_key'; // Stored API key in credentials file - } - break; // Found and processed credentials file - } catch { - // No credentials file at this path or invalid format + auth.method = 'oauth_token'; + } else if (indicators.credentials.hasApiKey) { + auth.apiKeyValid = true; + auth.authenticated = true; + auth.method = 'api_key'; } } @@ -174,21 +115,21 @@ export async function getClaudeStatus() { if (auth.hasEnvApiKey) { auth.authenticated = true; auth.apiKeyValid = true; - auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var + auth.method = 'api_key_env'; } // In-memory stored OAuth token (from setup wizard - subscription auth) if (!auth.authenticated && getApiKey('anthropic_oauth_token')) { auth.authenticated = true; auth.oauthTokenValid = true; - auth.method = 'oauth_token'; // Stored OAuth token from setup wizard + auth.method = 'oauth_token'; } // In-memory stored API key (from settings UI - pay-per-use) if (!auth.authenticated && getApiKey('anthropic')) { auth.authenticated = true; auth.apiKeyValid = true; - auth.method = 'api_key'; // Manually stored API key + auth.method = 'api_key'; } return { diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index e64ff6b7..0fee1b8b 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -5,40 +5,22 @@ import type { Request, Response } from 'express'; import { createLogger } from '@automaker/utils'; import path from 'path'; -import fs from 'fs/promises'; +import { secureFs } from '@automaker/platform'; const logger = createLogger('Setup'); // In-memory storage reference (imported from common.ts pattern) -// We need to modify common.ts to export a deleteApiKey function import { setApiKey } from '../common.js'; /** * Remove an API key from the .env file + * Uses centralized secureFs.removeEnvKey for path validation */ async function removeApiKeyFromEnv(key: string): Promise { const envPath = path.join(process.cwd(), '.env'); try { - let envContent = ''; - try { - envContent = await fs.readFile(envPath, 'utf-8'); - } catch { - // .env file doesn't exist, nothing to delete - return; - } - - // Parse existing env content and remove the key - const lines = envContent.split('\n'); - const keyRegex = new RegExp(`^${key}=`); - const newLines = lines.filter((line) => !keyRegex.test(line)); - - // Remove empty lines at the end - while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') { - newLines.pop(); - } - - await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : '')); + await secureFs.removeEnvKey(envPath, key); logger.info(`[Setup] Removed ${key} from .env file`); } catch (error) { logger.error(`[Setup] Failed to remove ${key} from .env:`, error); diff --git a/apps/server/src/routes/setup/routes/gh-status.ts b/apps/server/src/routes/setup/routes/gh-status.ts index e48b5c25..f78bbd6d 100644 --- a/apps/server/src/routes/setup/routes/gh-status.ts +++ b/apps/server/src/routes/setup/routes/gh-status.ts @@ -5,27 +5,14 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import os from 'os'; -import path from 'path'; -import fs from 'fs/promises'; +import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); -// Extended PATH to include common tool installation locations -const extendedPath = [ - process.env.PATH, - '/opt/homebrew/bin', - '/usr/local/bin', - '/home/linuxbrew/.linuxbrew/bin', - `${process.env.HOME}/.local/bin`, -] - .filter(Boolean) - .join(':'); - const execEnv = { ...process.env, - PATH: extendedPath, + PATH: getExtendedPath(), }; export interface GhStatus { @@ -55,25 +42,16 @@ async function getGhStatus(): Promise { status.path = stdout.trim().split(/\r?\n/)[0]; status.installed = true; } catch { - // gh not in PATH, try common locations - const commonPaths = isWindows - ? [ - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'), - path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'), - ] - : [ - '/opt/homebrew/bin/gh', - '/usr/local/bin/gh', - path.join(os.homedir(), '.local', 'bin', 'gh'), - '/home/linuxbrew/.linuxbrew/bin/gh', - ]; + // gh not in PATH, try common locations from centralized system paths + const commonPaths = getGitHubCliPaths(); for (const p of commonPaths) { try { - await fs.access(p); - status.path = p; - status.installed = true; - break; + if (await systemPathAccess(p)) { + status.path = p; + status.installed = true; + break; + } } catch { // Not found at this path } diff --git a/apps/server/src/routes/terminal/routes/sessions.ts b/apps/server/src/routes/terminal/routes/sessions.ts index a7f42509..7d4b5383 100644 --- a/apps/server/src/routes/terminal/routes/sessions.ts +++ b/apps/server/src/routes/terminal/routes/sessions.ts @@ -22,12 +22,12 @@ export function createSessionsListHandler() { } export function createSessionsCreateHandler() { - return (req: Request, res: Response): void => { + return async (req: Request, res: Response): Promise => { try { const terminalService = getTerminalService(); const { cwd, cols, rows, shell } = req.body; - const session = terminalService.createSession({ + const session = await terminalService.createSession({ cwd, cols: cols || 80, rows: rows || 24, diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 7d59633e..1aea267d 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -8,8 +8,13 @@ import * as pty from 'node-pty'; import { EventEmitter } from 'events'; import * as os from 'os'; -import * as fs from 'fs'; import * as path from 'path'; +// secureFs is used for user-controllable paths (working directory validation) +// to enforce ALLOWED_ROOT_DIRECTORY security boundary +import * as secureFs from '../lib/secure-fs.js'; +// System paths module handles shell binary checks and WSL detection +// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing +import { systemPathExists, systemPathReadFileSync, getWslVersionPath } from '@automaker/platform'; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -68,7 +73,7 @@ export class TerminalService extends EventEmitter { if (platform === 'linux' && this.isWSL()) { // In WSL, prefer the user's configured shell or bash const userShell = process.env.SHELL || '/bin/bash'; - if (fs.existsSync(userShell)) { + if (systemPathExists(userShell)) { return { shell: userShell, args: ['--login'] }; } return { shell: '/bin/bash', args: ['--login'] }; @@ -80,10 +85,10 @@ export class TerminalService extends EventEmitter { const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; - if (fs.existsSync(pwshCore)) { + if (systemPathExists(pwshCore)) { return { shell: pwshCore, args: [] }; } - if (fs.existsSync(pwsh)) { + if (systemPathExists(pwsh)) { return { shell: pwsh, args: [] }; } return { shell: 'cmd.exe', args: [] }; @@ -92,10 +97,10 @@ export class TerminalService extends EventEmitter { case 'darwin': { // macOS: prefer user's shell, then zsh, then bash const userShell = process.env.SHELL; - if (userShell && fs.existsSync(userShell)) { + if (userShell && systemPathExists(userShell)) { return { shell: userShell, args: ['--login'] }; } - if (fs.existsSync('/bin/zsh')) { + if (systemPathExists('/bin/zsh')) { return { shell: '/bin/zsh', args: ['--login'] }; } return { shell: '/bin/bash', args: ['--login'] }; @@ -105,10 +110,10 @@ export class TerminalService extends EventEmitter { default: { // Linux: prefer user's shell, then bash, then sh const userShell = process.env.SHELL; - if (userShell && fs.existsSync(userShell)) { + if (userShell && systemPathExists(userShell)) { return { shell: userShell, args: ['--login'] }; } - if (fs.existsSync('/bin/bash')) { + if (systemPathExists('/bin/bash')) { return { shell: '/bin/bash', args: ['--login'] }; } return { shell: '/bin/sh', args: [] }; @@ -122,8 +127,9 @@ export class TerminalService extends EventEmitter { isWSL(): boolean { try { // Check /proc/version for Microsoft/WSL indicators - if (fs.existsSync('/proc/version')) { - const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); + const wslVersionPath = getWslVersionPath(); + if (systemPathExists(wslVersionPath)) { + const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase(); return version.includes('microsoft') || version.includes('wsl'); } // Check for WSL environment variable @@ -157,8 +163,9 @@ export class TerminalService extends EventEmitter { /** * Validate and resolve a working directory path * Includes basic sanitization against null bytes and path normalization + * Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths */ - private resolveWorkingDirectory(requestedCwd?: string): string { + private async resolveWorkingDirectory(requestedCwd?: string): Promise { const homeDir = os.homedir(); // If no cwd requested, use home @@ -187,15 +194,19 @@ export class TerminalService extends EventEmitter { } // Check if path exists and is a directory + // Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary + // This prevents terminals from being opened in directories outside the allowed workspace try { - const stat = fs.statSync(cwd); - if (stat.isDirectory()) { + const statResult = await secureFs.stat(cwd); + if (statResult.isDirectory()) { return cwd; } console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`); return homeDir; } catch { - console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`); + console.warn( + `[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home` + ); return homeDir; } } @@ -228,7 +239,7 @@ export class TerminalService extends EventEmitter { * Create a new terminal session * Returns null if the maximum session limit has been reached */ - createSession(options: TerminalOptions = {}): TerminalSession | null { + async createSession(options: TerminalOptions = {}): Promise { // Check session limit if (this.sessions.size >= maxSessions) { console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`); @@ -241,7 +252,8 @@ export class TerminalService extends EventEmitter { const shell = options.shell || detectedShell; // Validate and resolve working directory - const cwd = this.resolveWorkingDirectory(options.cwd); + // Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY + const cwd = await this.resolveWorkingDirectory(options.cwd); // Build environment with some useful defaults // These settings ensure consistent terminal behavior across platforms diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b856bd51..1a0ad33f 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -566,14 +566,15 @@ export class HttpApiClient implements ElectronAPI { const result = await this.post<{ success: boolean; path?: string; + isAllowed?: boolean; error?: string; }>('/api/fs/validate-path', { filePath: path }); - if (result.success && result.path) { + if (result.success && result.path && result.isAllowed !== false) { return { canceled: false, filePaths: [result.path] }; } - console.error('Invalid directory:', result.error); + console.error('Invalid directory:', result.error || 'Path not allowed'); return { canceled: true, filePaths: [] }; } diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index b2f7bd86..e8bcaaa9 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -3,15 +3,36 @@ * * This version spawns the backend server and uses HTTP API for most operations. * Only native features (dialogs, shell) use IPC. + * + * SECURITY: All file system access uses centralized methods from @automaker/platform. */ import path from 'path'; import { spawn, execSync, ChildProcess } from 'child_process'; -import fs from 'fs'; import crypto from 'crypto'; import http, { Server } from 'http'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; -import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; +import { + findNodeExecutable, + buildEnhancedPath, + initAllowedPaths, + isPathAllowed, + getAllowedRootDirectory, + // Electron userData operations + setElectronUserDataPath, + electronUserDataReadFileSync, + electronUserDataWriteFileSync, + electronUserDataExists, + // Electron app bundle operations + setElectronAppPaths, + electronAppExists, + electronAppReadFileSync, + electronAppStatSync, + electronAppStat, + electronAppReadFile, + // System path operations + systemPathExists, +} from '@automaker/platform'; // Development environment const isDev = !app.isPackaged; @@ -64,21 +85,19 @@ let saveWindowBoundsTimeout: ReturnType | null = null; let apiKey: string | null = null; /** - * Get path to API key file in user data directory + * Get the relative path to API key file within userData */ -function getApiKeyPath(): string { - return path.join(app.getPath('userData'), '.api-key'); -} +const API_KEY_FILENAME = '.api-key'; /** * Ensure an API key exists - load from file or generate new one. * This key is passed to the server for CSRF protection. + * Uses centralized electronUserData methods for path validation. */ function ensureApiKey(): string { - const keyPath = getApiKeyPath(); try { - if (fs.existsSync(keyPath)) { - const key = fs.readFileSync(keyPath, 'utf-8').trim(); + if (electronUserDataExists(API_KEY_FILENAME)) { + const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim(); if (key) { apiKey = key; console.log('[Electron] Loaded existing API key'); @@ -92,7 +111,7 @@ function ensureApiKey(): string { // Generate new key apiKey = crypto.randomUUID(); try { - fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 }); + electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 }); console.log('[Electron] Generated new API key'); } catch (error) { console.error('[Electron] Failed to save API key:', error); @@ -102,6 +121,7 @@ function ensureApiKey(): string { /** * Get icon path - works in both dev and production, cross-platform + * Uses centralized electronApp methods for path validation. */ function getIconPath(): string | null { let iconFile: string; @@ -117,8 +137,13 @@ function getIconPath(): string | null { ? path.join(__dirname, '../public', iconFile) : path.join(__dirname, '../dist/public', iconFile); - if (!fs.existsSync(iconPath)) { - console.warn(`[Electron] Icon not found at: ${iconPath}`); + try { + if (!electronAppExists(iconPath)) { + console.warn(`[Electron] Icon not found at: ${iconPath}`); + return null; + } + } catch (error) { + console.warn(`[Electron] Icon check failed: ${iconPath}`, error); return null; } @@ -126,20 +151,18 @@ function getIconPath(): string | null { } /** - * Get path to window bounds settings file + * Relative path to window bounds settings file within userData */ -function getWindowBoundsPath(): string { - return path.join(app.getPath('userData'), 'window-bounds.json'); -} +const WINDOW_BOUNDS_FILENAME = 'window-bounds.json'; /** * Load saved window bounds from disk + * Uses centralized electronUserData methods for path validation. */ function loadWindowBounds(): WindowBounds | null { try { - const boundsPath = getWindowBoundsPath(); - if (fs.existsSync(boundsPath)) { - const data = fs.readFileSync(boundsPath, 'utf-8'); + if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) { + const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME); const bounds = JSON.parse(data) as WindowBounds; // Validate the loaded data has required fields if ( @@ -159,11 +182,11 @@ function loadWindowBounds(): WindowBounds | null { /** * Save window bounds to disk + * Uses centralized electronUserData methods for path validation. */ function saveWindowBounds(bounds: WindowBounds): void { try { - const boundsPath = getWindowBoundsPath(); - fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8'); + electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2)); console.log('[Electron] Window bounds saved'); } catch (error) { console.warn('[Electron] Failed to save window bounds:', (error as Error).message); @@ -241,6 +264,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds { /** * Start static file server for production builds + * Uses centralized electronApp methods for serving static files from app bundle. */ async function startStaticServer(): Promise { const staticPath = path.join(__dirname, '../dist'); @@ -253,20 +277,24 @@ async function startStaticServer(): Promise { } else if (!path.extname(filePath)) { // For client-side routing, serve index.html for paths without extensions const possibleFile = filePath + '.html'; - if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) { + try { + if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) { + filePath = path.join(staticPath, 'index.html'); + } else if (electronAppExists(possibleFile)) { + filePath = possibleFile; + } + } catch { filePath = path.join(staticPath, 'index.html'); - } else if (fs.existsSync(possibleFile)) { - filePath = possibleFile; } } - fs.stat(filePath, (err, stats) => { + electronAppStat(filePath, (err, stats) => { if (err || !stats?.isFile()) { filePath = path.join(staticPath, 'index.html'); } - fs.readFile(filePath, (error, content) => { - if (error) { + electronAppReadFile(filePath, (error, content) => { + if (error || !content) { response.writeHead(500); response.end('Server Error'); return; @@ -308,6 +336,7 @@ async function startStaticServer(): Promise { /** * Start the backend server + * Uses centralized methods for path validation. */ async function startServer(): Promise { // Find Node.js executable (handles desktop launcher scenarios) @@ -318,8 +347,17 @@ async function startServer(): Promise { const command = nodeResult.nodePath; // Validate that the found Node executable actually exists - if (command !== 'node' && !fs.existsSync(command)) { - throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + // systemPathExists is used because node-finder returns system paths + if (command !== 'node') { + try { + if (!systemPathExists(command)) { + throw new Error( + `Node.js executable not found at: ${command} (source: ${nodeResult.source})` + ); + } + } catch { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } } let args: string[]; @@ -332,11 +370,22 @@ async function startServer(): Promise { const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx'); let tsxCliPath: string; - if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) { - tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); - } else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) { - tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); - } else { + // Check for tsx in app bundle paths + try { + if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); + } else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); + } else { + try { + tsxCliPath = require.resolve('tsx/cli.mjs', { + paths: [path.join(__dirname, '../../server')], + }); + } catch { + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); + } + } + } catch { try { tsxCliPath = require.resolve('tsx/cli.mjs', { paths: [path.join(__dirname, '../../server')], @@ -351,7 +400,11 @@ async function startServer(): Promise { serverPath = path.join(process.resourcesPath, 'server', 'index.js'); args = [serverPath]; - if (!fs.existsSync(serverPath)) { + try { + if (!electronAppExists(serverPath)) { + throw new Error(`Server not found at: ${serverPath}`); + } + } catch { throw new Error(`Server not found at: ${serverPath}`); } } @@ -360,6 +413,13 @@ async function startServer(): Promise { ? path.join(process.resourcesPath, 'server', 'node_modules') : path.join(__dirname, '../../server/node_modules'); + // Server root directory - where .env file is located + // In dev: apps/server (not apps/server/src) + // In production: resources/server + const serverRoot = app.isPackaged + ? path.join(process.resourcesPath, 'server') + : path.join(__dirname, '../../server'); + // Build enhanced PATH that includes Node.js directory (cross-platform) const enhancedPath = buildEnhancedPath(command, process.env.PATH || ''); if (enhancedPath !== process.env.PATH) { @@ -383,10 +443,11 @@ async function startServer(): Promise { console.log('[Electron] Starting backend server...'); console.log('[Electron] Server path:', serverPath); + console.log('[Electron] Server root (cwd):', serverRoot); console.log('[Electron] NODE_PATH:', serverNodeModules); serverProcess = spawn(command, args, { - cwd: path.dirname(serverPath), + cwd: serverRoot, env, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -541,6 +602,28 @@ app.whenReady().then(async () => { console.warn('[Electron] Failed to set userData path:', (error as Error).message); } + // Initialize centralized path helpers for Electron + // This must be done before any file operations + setElectronUserDataPath(app.getPath('userData')); + + // In development mode, allow access to the entire project root (for source files, node_modules, etc.) + // In production, only allow access to the built app directory and resources + if (isDev) { + // __dirname is apps/ui/dist-electron, so go up 3 levels to get project root + const projectRoot = path.join(__dirname, '../../..'); + setElectronAppPaths([__dirname, projectRoot]); + } else { + setElectronAppPaths(__dirname, process.resourcesPath); + } + console.log('[Electron] Initialized path security helpers'); + + // Initialize security settings for path validation + // Set DATA_DIR before initializing so it's available for security checks + process.env.DATA_DIR = app.getPath('userData'); + // ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user + // (it will be passed to server process, but we also need it in main process for dialog validation) + initAllowedPaths(); + if (process.platform === 'darwin' && app.dock) { const iconPath = getIconPath(); if (iconPath) { @@ -631,6 +714,22 @@ ipcMain.handle('dialog:openDirectory', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); + + // Validate selected path against ALLOWED_ROOT_DIRECTORY if configured + if (!result.canceled && result.filePaths.length > 0) { + const selectedPath = result.filePaths[0]; + if (!isPathAllowed(selectedPath)) { + const allowedRoot = getAllowedRootDirectory(); + const errorMessage = allowedRoot + ? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}` + : 'The selected directory is not allowed.'; + + await dialog.showErrorBox('Directory Not Allowed', errorMessage); + + return { canceled: true, filePaths: [] }; + } + } + return result; }); diff --git a/docs/migration-plan-nextjs-to-vite.md b/docs/migration-plan-nextjs-to-vite.md deleted file mode 100644 index 7211e1ec..00000000 --- a/docs/migration-plan-nextjs-to-vite.md +++ /dev/null @@ -1,1829 +0,0 @@ -# Migration Plan: Next.js to Vite + Electron + TanStack - -> **Document Version**: 1.1 -> **Date**: December 2025 -> **Status**: Phase 2 Complete - Core Migration Done -> **Branch**: refactor/frontend - ---- - -## Table of Contents - -1. [Executive Summary](#executive-summary) -2. [Current Architecture Assessment](#current-architecture-assessment) -3. [Proposed New Architecture](#proposed-new-architecture) -4. [Folder Structure](#folder-structure) -5. [Shared Packages (libs/)](#shared-packages-libs) -6. [Type-Safe Electron Implementation](#type-safe-electron-implementation) -7. [Components Refactoring](#components-refactoring) -8. [Web + Electron Dual Support](#web--electron-dual-support) -9. [Migration Phases](#migration-phases) -10. [Expected Benefits](#expected-benefits) -11. [Risk Mitigation](#risk-mitigation) - ---- - -## Executive Summary - -### Why Migrate? - -Our current Next.js implementation uses **less than 5%** of the framework's capabilities. We're essentially running a static SPA with unnecessary overhead: - -| Next.js Feature | Our Usage | -| ---------------------- | ---------------------------- | -| Server-Side Rendering | ❌ Not used | -| Static Site Generation | ❌ Not used | -| API Routes | ⚠️ Only 2 test endpoints | -| Image Optimization | ❌ Not used | -| Dynamic Routing | ❌ Not used | -| App Router | ⚠️ File structure only | -| Metadata API | ⚠️ Title/description only | -| Static Export | ✅ Used (`output: "export"`) | - -### Migration Benefits - -| Metric | Current (Next.js) | Expected (Vite) | -| ---------------------- | ----------------- | ------------------ | -| Dev server startup | ~8-15s | ~1-3s | -| HMR speed | ~500ms-2s | ~50-100ms | -| Production build | ~45-90s | ~15-30s | -| Bundle overhead | Next.js runtime | None | -| Type safety (Electron) | 0% | 100% | -| Debug capabilities | Limited | Full debug console | - -### Target Stack - -- **Bundler**: Vite -- **Framework**: React 19 -- **Routing**: TanStack Router (file-based) -- **Data Fetching**: TanStack Query -- **State**: Zustand (unchanged) -- **Styling**: Tailwind CSS 4 (unchanged) -- **Desktop**: Electron (TypeScript rewrite) - ---- - -## Current Architecture Assessment - -### Data Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ELECTRON APP │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ HTTP/WS ┌─────────────────┐ │ -│ │ React SPA │ ←──────────────────→ │ Backend Server │ │ -│ │ (Next.js) │ localhost:3008 │ (Express) │ │ -│ │ │ │ │ │ -│ │ • Zustand Store │ │ • AI Providers │ │ -│ │ • 16 Views │ │ • Git/FS Ops │ │ -│ │ • 180+ Comps │ │ • Terminal │ │ -│ └────────┬────────┘ └─────────────────┘ │ -│ │ │ -│ │ IPC (minimal - dialogs/shell only) │ -│ ↓ │ -│ ┌─────────────────┐ │ -│ │ Electron Main │ • File dialogs │ -│ │ (main.js) │ • Shell operations │ -│ │ **NO TYPES** │ • App paths │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Current Electron Layer Issues - -| Issue | Impact | Solution | -| ----------------------- | --------------------------- | ------------------------ | -| Pure JavaScript | No compile-time safety | Migrate to TypeScript | -| Untyped IPC handlers | Runtime errors | IPC Schema with generics | -| String literal channels | Typos cause silent failures | Const enums | -| No debug tooling | Hard to diagnose issues | Debug console feature | -| Monolithic main.js | Hard to maintain | Modular IPC organization | - -### Current Component Structure Issues - -| View File | Lines | Issue | -| ------------------ | ----- | ---------------------------------- | -| spec-view.tsx | 1,230 | Exceeds 500-line threshold | -| analysis-view.tsx | 1,134 | Exceeds 500-line threshold | -| agent-view.tsx | 916 | Exceeds 500-line threshold | -| welcome-view.tsx | 815 | Exceeds 500-line threshold | -| context-view.tsx | 735 | Exceeds 500-line threshold | -| terminal-view.tsx | 697 | Exceeds 500-line threshold | -| interview-view.tsx | 637 | Exceeds 500-line threshold | -| board-view.tsx | 685 | ✅ Already has subfolder structure | - ---- - -## Proposed New Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ MIGRATED ARCHITECTURE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @automaker/app (Vite + React) │ │ -│ ├──────────────────────────────────────────────────────────────────┤ │ -│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ │ -│ │ │ TanStack │ │ TanStack │ │ Zustand │ │ │ -│ │ │ Router │ │ Query │ │ Store │ │ │ -│ │ │ (file-based) │ │ (data fetch) │ │ (UI state) │ │ │ -│ │ └────────────────┘ └────────────────┘ └────────────────────┘ │ │ -│ │ │ │ -│ │ src/ │ │ -│ │ ├── routes/ # TanStack file-based routes │ │ -│ │ ├── components/ # Refactored per folder-pattern.md │ │ -│ │ ├── hooks/ # React hooks │ │ -│ │ ├── store/ # Zustand stores │ │ -│ │ ├── lib/ # Utilities │ │ -│ │ └── config/ # Configuration │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ HTTP/WS (unchanged) │ Type-Safe IPC │ -│ ↓ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Electron Layer (TypeScript) │ │ -│ ├──────────────────────────────────────────────────────────────────┤ │ -│ │ electron/ │ │ -│ │ ├── main.ts # Main process entry │ │ -│ │ ├── preload.ts # Context bridge exposure │ │ -│ │ ├── debug-console/ # Debug console feature │ │ -│ │ └── ipc/ # Modular IPC handlers │ │ -│ │ ├── ipc-schema.ts # Type definitions │ │ -│ │ ├── dialog/ # File dialogs │ │ -│ │ ├── shell/ # Shell operations │ │ -│ │ └── server/ # Server management │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @automaker/server (unchanged) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────┐ -│ SHARED PACKAGES (libs/) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ @automaker/types # API contracts, model definitions │ -│ @automaker/utils # Shared utilities (error handling, etc.) │ -│ @automaker/platform # OS-specific utilities, path handling │ -│ @automaker/model-resolver # Model string resolution │ -│ @automaker/ipc-types # IPC channel type definitions │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Folder Structure - -### apps/ui/ (After Migration) - -``` -apps/ui/ -├── electron/ # Electron main process (TypeScript) -│ ├── main.ts # Main entry point -│ ├── preload.ts # Context bridge -│ ├── tsconfig.json # Electron-specific TS config -│ ├── debug-console/ -│ │ ├── debug-console.html -│ │ ├── debug-console-preload.ts -│ │ └── debug-mode.ts -│ ├── ipc/ -│ │ ├── ipc-schema.ts # Central type definitions -│ │ ├── context-exposer.ts # Exposes all contexts to renderer -│ │ ├── listeners-register.ts # Registers all main process handlers -│ │ ├── dialog/ -│ │ │ ├── dialog-channels.ts # Channel constants -│ │ │ ├── dialog-context.ts # Preload exposure -│ │ │ └── dialog-listeners.ts # Main process handlers -│ │ ├── shell/ -│ │ │ ├── shell-channels.ts -│ │ │ ├── shell-context.ts -│ │ │ └── shell-listeners.ts -│ │ ├── app-info/ -│ │ │ ├── app-info-channels.ts -│ │ │ ├── app-info-context.ts -│ │ │ └── app-info-listeners.ts -│ │ └── server/ -│ │ ├── server-channels.ts -│ │ ├── server-context.ts -│ │ └── server-listeners.ts -│ └── helpers/ -│ ├── server-manager.ts # Backend server spawn/health -│ ├── static-server.ts # Production static file server -│ ├── window-helpers.ts # Window utilities -│ └── window-registry.ts # Multi-window tracking -│ -├── src/ -│ ├── routes/ # TanStack Router (file-based) -│ │ ├── __root.tsx # Root layout -│ │ ├── index.tsx # Welcome/home (default route) -│ │ ├── board.tsx # Board view -│ │ ├── agent.tsx # Agent view -│ │ ├── settings.tsx # Settings view -│ │ ├── setup.tsx # Setup view -│ │ ├── terminal.tsx # Terminal view -│ │ ├── spec.tsx # Spec view -│ │ ├── context.tsx # Context view -│ │ ├── profiles.tsx # Profiles view -│ │ ├── interview.tsx # Interview view -│ │ ├── wiki.tsx # Wiki view -│ │ ├── analysis.tsx # Analysis view -│ │ └── agent-tools.tsx # Agent tools view -│ │ -│ ├── components/ # Refactored per folder-pattern.md -│ │ ├── ui/ # Global UI primitives (unchanged) -│ │ ├── layout/ -│ │ │ ├── sidebar.tsx -│ │ │ ├── base-layout.tsx -│ │ │ └── index.ts -│ │ ├── dialogs/ # Global dialogs -│ │ │ ├── index.ts -│ │ │ ├── new-project-modal.tsx -│ │ │ ├── workspace-picker-modal.tsx -│ │ │ └── file-browser-dialog.tsx -│ │ └── views/ # Complex view components -│ │ ├── board-view/ # ✅ Already structured -│ │ ├── settings-view/ # Needs dialogs reorganization -│ │ ├── setup-view/ # ✅ Already structured -│ │ ├── profiles-view/ # ✅ Already structured -│ │ ├── agent-view/ # NEW: needs subfolder -│ │ │ ├── components/ -│ │ │ │ ├── index.ts -│ │ │ │ ├── message-list.tsx -│ │ │ │ ├── message-input.tsx -│ │ │ │ └── session-sidebar.tsx -│ │ │ ├── dialogs/ -│ │ │ │ ├── index.ts -│ │ │ │ ├── delete-session-dialog.tsx -│ │ │ │ └── delete-all-archived-dialog.tsx -│ │ │ └── hooks/ -│ │ │ ├── index.ts -│ │ │ └── use-agent-state.ts -│ │ ├── spec-view/ # NEW: needs subfolder (1230 lines!) -│ │ ├── analysis-view/ # NEW: needs subfolder (1134 lines!) -│ │ ├── context-view/ # NEW: needs subfolder -│ │ ├── welcome-view/ # NEW: needs subfolder -│ │ ├── interview-view/ # NEW: needs subfolder -│ │ └── terminal-view/ # Expand existing -│ │ -│ ├── hooks/ # Global hooks -│ ├── store/ # Zustand stores -│ ├── lib/ # Utilities -│ ├── config/ # Configuration -│ ├── contexts/ # React contexts -│ ├── types/ # Type definitions -│ ├── App.tsx # Root component -│ ├── renderer.ts # Vite entry point -│ └── routeTree.gen.ts # Generated by TanStack Router -│ -├── index.html # Vite HTML entry -├── vite.config.mts # Vite configuration -├── tsconfig.json # TypeScript config (renderer) -├── package.json -└── tailwind.config.ts -``` - ---- - -## Shared Packages (libs/) - -### Package Overview - -``` -libs/ -├── @automaker/types # API contracts, model definitions -├── @automaker/utils # General utilities (error handling, logger) -├── @automaker/platform # OS-specific utilities, path handling -├── @automaker/model-resolver # Model string resolution -└── @automaker/ipc-types # IPC channel type definitions -``` - -### @automaker/types - -Shared type definitions for API contracts between frontend and backend. - -``` -libs/types/ -├── src/ -│ ├── api.ts # API response types -│ ├── models.ts # ModelDefinition, ProviderStatus -│ ├── features.ts # Feature, FeatureStatus, Priority -│ ├── sessions.ts # Session, Message types -│ ├── agent.ts # Agent types -│ ├── git.ts # Git operation types -│ ├── worktree.ts # Worktree types -│ └── index.ts # Barrel export -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/types/src/models.ts -export interface ModelDefinition { - id: string; - name: string; - provider: ProviderType; - contextWindow: number; - maxOutputTokens: number; - capabilities: ModelCapabilities; -} - -export interface ModelCapabilities { - vision: boolean; - toolUse: boolean; - streaming: boolean; - computerUse: boolean; -} - -export type ProviderType = 'claude' | 'openai' | 'gemini' | 'ollama'; -``` - -### @automaker/utils - -General utilities shared between frontend and backend. - -``` -libs/utils/ -├── src/ -│ ├── error-handler.ts # Error classification & user-friendly messages -│ ├── logger.ts # Logging utilities -│ ├── conversation-utils.ts # Message formatting & history -│ ├── image-utils.ts # Image processing utilities -│ ├── string-utils.ts # String manipulation helpers -│ └── index.ts -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/utils/src/error-handler.ts -export type ErrorType = - | 'authentication' - | 'rate_limit' - | 'network' - | 'validation' - | 'not_found' - | 'server' - | 'unknown'; - -export interface ErrorInfo { - type: ErrorType; - message: string; - userMessage: string; - retryable: boolean; - statusCode?: number; -} - -export function classifyError(error: unknown): ErrorInfo; -export function getUserFriendlyErrorMessage(error: unknown): string; -export function isAbortError(error: unknown): boolean; -export function isAuthenticationError(error: unknown): boolean; -export function isRateLimitError(error: unknown): boolean; -``` - -### @automaker/platform - -**OS-specific utilities, path handling, and cross-platform helpers.** - -``` -libs/platform/ -├── src/ -│ ├── paths/ -│ │ ├── index.ts # Path utilities barrel export -│ │ ├── path-resolver.ts # Cross-platform path resolution -│ │ ├── path-constants.ts # Common path constants -│ │ └── path-validator.ts # Path validation utilities -│ ├── os/ -│ │ ├── index.ts # OS utilities barrel export -│ │ ├── platform-info.ts # Platform detection & info -│ │ ├── shell-commands.ts # OS-specific shell commands -│ │ └── env-utils.ts # Environment variable utilities -│ ├── fs/ -│ │ ├── index.ts # FS utilities barrel export -│ │ ├── safe-fs.ts # Symlink-safe file operations -│ │ ├── temp-files.ts # Temporary file handling -│ │ └── permissions.ts # File permission utilities -│ └── index.ts # Main barrel export -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/platform/src/paths/path-resolver.ts -import path from 'path'; - -/** - * Platform-aware path separator - */ -export const SEP = path.sep; - -/** - * Normalizes a path to use the correct separator for the current OS - */ -export function normalizePath(inputPath: string): string { - return inputPath.replace(/[/\\]/g, SEP); -} - -/** - * Converts a path to POSIX format (forward slashes) - * Useful for consistent storage/comparison - */ -export function toPosixPath(inputPath: string): string { - return inputPath.replace(/\\/g, '/'); -} - -/** - * Converts a path to Windows format (backslashes) - */ -export function toWindowsPath(inputPath: string): string { - return inputPath.replace(/\//g, '\\'); -} - -/** - * Resolves a path relative to a base, handling platform differences - */ -export function resolvePath(basePath: string, ...segments: string[]): string { - return path.resolve(basePath, ...segments); -} - -/** - * Gets the relative path from one location to another - */ -export function getRelativePath(from: string, to: string): string { - return path.relative(from, to); -} - -/** - * Joins path segments with proper platform separator - */ -export function joinPath(...segments: string[]): string { - return path.join(...segments); -} - -/** - * Extracts directory name from a path - */ -export function getDirname(filePath: string): string { - return path.dirname(filePath); -} - -/** - * Extracts filename from a path - */ -export function getBasename(filePath: string, ext?: string): string { - return path.basename(filePath, ext); -} - -/** - * Extracts file extension from a path - */ -export function getExtension(filePath: string): string { - return path.extname(filePath); -} - -/** - * Checks if a path is absolute - */ -export function isAbsolutePath(inputPath: string): boolean { - return path.isAbsolute(inputPath); -} - -/** - * Ensures a path is absolute, resolving relative to cwd if needed - */ -export function ensureAbsolutePath(inputPath: string, basePath?: string): string { - if (isAbsolutePath(inputPath)) { - return inputPath; - } - return resolvePath(basePath || process.cwd(), inputPath); -} -``` - -```typescript -// libs/platform/src/paths/path-constants.ts -import path from 'path'; -import os from 'os'; - -/** - * Common system paths - */ -export const SYSTEM_PATHS = { - /** User's home directory */ - home: os.homedir(), - - /** System temporary directory */ - temp: os.tmpdir(), - - /** Current working directory */ - cwd: process.cwd(), -} as const; - -/** - * Gets the appropriate app data directory for the current platform - */ -export function getAppDataPath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - appName - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', appName); - default: // Linux and others - return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName); - } -} - -/** - * Gets the appropriate cache directory for the current platform - */ -export function getCachePath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), - appName, - 'Cache' - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Caches', appName); - default: - return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), appName); - } -} - -/** - * Gets the appropriate logs directory for the current platform - */ -export function getLogsPath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), - appName, - 'Logs' - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Logs', appName); - default: - return path.join( - process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), - appName, - 'logs' - ); - } -} - -/** - * Gets the user's Documents directory - */ -export function getDocumentsPath(): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, 'Documents') - : path.join(os.homedir(), 'Documents'); - case 'darwin': - return path.join(os.homedir(), 'Documents'); - default: - return process.env.XDG_DOCUMENTS_DIR || path.join(os.homedir(), 'Documents'); - } -} - -/** - * Gets the user's Desktop directory - */ -export function getDesktopPath(): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, 'Desktop') - : path.join(os.homedir(), 'Desktop'); - case 'darwin': - return path.join(os.homedir(), 'Desktop'); - default: - return process.env.XDG_DESKTOP_DIR || path.join(os.homedir(), 'Desktop'); - } -} -``` - -```typescript -// libs/platform/src/paths/path-validator.ts -import path from 'path'; -import { isAbsolutePath } from './path-resolver'; - -/** - * Characters that are invalid in file/directory names on Windows - */ -const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g; - -/** - * Reserved names on Windows (case-insensitive) - */ -const WINDOWS_RESERVED_NAMES = [ - 'CON', - 'PRN', - 'AUX', - 'NUL', - 'COM1', - 'COM2', - 'COM3', - 'COM4', - 'COM5', - 'COM6', - 'COM7', - 'COM8', - 'COM9', - 'LPT1', - 'LPT2', - 'LPT3', - 'LPT4', - 'LPT5', - 'LPT6', - 'LPT7', - 'LPT8', - 'LPT9', -]; - -export interface PathValidationResult { - valid: boolean; - errors: string[]; - sanitized?: string; -} - -/** - * Validates a filename for the current platform - */ -export function validateFilename(filename: string): PathValidationResult { - const errors: string[] = []; - - if (!filename || filename.trim().length === 0) { - return { valid: false, errors: ['Filename cannot be empty'] }; - } - - // Check for path separators (filename shouldn't be a path) - if (filename.includes('/') || filename.includes('\\')) { - errors.push('Filename cannot contain path separators'); - } - - // Platform-specific checks - if (process.platform === 'win32') { - if (WINDOWS_INVALID_CHARS.test(filename)) { - errors.push('Filename contains invalid characters for Windows'); - } - - const nameWithoutExt = filename.split('.')[0].toUpperCase(); - if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - errors.push(`"${nameWithoutExt}" is a reserved name on Windows`); - } - - if (filename.endsWith(' ') || filename.endsWith('.')) { - errors.push('Filename cannot end with a space or period on Windows'); - } - } - - // Check length - if (filename.length > 255) { - errors.push('Filename exceeds maximum length of 255 characters'); - } - - return { - valid: errors.length === 0, - errors, - sanitized: errors.length > 0 ? sanitizeFilename(filename) : filename, - }; -} - -/** - * Sanitizes a filename for cross-platform compatibility - */ -export function sanitizeFilename(filename: string): string { - let sanitized = filename.replace(WINDOWS_INVALID_CHARS, '_').replace(/[/\\]/g, '_').trim(); - - // Handle Windows reserved names - const nameWithoutExt = sanitized.split('.')[0].toUpperCase(); - if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - sanitized = '_' + sanitized; - } - - // Remove trailing spaces and periods (Windows) - sanitized = sanitized.replace(/[\s.]+$/, ''); - - // Ensure not empty - if (!sanitized) { - sanitized = 'unnamed'; - } - - // Truncate if too long - if (sanitized.length > 255) { - const ext = path.extname(sanitized); - const name = path.basename(sanitized, ext); - sanitized = name.slice(0, 255 - ext.length) + ext; - } - - return sanitized; -} - -/** - * Validates a full path for the current platform - */ -export function validatePath(inputPath: string): PathValidationResult { - const errors: string[] = []; - - if (!inputPath || inputPath.trim().length === 0) { - return { valid: false, errors: ['Path cannot be empty'] }; - } - - // Check total path length - const maxPathLength = process.platform === 'win32' ? 260 : 4096; - if (inputPath.length > maxPathLength) { - errors.push(`Path exceeds maximum length of ${maxPathLength} characters`); - } - - // Validate each segment - const segments = inputPath.split(/[/\\]/).filter(Boolean); - for (const segment of segments) { - // Skip drive letters on Windows - if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(segment)) { - continue; - } - - const segmentValidation = validateFilename(segment); - if (!segmentValidation.valid) { - errors.push(...segmentValidation.errors.map((e) => `Segment "${segment}": ${e}`)); - } - } - - return { - valid: errors.length === 0, - errors, - }; -} - -/** - * Checks if a path is within a base directory (prevents directory traversal) - */ -export function isPathWithin(childPath: string, parentPath: string): boolean { - const resolvedChild = path.resolve(childPath); - const resolvedParent = path.resolve(parentPath); - - return resolvedChild.startsWith(resolvedParent + path.sep) || resolvedChild === resolvedParent; -} -``` - -```typescript -// libs/platform/src/os/platform-info.ts -import os from 'os'; - -export type Platform = 'windows' | 'macos' | 'linux' | 'unknown'; -export type Architecture = 'x64' | 'arm64' | 'ia32' | 'unknown'; - -export interface PlatformInfo { - platform: Platform; - arch: Architecture; - release: string; - hostname: string; - username: string; - cpus: number; - totalMemory: number; - freeMemory: number; - isWsl: boolean; - isDocker: boolean; -} - -/** - * Gets the normalized platform name - */ -export function getPlatform(): Platform { - switch (process.platform) { - case 'win32': - return 'windows'; - case 'darwin': - return 'macos'; - case 'linux': - return 'linux'; - default: - return 'unknown'; - } -} - -/** - * Gets the normalized architecture - */ -export function getArchitecture(): Architecture { - switch (process.arch) { - case 'x64': - return 'x64'; - case 'arm64': - return 'arm64'; - case 'ia32': - return 'ia32'; - default: - return 'unknown'; - } -} - -/** - * Checks if running on Windows - */ -export function isWindows(): boolean { - return process.platform === 'win32'; -} - -/** - * Checks if running on macOS - */ -export function isMacOS(): boolean { - return process.platform === 'darwin'; -} - -/** - * Checks if running on Linux - */ -export function isLinux(): boolean { - return process.platform === 'linux'; -} - -/** - * Checks if running in WSL (Windows Subsystem for Linux) - */ -export function isWsl(): boolean { - if (process.platform !== 'linux') return false; - - try { - const release = os.release().toLowerCase(); - return release.includes('microsoft') || release.includes('wsl'); - } catch { - return false; - } -} - -/** - * Checks if running in Docker container - */ -export function isDocker(): boolean { - try { - const fs = require('fs'); - return ( - fs.existsSync('/.dockerenv') || - (fs.existsSync('/proc/1/cgroup') && - fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')) - ); - } catch { - return false; - } -} - -/** - * Gets comprehensive platform information - */ -export function getPlatformInfo(): PlatformInfo { - return { - platform: getPlatform(), - arch: getArchitecture(), - release: os.release(), - hostname: os.hostname(), - username: os.userInfo().username, - cpus: os.cpus().length, - totalMemory: os.totalmem(), - freeMemory: os.freemem(), - isWsl: isWsl(), - isDocker: isDocker(), - }; -} - -/** - * Gets the appropriate line ending for the current platform - */ -export function getLineEnding(): string { - return isWindows() ? '\r\n' : '\n'; -} - -/** - * Normalizes line endings to the current platform - */ -export function normalizeLineEndings(text: string): string { - const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - return isWindows() ? normalized.replace(/\n/g, '\r\n') : normalized; -} -``` - -```typescript -// libs/platform/src/os/shell-commands.ts -import { isWindows, isMacOS } from './platform-info'; - -export interface ShellCommand { - command: string; - args: string[]; -} - -/** - * Gets the appropriate command to open a file/URL with default application - */ -export function getOpenCommand(target: string): ShellCommand { - if (isWindows()) { - return { command: 'cmd', args: ['/c', 'start', '', target] }; - } else if (isMacOS()) { - return { command: 'open', args: [target] }; - } else { - return { command: 'xdg-open', args: [target] }; - } -} - -/** - * Gets the appropriate command to reveal a file in file manager - */ -export function getRevealCommand(filePath: string): ShellCommand { - if (isWindows()) { - return { command: 'explorer', args: ['/select,', filePath] }; - } else if (isMacOS()) { - return { command: 'open', args: ['-R', filePath] }; - } else { - // Linux: try multiple file managers - return { command: 'xdg-open', args: [require('path').dirname(filePath)] }; - } -} - -/** - * Gets the default shell for the current platform - */ -export function getDefaultShell(): string { - if (isWindows()) { - return process.env.COMSPEC || 'cmd.exe'; - } - return process.env.SHELL || '/bin/sh'; -} - -/** - * Gets shell-specific arguments for running a command - */ -export function getShellArgs(command: string): ShellCommand { - if (isWindows()) { - return { command: 'cmd.exe', args: ['/c', command] }; - } - return { command: '/bin/sh', args: ['-c', command] }; -} - -/** - * Escapes a string for safe use in shell commands - */ -export function escapeShellArg(arg: string): string { - if (isWindows()) { - // Windows cmd.exe escaping - return `"${arg.replace(/"/g, '""')}"`; - } - // POSIX shell escaping - return `'${arg.replace(/'/g, "'\\''")}'`; -} -``` - -```typescript -// libs/platform/src/os/env-utils.ts -import { isWindows } from './platform-info'; - -/** - * Gets an environment variable with a fallback - */ -export function getEnv(key: string, fallback?: string): string | undefined { - return process.env[key] ?? fallback; -} - -/** - * Gets an environment variable, throwing if not set - */ -export function requireEnv(key: string): string { - const value = process.env[key]; - if (value === undefined) { - throw new Error(`Required environment variable "${key}" is not set`); - } - return value; -} - -/** - * Parses a boolean environment variable - */ -export function getBoolEnv(key: string, fallback = false): boolean { - const value = process.env[key]; - if (value === undefined) return fallback; - return ['true', '1', 'yes', 'on'].includes(value.toLowerCase()); -} - -/** - * Parses a numeric environment variable - */ -export function getNumericEnv(key: string, fallback: number): number { - const value = process.env[key]; - if (value === undefined) return fallback; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? fallback : parsed; -} - -/** - * Expands environment variables in a string - * Supports both $VAR and ${VAR} syntax, plus %VAR% on Windows - */ -export function expandEnvVars(input: string): string { - let result = input; - - // Expand ${VAR} syntax - result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ''); - - // Expand $VAR syntax (not followed by another word char) - result = result.replace( - /\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])/g, - (_, name) => process.env[name] || '' - ); - - // Expand %VAR% syntax (Windows) - if (isWindows()) { - result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ''); - } - - return result; -} - -/** - * Gets the PATH environment variable as an array - */ -export function getPathEntries(): string[] { - const pathVar = process.env.PATH || process.env.Path || ''; - const separator = isWindows() ? ';' : ':'; - return pathVar.split(separator).filter(Boolean); -} - -/** - * Checks if a command is available in PATH - */ -export function isCommandInPath(command: string): boolean { - const pathEntries = getPathEntries(); - const extensions = isWindows() ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : ['']; - const path = require('path'); - const fs = require('fs'); - - for (const dir of pathEntries) { - for (const ext of extensions) { - const fullPath = path.join(dir, command + ext); - try { - fs.accessSync(fullPath, fs.constants.X_OK); - return true; - } catch { - // Continue searching - } - } - } - - return false; -} -``` - -```typescript -// libs/platform/src/fs/safe-fs.ts -import fs from 'fs'; -import path from 'path'; - -/** - * Safely reads a file, following symlinks but preventing escape from base directory - */ -export async function safeReadFile( - filePath: string, - basePath: string, - encoding: BufferEncoding = 'utf8' -): Promise { - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(basePath); - - // Resolve symlinks - const realPath = await fs.promises.realpath(resolvedPath); - const realBase = await fs.promises.realpath(resolvedBase); - - // Ensure resolved path is within base - if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) { - throw new Error(`Path "${filePath}" resolves outside of allowed directory`); - } - - return fs.promises.readFile(realPath, encoding); -} - -/** - * Safely writes a file, preventing writes outside base directory - */ -export async function safeWriteFile( - filePath: string, - basePath: string, - content: string -): Promise { - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(basePath); - - // Ensure path is within base before any symlink resolution - if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { - throw new Error(`Path "${filePath}" is outside of allowed directory`); - } - - // Check parent directory exists and is within base - const parentDir = path.dirname(resolvedPath); - - try { - const realParent = await fs.promises.realpath(parentDir); - const realBase = await fs.promises.realpath(resolvedBase); - - if (!realParent.startsWith(realBase + path.sep) && realParent !== realBase) { - throw new Error(`Parent directory resolves outside of allowed directory`); - } - } catch (error) { - // Parent doesn't exist, that's OK - we'll create it - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - - await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true }); - await fs.promises.writeFile(resolvedPath, content, 'utf8'); -} - -/** - * Checks if a path exists and is accessible - */ -export async function pathExists(filePath: string): Promise { - try { - await fs.promises.access(filePath); - return true; - } catch { - return false; - } -} - -/** - * Gets file stats, returning null if file doesn't exist - */ -export async function safeStat(filePath: string): Promise { - try { - return await fs.promises.stat(filePath); - } catch { - return null; - } -} - -/** - * Recursively removes a directory - */ -export async function removeDirectory(dirPath: string): Promise { - await fs.promises.rm(dirPath, { recursive: true, force: true }); -} - -/** - * Copies a file or directory - */ -export async function copy(src: string, dest: string): Promise { - const stats = await fs.promises.stat(src); - - if (stats.isDirectory()) { - await fs.promises.mkdir(dest, { recursive: true }); - const entries = await fs.promises.readdir(src, { withFileTypes: true }); - - for (const entry of entries) { - await copy(path.join(src, entry.name), path.join(dest, entry.name)); - } - } else { - await fs.promises.copyFile(src, dest); - } -} -``` - -```typescript -// libs/platform/src/index.ts -// Main barrel export - -// Path utilities -export * from './paths/path-resolver'; -export * from './paths/path-constants'; -export * from './paths/path-validator'; - -// OS utilities -export * from './os/platform-info'; -export * from './os/shell-commands'; -export * from './os/env-utils'; - -// File system utilities -export * from './fs/safe-fs'; -``` - -### @automaker/model-resolver - -Model string resolution shared between frontend and backend. - -``` -libs/model-resolver/ -├── src/ -│ ├── model-map.ts # CLAUDE_MODEL_MAP, DEFAULT_MODELS -│ ├── resolver.ts # resolveModelString, getEffectiveModel -│ └── index.ts -├── package.json -└── tsconfig.json -``` - -### @automaker/ipc-types - -IPC channel type definitions for type-safe Electron communication. - -``` -libs/ipc-types/ -├── src/ -│ ├── schema.ts # IPCSchema interface -│ ├── channels.ts # Channel constant enums -│ ├── helpers.ts # Type helper functions -│ └── index.ts -├── package.json -└── tsconfig.json -``` - ---- - -## Type-Safe Electron Implementation - -### IPC Schema Definition - -```typescript -// electron/ipc/ipc-schema.ts -import type { OpenDialogOptions, SaveDialogOptions } from 'electron'; - -// Dialog result types -export interface DialogResult { - canceled: boolean; - filePaths?: string[]; - filePath?: string; - data?: T; -} - -// App path names (from Electron) -export type AppPathName = - | 'home' - | 'appData' - | 'userData' - | 'sessionData' - | 'temp' - | 'exe' - | 'module' - | 'desktop' - | 'documents' - | 'downloads' - | 'music' - | 'pictures' - | 'videos' - | 'recent' - | 'logs' - | 'crashDumps'; - -// Complete IPC Schema with request/response types -export interface IPCSchema { - // Dialog operations - 'dialog:openDirectory': { - request: Partial; - response: DialogResult; - }; - 'dialog:openFile': { - request: Partial; - response: DialogResult; - }; - 'dialog:saveFile': { - request: Partial; - response: DialogResult; - }; - - // Shell operations - 'shell:openExternal': { - request: { url: string }; - response: { success: boolean; error?: string }; - }; - 'shell:openPath': { - request: { path: string }; - response: { success: boolean; error?: string }; - }; - - // App info - 'app:getPath': { - request: { name: AppPathName }; - response: string; - }; - 'app:getVersion': { - request: void; - response: string; - }; - 'app:isPackaged': { - request: void; - response: boolean; - }; - - // Server management - 'server:getUrl': { - request: void; - response: string; - }; - - // Connection test - ping: { - request: void; - response: 'pong'; - }; - - // Debug console - 'debug:log': { - request: { - level: DebugLogLevel; - category: DebugCategory; - message: string; - args: unknown[]; - }; - response: void; - }; -} - -export type DebugLogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success'; -export type DebugCategory = - | 'general' - | 'ipc' - | 'route' - | 'network' - | 'perf' - | 'state' - | 'lifecycle' - | 'updater'; - -// Type extractors -export type IPCChannel = keyof IPCSchema; -export type IPCRequest = IPCSchema[T]['request']; -export type IPCResponse = IPCSchema[T]['response']; -``` - -### Modular IPC Organization - -```typescript -// electron/ipc/dialog/dialog-channels.ts -export const DIALOG_CHANNELS = { - OPEN_DIRECTORY: 'dialog:openDirectory', - OPEN_FILE: 'dialog:openFile', - SAVE_FILE: 'dialog:saveFile', -} as const; - -// electron/ipc/dialog/dialog-context.ts -import { contextBridge, ipcRenderer } from 'electron'; -import { DIALOG_CHANNELS } from './dialog-channels'; -import type { IPCRequest, IPCResponse } from '../ipc-schema'; - -export function exposeDialogContext(): void { - contextBridge.exposeInMainWorld('dialogAPI', { - openDirectory: (options?: IPCRequest<'dialog:openDirectory'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_DIRECTORY, options), - - openFile: (options?: IPCRequest<'dialog:openFile'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_FILE, options), - - saveFile: (options?: IPCRequest<'dialog:saveFile'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.SAVE_FILE, options), - }); -} - -// electron/ipc/dialog/dialog-listeners.ts -import { ipcMain, dialog, BrowserWindow } from 'electron'; -import { DIALOG_CHANNELS } from './dialog-channels'; -import type { IPCRequest, IPCResponse } from '../ipc-schema'; -import { debugLog } from '../../helpers/debug-mode'; - -export function addDialogEventListeners(mainWindow: BrowserWindow): void { - ipcMain.handle( - DIALOG_CHANNELS.OPEN_DIRECTORY, - async (_, options: IPCRequest<'dialog:openDirectory'> = {}) => { - debugLog.ipc(`OPEN_DIRECTORY called with options: ${JSON.stringify(options)}`); - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory', 'createDirectory'], - ...options, - }); - - debugLog.ipc( - `OPEN_DIRECTORY result: canceled=${result.canceled}, paths=${result.filePaths.length}` - ); - - return { - canceled: result.canceled, - filePaths: result.filePaths, - } satisfies IPCResponse<'dialog:openDirectory'>; - } - ); - - ipcMain.handle( - DIALOG_CHANNELS.OPEN_FILE, - async (_, options: IPCRequest<'dialog:openFile'> = {}) => { - debugLog.ipc(`OPEN_FILE called`); - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openFile'], - ...options, - }); - - return { - canceled: result.canceled, - filePaths: result.filePaths, - } satisfies IPCResponse<'dialog:openFile'>; - } - ); - - ipcMain.handle( - DIALOG_CHANNELS.SAVE_FILE, - async (_, options: IPCRequest<'dialog:saveFile'> = {}) => { - debugLog.ipc(`SAVE_FILE called`); - - const result = await dialog.showSaveDialog(mainWindow, options); - - return { - canceled: result.canceled, - filePath: result.filePath, - } satisfies IPCResponse<'dialog:saveFile'>; - } - ); -} -``` - ---- - -## Components Refactoring - -### Priority Matrix - -| Priority | View | Lines | Action Required | -| -------- | -------------- | ----- | ------------------------------------------------------ | -| 🔴 P0 | spec-view | 1,230 | Create subfolder with components/, dialogs/, hooks/ | -| 🔴 P0 | analysis-view | 1,134 | Create subfolder with components/, dialogs/, hooks/ | -| 🔴 P0 | agent-view | 916 | Create subfolder, extract message list, input, sidebar | -| 🟡 P1 | welcome-view | 815 | Create subfolder, extract sections | -| 🟡 P1 | context-view | 735 | Create subfolder, extract components | -| 🟡 P1 | terminal-view | 697 | Expand existing subfolder | -| 🟡 P1 | interview-view | 637 | Create subfolder | -| 🟢 P2 | settings-view | 178 | Move dialogs from components/ to dialogs/ | -| ✅ Done | board-view | 685 | Already properly structured | -| ✅ Done | setup-view | 144 | Already properly structured | -| ✅ Done | profiles-view | 300 | Already properly structured | - -### Immediate Dialog Reorganization - -```bash -# Settings-view: Move dialogs to proper location -mv settings-view/components/keyboard-map-dialog.tsx → settings-view/dialogs/ -mv settings-view/components/delete-project-dialog.tsx → settings-view/dialogs/ - -# Root components: Organize global dialogs -mv components/dialogs/board-background-modal.tsx → board-view/dialogs/ - -# Agent-related dialogs: Move to agent-view -mv components/delete-session-dialog.tsx → agent-view/dialogs/ -mv components/delete-all-archived-sessions-dialog.tsx → agent-view/dialogs/ -``` - ---- - -## Web + Electron Dual Support - -### Platform Detection - -```typescript -// src/lib/platform.ts -export const isElectron = typeof window !== 'undefined' && 'electronAPI' in window; - -export const platform = { - isElectron, - isWeb: !isElectron, - isMac: isElectron ? window.electronAPI.platform === 'darwin' : false, - isWindows: isElectron ? window.electronAPI.platform === 'win32' : false, - isLinux: isElectron ? window.electronAPI.platform === 'linux' : false, -}; -``` - -### API Abstraction Layer - -```typescript -// src/lib/api/file-picker.ts -import { platform } from '../platform'; - -export interface FilePickerResult { - canceled: boolean; - paths: string[]; -} - -export async function pickDirectory(): Promise { - if (platform.isElectron) { - const result = await window.dialogAPI.openDirectory(); - return { canceled: result.canceled, paths: result.filePaths || [] }; - } - - // Web fallback using File System Access API - try { - const handle = await window.showDirectoryPicker(); - return { canceled: false, paths: [handle.name] }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return { canceled: true, paths: [] }; - } - throw error; - } -} - -export async function pickFile(options?: { - accept?: Record; -}): Promise { - if (platform.isElectron) { - const result = await window.dialogAPI.openFile({ - filters: options?.accept - ? Object.entries(options.accept).map(([name, extensions]) => ({ - name, - extensions, - })) - : undefined, - }); - return { canceled: result.canceled, paths: result.filePaths || [] }; - } - - // Web fallback - try { - const [handle] = await window.showOpenFilePicker({ - types: options?.accept - ? Object.entries(options.accept).map(([description, accept]) => ({ - description, - accept: { 'application/*': accept }, - })) - : undefined, - }); - return { canceled: false, paths: [handle.name] }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return { canceled: true, paths: [] }; - } - throw error; - } -} -``` - ---- - -## Migration Phases - -### Phase 1: Foundation (Week 1-2) - -**Goal**: Set up new build infrastructure without breaking existing functionality. - -- [x] Create `vite.config.mts` with electron plugins -- [x] Create `electron/tsconfig.json` for Electron TypeScript -- [x] Convert `electron/main.js` → `electron/main.ts` -- [x] Convert `electron/preload.js` → `electron/preload.ts` -- [ ] Implement IPC schema and type-safe handlers (deferred - using existing HTTP API) -- [x] Set up TanStack Router configuration -- [ ] Port debug console from starter template (deferred) -- [x] Create `index.html` for Vite entry - -**Deliverables**: - -- [x] Working Vite dev server -- [x] TypeScript Electron main process -- [ ] Debug console functional (deferred) - -### Phase 2: Core Migration (Week 3-4) - -**Goal**: Replace Next.js with Vite while maintaining feature parity. - -- [x] Create `src/renderer.tsx` entry point -- [x] Create `src/App.tsx` root component -- [x] Set up TanStack Router with file-based routes -- [x] Port all views to route files -- [x] Update environment variables (`NEXT_PUBLIC_*` → `VITE_*`) -- [x] Verify Zustand stores work unchanged -- [x] Verify HTTP API client works unchanged -- [x] Test Electron build -- [ ] Test Web build (needs verification) - -**Additional completed tasks**: - -- [x] Remove all "use client" directives (not needed in Vite) -- [x] Replace all `setCurrentView()` calls with TanStack Router `navigate()` -- [x] Rename `apps/app` to `apps/ui` -- [x] Update package.json scripts -- [x] Configure memory history for Electron (no URL bar) -- [x] Fix ES module imports (replace `require()` with `import`) -- [x] Remove PostCSS config (using `@tailwindcss/vite` plugin) - -**Deliverables**: - -- [x] All views accessible via TanStack Router -- [x] Electron build functional -- [ ] Web build functional (needs testing) -- [x] No regression in existing functionality - -### Phase 3: Component Refactoring (Week 5-7) - -**Goal**: Refactor large view files to follow folder-pattern.md. - -- [ ] Refactor `spec-view.tsx` (1,230 lines) -- [ ] Refactor `analysis-view.tsx` (1,134 lines) -- [ ] Refactor `agent-view.tsx` (916 lines) -- [ ] Refactor `welcome-view.tsx` (815 lines) -- [ ] Refactor `context-view.tsx` (735 lines) -- [ ] Refactor `terminal-view.tsx` (697 lines) -- [ ] Refactor `interview-view.tsx` (637 lines) -- [ ] Reorganize `settings-view` dialogs - -**Deliverables**: - -- All views under 500 lines -- Consistent folder structure across all views -- Barrel exports for all component folders - -### Phase 4: Package Extraction (Week 8) - -**Goal**: Create shared packages for better modularity. - -- [ ] Create `libs/types/` package -- [ ] Create `libs/utils/` package -- [ ] Create `libs/platform/` package -- [ ] Create `libs/model-resolver/` package -- [ ] Create `libs/ipc-types/` package -- [ ] Update imports across apps - -**Deliverables**: - -- 5 new shared packages -- No code duplication between apps -- Clean dependency graph - -### Phase 5: Polish & Testing (Week 9-10) - -**Goal**: Ensure production readiness. - -- [ ] Write E2E tests with Playwright -- [ ] Performance benchmarking -- [ ] Bundle size optimization -- [ ] Documentation updates -- [ ] CI/CD pipeline updates -- [ ] Remove Next.js dependencies - -**Deliverables**: - -- Comprehensive test coverage -- Performance metrics documentation -- Updated CI/CD configuration -- Clean package.json (no Next.js) - ---- - -## Expected Benefits - -### Developer Experience - -| Aspect | Before | After | -| ---------------------- | ------------- | ------------------ | -| Dev server startup | 8-15 seconds | 1-3 seconds | -| Hot Module Replacement | 500ms-2s | 50-100ms | -| TypeScript in Electron | Not supported | Full support | -| Debug tooling | Limited | Full debug console | -| Build times | 45-90 seconds | 15-30 seconds | - -### Code Quality - -| Aspect | Before | After | -| ---------------------- | ------------ | --------------------- | -| Electron type safety | 0% | 100% | -| Component organization | Inconsistent | Standardized | -| Code sharing | None | 5 shared packages | -| Path handling | Ad-hoc | Centralized utilities | - -### Bundle Size - -| Aspect | Before | After | -| ------------------ | ------- | ------- | -| Next.js runtime | ~200KB | 0KB | -| Framework overhead | High | Minimal | -| Tree shaking | Limited | Full | - ---- - -## Risk Mitigation - -### Rollback Strategy - -1. **Branch-based development**: All work on feature branch -2. **Parallel running**: Keep Next.js functional until migration complete -3. **Feature flags**: Toggle between old/new implementations -4. **Comprehensive testing**: E2E tests before/after comparison - -### Known Challenges - -| Challenge | Mitigation | -| --------------------- | ------------------------------------------------ | -| Route migration | TanStack Router has similar file-based routing | -| Environment variables | Simple search/replace (`NEXT_PUBLIC_` → `VITE_`) | -| Build configuration | Reference electron-starter-template | -| SSR considerations | N/A - we don't use SSR | - -### Testing Strategy - -1. **Unit tests**: Vitest for component/utility testing -2. **Integration tests**: Test IPC communication -3. **E2E tests**: Playwright for full application testing -4. **Manual testing**: QA checklist for each view - ---- - -## Appendix: Vite Configuration Reference - -```typescript -// vite.config.mts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import electron from 'vite-plugin-electron'; -import renderer from 'vite-plugin-electron-renderer'; -import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; -import tailwindcss from '@tailwindcss/vite'; -import path from 'path'; - -export default defineConfig({ - plugins: [ - react({ - babel: { - plugins: [['babel-plugin-react-compiler', {}]], - }, - }), - TanStackRouterVite({ - routesDirectory: './src/routes', - generatedRouteTree: './src/routeTree.gen.ts', - autoCodeSplitting: true, - }), - tailwindcss(), - electron([ - { - entry: 'electron/main.ts', - vite: { - build: { - outDir: 'dist-electron', - rollupOptions: { - external: ['electron'], - }, - }, - }, - }, - { - entry: 'electron/preload.ts', - onstart: ({ reload }) => reload(), - vite: { - build: { - outDir: 'dist-electron', - rollupOptions: { - external: ['electron'], - }, - }, - }, - }, - ]), - renderer(), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - '@electron': path.resolve(__dirname, 'electron'), - }, - }, - build: { - outDir: 'dist', - }, -}); -``` - ---- - -## Document History - -| Version | Date | Author | Changes | -| ------- | -------- | ------ | ------------------------------------------------------------------------------------- | -| 1.0 | Dec 2025 | Team | Initial migration plan | -| 1.1 | Dec 2025 | Team | Phase 1 & 2 complete. Updated checkboxes, added completed tasks, noted deferred items | - ---- - -**Next Steps**: - -1. ~~Review and approve this plan~~ ✅ -2. ~~Wait for `feature/worktrees` branch merge~~ ✅ -3. ~~Create migration branch~~ ✅ (refactor/frontend) -4. ~~Complete Phase 1 implementation~~ ✅ -5. ~~Complete Phase 2 implementation~~ ✅ -6. **Current**: Verify web build works, then begin Phase 3 (Component Refactoring) -7. Consider implementing deferred items: Debug console, IPC schema diff --git a/init.mjs b/init.mjs index 4fcf8b08..68387ba5 100644 --- a/init.mjs +++ b/init.mjs @@ -4,10 +4,14 @@ * Automaker - Cross-Platform Development Environment Setup and Launch Script * * This script works on Windows, macOS, and Linux. + * + * SECURITY NOTE: This script uses a restricted fs wrapper that only allows + * operations within the script's directory (__dirname). This is a standalone + * launch script that runs before the platform library is available. */ import { execSync } from 'child_process'; -import fs from 'fs'; +import fsNative from 'fs'; import http from 'http'; import path from 'path'; import readline from 'readline'; @@ -21,6 +25,43 @@ const crossSpawn = require('cross-spawn'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// ============================================================================= +// Restricted fs wrapper - only allows operations within __dirname +// ============================================================================= + +/** + * Validate that a path is within the script's directory + * @param {string} targetPath - Path to validate + * @returns {string} - Resolved path if valid + * @throws {Error} - If path is outside __dirname + */ +function validateScriptPath(targetPath) { + const resolved = path.resolve(__dirname, targetPath); + const normalizedBase = path.resolve(__dirname); + if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { + throw new Error(`[init.mjs] Security: Path access denied outside script directory: ${targetPath}`); + } + return resolved; +} + +/** + * Restricted fs operations - only within script directory + */ +const fs = { + existsSync(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.existsSync(validated); + }, + mkdirSync(targetPath, options) { + const validated = validateScriptPath(targetPath); + return fsNative.mkdirSync(validated, options); + }, + createWriteStream(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.createWriteStream(validated); + }, +}; + // Colors for terminal output (works on modern terminals including Windows) const colors = { green: '\x1b[0;32m', diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index eba84101..c30f7d73 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -55,3 +55,63 @@ export { type NodeFinderResult, type NodeFinderOptions, } from './node-finder.js'; + +// System paths for tool detection (GitHub CLI, Claude CLI, Node.js, etc.) +export * as systemPaths from './system-paths.js'; +export { + // CLI tool paths + getGitHubCliPaths, + getClaudeCliPaths, + getClaudeConfigDir, + getClaudeCredentialPaths, + getClaudeSettingsPath, + getClaudeStatsCachePath, + getClaudeProjectsDir, + getShellPaths, + getExtendedPath, + // Node.js paths + getNvmPaths, + getFnmPaths, + getNodeSystemPaths, + getScoopNodePath, + getChocolateyNodePath, + getWslVersionPath, + // System path operations + systemPathExists, + systemPathAccess, + systemPathIsExecutable, + systemPathReadFile, + systemPathReadFileSync, + systemPathWriteFileSync, + systemPathReaddir, + systemPathReaddirSync, + systemPathStatSync, + systemPathStat, + isAllowedSystemPath, + // High-level methods + findFirstExistingPath, + findGitHubCliPath, + findClaudeCliPath, + getClaudeAuthIndicators, + type ClaudeAuthIndicators, + // Electron userData operations + setElectronUserDataPath, + getElectronUserDataPath, + isElectronUserDataPath, + electronUserDataReadFileSync, + electronUserDataWriteFileSync, + electronUserDataExists, + // Script directory operations + setScriptBaseDir, + getScriptBaseDir, + scriptDirExists, + scriptDirMkdirSync, + scriptDirCreateWriteStream, + // Electron app bundle operations + setElectronAppPaths, + electronAppExists, + electronAppReadFileSync, + electronAppStatSync, + electronAppStat, + electronAppReadFile, +} from './system-paths.js'; diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index ed2cbb03..cb771a00 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -3,12 +3,25 @@ * * Handles finding Node.js when the app is launched from desktop environments * (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited. + * + * Uses centralized system-paths module for all file system access. */ import { execSync } from 'child_process'; -import fs from 'fs'; import path from 'path'; import os from 'os'; +import { + systemPathExists, + systemPathIsExecutable, + systemPathReaddirSync, + systemPathReadFileSync, + getNvmPaths, + getFnmPaths, + getNodeSystemPaths, + getScoopNodePath, + getChocolateyNodePath, + getWslVersionPath, +} from './system-paths.js'; /** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */ const VERSION_DIR_PATTERN = /^v?\d+/; @@ -45,18 +58,11 @@ export interface NodeFinderOptions { /** * Check if a file exists and is executable - * On Windows, only checks existence (X_OK is not meaningful) + * Uses centralized systemPathIsExecutable for path validation */ function isExecutable(filePath: string): boolean { try { - if (process.platform === 'win32') { - // On Windows, fs.constants.X_OK is not meaningful - just check existence - fs.accessSync(filePath, fs.constants.F_OK); - } else { - // On Unix-like systems, check for execute permission - fs.accessSync(filePath, fs.constants.X_OK); - } - return true; + return systemPathIsExecutable(filePath); } catch { return false; } @@ -71,11 +77,14 @@ function findNodeFromVersionManager( basePath: string, binSubpath: string = 'bin/node' ): string | null { - if (!fs.existsSync(basePath)) return null; + try { + if (!systemPathExists(basePath)) return null; + } catch { + return null; + } try { - const allVersions = fs - .readdirSync(basePath) + const allVersions = systemPathReaddirSync(basePath) .filter((v) => VERSION_DIR_PATTERN.test(v)) // Semantic version sort - newest first using localeCompare with numeric option .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); @@ -101,39 +110,30 @@ function findNodeFromVersionManager( /** * Find Node.js on macOS */ -function findNodeMacOS(homeDir: string): NodeFinderResult | null { - // Check Homebrew paths in order of preference - const homebrewPaths = [ - // Apple Silicon - '/opt/homebrew/bin/node', - // Intel - '/usr/local/bin/node', - ]; - - for (const nodePath of homebrewPaths) { +function findNodeMacOS(_homeDir: string): NodeFinderResult | null { + // Check system paths (Homebrew, system) + const systemPaths = getNodeSystemPaths(); + for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { - return { nodePath, source: 'homebrew' }; + // Determine source based on path + if (nodePath.includes('homebrew') || nodePath === '/usr/local/bin/node') { + return { nodePath, source: 'homebrew' }; + } + return { nodePath, source: 'system' }; } } - // System Node - if (isExecutable('/usr/bin/node')) { - return { nodePath: '/usr/bin/node', source: 'system' }; - } - // NVM installation - const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); - const nvmNode = findNodeFromVersionManager(nvmPath); - if (nvmNode) { - return { nodePath: nvmNode, source: 'nvm' }; + const nvmPaths = getNvmPaths(); + for (const nvmPath of nvmPaths) { + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } } - // fnm installation (multiple possible locations) - const fnmPaths = [ - path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), - path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'), - ]; - + // fnm installation + const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath); if (fnmNode) { @@ -147,15 +147,9 @@ function findNodeMacOS(homeDir: string): NodeFinderResult | null { /** * Find Node.js on Linux */ -function findNodeLinux(homeDir: string): NodeFinderResult | null { - // Common Linux paths - const systemPaths = [ - '/usr/bin/node', - '/usr/local/bin/node', - // Snap installation - '/snap/bin/node', - ]; - +function findNodeLinux(_homeDir: string): NodeFinderResult | null { + // Check system paths + const systemPaths = getNodeSystemPaths(); for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { return { nodePath, source: 'system' }; @@ -163,18 +157,16 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null { } // NVM installation - const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); - const nvmNode = findNodeFromVersionManager(nvmPath); - if (nvmNode) { - return { nodePath: nvmNode, source: 'nvm' }; + const nvmPaths = getNvmPaths(); + for (const nvmPath of nvmPaths) { + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } } // fnm installation - const fnmPaths = [ - path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), - path.join(homeDir, '.fnm', 'node-versions'), - ]; - + const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath); if (fnmNode) { @@ -188,40 +180,27 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null { /** * Find Node.js on Windows */ -function findNodeWindows(homeDir: string): NodeFinderResult | null { +function findNodeWindows(_homeDir: string): NodeFinderResult | null { // Program Files paths - const programFilesPaths = [ - path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'), - path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'), - ]; - - for (const nodePath of programFilesPaths) { + const systemPaths = getNodeSystemPaths(); + for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { return { nodePath, source: 'program-files' }; } } // NVM for Windows - const nvmWindowsPath = path.join( - process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), - 'nvm' - ); - const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe'); - if (nvmNode) { - return { nodePath: nvmNode, source: 'nvm-windows' }; + const nvmPaths = getNvmPaths(); + for (const nvmPath of nvmPaths) { + const nvmNode = findNodeFromVersionManager(nvmPath, 'node.exe'); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm-windows' }; + } } - // fnm on Windows (prioritize canonical installation path over shell shims) - const fnmWindowsPaths = [ - path.join(homeDir, '.fnm', 'node-versions'), - path.join( - process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), - 'fnm', - 'node-versions' - ), - ]; - - for (const fnmBasePath of fnmWindowsPaths) { + // fnm on Windows + const fnmPaths = getFnmPaths(); + for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe'); if (fnmNode) { return { nodePath: fnmNode, source: 'fnm' }; @@ -229,17 +208,13 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { } // Scoop installation - const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); + const scoopPath = getScoopNodePath(); if (isExecutable(scoopPath)) { return { nodePath: scoopPath, source: 'scoop' }; } // Chocolatey installation - const chocoPath = path.join( - process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', - 'bin', - 'node.exe' - ); + const chocoPath = getChocolateyNodePath(); if (isExecutable(chocoPath)) { return { nodePath: chocoPath, source: 'chocolatey' }; } diff --git a/libs/platform/src/secure-fs.ts b/libs/platform/src/secure-fs.ts index b5b716cb..e324b0c3 100644 --- a/libs/platform/src/secure-fs.ts +++ b/libs/platform/src/secure-fs.ts @@ -11,7 +11,7 @@ */ import fs from 'fs/promises'; -import type { Dirent } from 'fs'; +import fsSync, { type Dirent, type Stats } from 'fs'; import path from 'path'; import pLimit from 'p-limit'; import { validatePath } from './security.js'; @@ -305,3 +305,323 @@ export function joinPath(...pathSegments: string[]): string { export function resolvePath(...pathSegments: string[]): string { return path.resolve(...pathSegments); } + +// ============================================================================= +// Synchronous File System Methods +// ============================================================================= + +/** + * Options for writeFileSync + */ +export interface WriteFileSyncOptions { + encoding?: BufferEncoding; + mode?: number; + flag?: string; +} + +/** + * Synchronous wrapper around fs.existsSync that validates path first + */ +export function existsSync(filePath: string): boolean { + const validatedPath = validatePath(filePath); + return fsSync.existsSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.readFileSync that validates path first + */ +export function readFileSync(filePath: string, encoding?: BufferEncoding): string | Buffer { + const validatedPath = validatePath(filePath); + if (encoding) { + return fsSync.readFileSync(validatedPath, encoding); + } + return fsSync.readFileSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.writeFileSync that validates path first + */ +export function writeFileSync( + filePath: string, + data: string | Buffer, + options?: WriteFileSyncOptions +): void { + const validatedPath = validatePath(filePath); + fsSync.writeFileSync(validatedPath, data, options); +} + +/** + * Synchronous wrapper around fs.mkdirSync that validates path first + */ +export function mkdirSync( + dirPath: string, + options?: { recursive?: boolean; mode?: number } +): string | undefined { + const validatedPath = validatePath(dirPath); + return fsSync.mkdirSync(validatedPath, options); +} + +/** + * Synchronous wrapper around fs.readdirSync that validates path first + */ +export function readdirSync(dirPath: string, options?: { withFileTypes?: false }): string[]; +export function readdirSync(dirPath: string, options: { withFileTypes: true }): Dirent[]; +export function readdirSync( + dirPath: string, + options?: { withFileTypes?: boolean } +): string[] | Dirent[] { + const validatedPath = validatePath(dirPath); + if (options?.withFileTypes === true) { + return fsSync.readdirSync(validatedPath, { withFileTypes: true }); + } + return fsSync.readdirSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.statSync that validates path first + */ +export function statSync(filePath: string): Stats { + const validatedPath = validatePath(filePath); + return fsSync.statSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.accessSync that validates path first + */ +export function accessSync(filePath: string, mode?: number): void { + const validatedPath = validatePath(filePath); + fsSync.accessSync(validatedPath, mode); +} + +/** + * Synchronous wrapper around fs.unlinkSync that validates path first + */ +export function unlinkSync(filePath: string): void { + const validatedPath = validatePath(filePath); + fsSync.unlinkSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.rmSync that validates path first + */ +export function rmSync(filePath: string, options?: { recursive?: boolean; force?: boolean }): void { + const validatedPath = validatePath(filePath); + fsSync.rmSync(validatedPath, options); +} + +// ============================================================================= +// Environment File Operations +// ============================================================================= + +/** + * Read and parse an .env file from a validated path + * Returns a record of key-value pairs + */ +export async function readEnvFile(envPath: string): Promise> { + const validatedPath = validatePath(envPath); + try { + const content = await executeWithRetry( + () => fs.readFile(validatedPath, 'utf-8'), + `readEnvFile(${envPath})` + ); + return parseEnvContent(content); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } +} + +/** + * Read and parse an .env file synchronously from a validated path + */ +export function readEnvFileSync(envPath: string): Record { + const validatedPath = validatePath(envPath); + try { + const content = fsSync.readFileSync(validatedPath, 'utf-8'); + return parseEnvContent(content); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } +} + +/** + * Parse .env file content into a record + */ +function parseEnvContent(content: string): Record { + const result: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const equalIndex = trimmed.indexOf('='); + if (equalIndex > 0) { + const key = trimmed.slice(0, equalIndex).trim(); + const value = trimmed.slice(equalIndex + 1).trim(); + result[key] = value; + } + } + + return result; +} + +/** + * Write or update a key-value pair in an .env file + * Preserves existing content and comments + */ +export async function writeEnvKey(envPath: string, key: string, value: string): Promise { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = await executeWithRetry( + () => fs.readFile(validatedPath, 'utf-8'), + `readFile(${envPath})` + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + // File doesn't exist, will create new one + } + + const newContent = updateEnvContent(content, key, value); + await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`); +} + +/** + * Write or update a key-value pair in an .env file (synchronous) + */ +export function writeEnvKeySync(envPath: string, key: string, value: string): void { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = fsSync.readFileSync(validatedPath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + // File doesn't exist, will create new one + } + + const newContent = updateEnvContent(content, key, value); + fsSync.writeFileSync(validatedPath, newContent); +} + +/** + * Remove a key from an .env file + */ +export async function removeEnvKey(envPath: string, key: string): Promise { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = await executeWithRetry( + () => fs.readFile(validatedPath, 'utf-8'), + `readFile(${envPath})` + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; // File doesn't exist, nothing to remove + } + throw error; + } + + const newContent = removeEnvKeyFromContent(content, key); + await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`); +} + +/** + * Remove a key from an .env file (synchronous) + */ +export function removeEnvKeySync(envPath: string, key: string): void { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = fsSync.readFileSync(validatedPath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; // File doesn't exist, nothing to remove + } + throw error; + } + + const newContent = removeEnvKeyFromContent(content, key); + fsSync.writeFileSync(validatedPath, newContent); +} + +/** + * Update .env content with a new key-value pair + */ +function updateEnvContent(content: string, key: string, value: string): string { + const lines = content.split('\n'); + const keyRegex = new RegExp(`^${escapeRegex(key)}=`); + let found = false; + + const newLines = lines.map((line) => { + if (keyRegex.test(line.trim())) { + found = true; + return `${key}=${value}`; + } + return line; + }); + + if (!found) { + // Add the key at the end + if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') { + newLines.push(`${key}=${value}`); + } else { + // Replace last empty line or add to empty file + if (newLines.length === 0 || (newLines.length === 1 && newLines[0] === '')) { + newLines[0] = `${key}=${value}`; + } else { + newLines[newLines.length - 1] = `${key}=${value}`; + } + } + } + + // Ensure file ends with newline + let result = newLines.join('\n'); + if (!result.endsWith('\n')) { + result += '\n'; + } + return result; +} + +/** + * Remove a key from .env content + */ +function removeEnvKeyFromContent(content: string, key: string): string { + const lines = content.split('\n'); + const keyRegex = new RegExp(`^${escapeRegex(key)}=`); + const newLines = lines.filter((line) => !keyRegex.test(line.trim())); + + // Remove trailing empty lines + while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') { + newLines.pop(); + } + + // Ensure file ends with newline if there's content + let result = newLines.join('\n'); + if (result.length > 0 && !result.endsWith('\n')) { + result += '\n'; + } + return result; +} + +/** + * Escape special regex characters in a string + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts new file mode 100644 index 00000000..30a8aef8 --- /dev/null +++ b/libs/platform/src/system-paths.ts @@ -0,0 +1,787 @@ +/** + * System Paths Configuration + * + * Centralized configuration for ALL system paths that automaker needs to access + * outside of the ALLOWED_ROOT_DIRECTORY. These are well-known system paths for + * tools like GitHub CLI, Claude CLI, Node.js version managers, etc. + * + * ALL file system access must go through this module or secureFs. + * Direct fs imports are NOT allowed anywhere else in the codebase. + * + * Categories of system paths: + * 1. CLI Tools: GitHub CLI, Claude CLI + * 2. Version Managers: NVM, fnm, Volta + * 3. Shells: /bin/zsh, /bin/bash, PowerShell + * 4. Electron userData: API keys, window bounds, app settings + * 5. Script directories: node_modules, logs (relative to script) + */ + +import os from 'os'; +import path from 'path'; +import fsSync from 'fs'; +import fs from 'fs/promises'; + +// ============================================================================= +// System Tool Path Definitions +// ============================================================================= + +/** + * Get common paths where GitHub CLI might be installed + */ +export function getGitHubCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + + if (isWindows) { + return [ + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'), + path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'), + ].filter(Boolean); + } + + return [ + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + path.join(os.homedir(), '.local', 'bin', 'gh'), + '/home/linuxbrew/.linuxbrew/bin/gh', + ]; +} + +/** + * Get common paths where Claude CLI might be installed + */ +export function getClaudeCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + + if (isWindows) { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return [ + path.join(os.homedir(), '.local', 'bin', 'claude.exe'), + path.join(appData, 'npm', 'claude.cmd'), + path.join(appData, 'npm', 'claude'), + path.join(appData, '.npm-global', 'bin', 'claude.cmd'), + path.join(appData, '.npm-global', 'bin', 'claude'), + ]; + } + + return [ + path.join(os.homedir(), '.local', 'bin', 'claude'), + path.join(os.homedir(), '.claude', 'local', 'claude'), + '/usr/local/bin/claude', + path.join(os.homedir(), '.npm-global', 'bin', 'claude'), + ]; +} + +/** + * Get the Claude configuration directory path + */ +export function getClaudeConfigDir(): string { + return path.join(os.homedir(), '.claude'); +} + +/** + * Get paths to Claude credential files + */ +export function getClaudeCredentialPaths(): string[] { + const claudeDir = getClaudeConfigDir(); + return [path.join(claudeDir, '.credentials.json'), path.join(claudeDir, 'credentials.json')]; +} + +/** + * Get path to Claude settings file + */ +export function getClaudeSettingsPath(): string { + return path.join(getClaudeConfigDir(), 'settings.json'); +} + +/** + * Get path to Claude stats cache file + */ +export function getClaudeStatsCachePath(): string { + return path.join(getClaudeConfigDir(), 'stats-cache.json'); +} + +/** + * Get path to Claude projects/sessions directory + */ +export function getClaudeProjectsDir(): string { + return path.join(getClaudeConfigDir(), 'projects'); +} + +/** + * Get common shell paths for shell detection + */ +export function getShellPaths(): string[] { + if (process.platform === 'win32') { + return [ + process.env.COMSPEC || 'cmd.exe', + 'powershell.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + ]; + } + + return ['/bin/zsh', '/bin/bash', '/bin/sh']; +} + +// ============================================================================= +// Node.js Version Manager Paths +// ============================================================================= + +/** + * Get NVM installation paths + */ +export function getNvmPaths(): string[] { + const homeDir = os.homedir(); + + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); + return [path.join(appData, 'nvm')]; + } + + return [path.join(homeDir, '.nvm', 'versions', 'node')]; +} + +/** + * Get fnm installation paths + */ +export function getFnmPaths(): string[] { + const homeDir = os.homedir(); + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + return [ + path.join(homeDir, '.fnm', 'node-versions'), + path.join(localAppData, 'fnm', 'node-versions'), + ]; + } + + if (process.platform === 'darwin') { + return [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'), + ]; + } + + return [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, '.fnm', 'node-versions'), + ]; +} + +/** + * Get common Node.js installation paths (not version managers) + */ +export function getNodeSystemPaths(): string[] { + if (process.platform === 'win32') { + return [ + path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'), + path.join( + process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', + 'nodejs', + 'node.exe' + ), + ]; + } + + if (process.platform === 'darwin') { + return ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']; + } + + // Linux + return ['/usr/bin/node', '/usr/local/bin/node', '/snap/bin/node']; +} + +/** + * Get Scoop installation path for Node.js (Windows) + */ +export function getScoopNodePath(): string { + return path.join(os.homedir(), 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); +} + +/** + * Get Chocolatey installation path for Node.js (Windows) + */ +export function getChocolateyNodePath(): string { + return path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'bin', + 'node.exe' + ); +} + +/** + * Get WSL detection path + */ +export function getWslVersionPath(): string { + return '/proc/version'; +} + +/** + * Extended PATH environment for finding system tools + */ +export function getExtendedPath(): string { + const paths = [ + process.env.PATH, + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin`, + ]; + + return paths.filter(Boolean).join(process.platform === 'win32' ? ';' : ':'); +} + +// ============================================================================= +// System Path Access Methods (Unconstrained - only for system tool detection) +// ============================================================================= + +/** + * Check if a file exists at a system path (synchronous) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + * Only use for checking system tool installation paths. + */ +export function systemPathExists(filePath: string): boolean { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fsSync.existsSync(filePath); +} + +/** + * Check if a file is accessible at a system path (async) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + * Only use for checking system tool installation paths. + */ +export async function systemPathAccess(filePath: string): Promise { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Check if a file has execute permission (synchronous) + * On Windows, only checks existence (X_OK is not meaningful) + */ +export function systemPathIsExecutable(filePath: string): boolean { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + try { + if (process.platform === 'win32') { + fsSync.accessSync(filePath, fsSync.constants.F_OK); + } else { + fsSync.accessSync(filePath, fsSync.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +/** + * Read a file from an allowed system path (async) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + * Only use for reading Claude config files and similar system configs. + */ +export async function systemPathReadFile( + filePath: string, + encoding: BufferEncoding = 'utf-8' +): Promise { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fs.readFile(filePath, encoding); +} + +/** + * Read a file from an allowed system path (synchronous) + */ +export function systemPathReadFileSync( + filePath: string, + encoding: BufferEncoding = 'utf-8' +): string { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fsSync.readFileSync(filePath, encoding); +} + +/** + * Write a file to an allowed system path (synchronous) + */ +export function systemPathWriteFileSync( + filePath: string, + data: string, + options?: { encoding?: BufferEncoding; mode?: number } +): void { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + fsSync.writeFileSync(filePath, data, options); +} + +/** + * Read directory contents from an allowed system path (async) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + */ +export async function systemPathReaddir(dirPath: string): Promise { + if (!isAllowedSystemPath(dirPath)) { + throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`); + } + return fs.readdir(dirPath); +} + +/** + * Read directory contents from an allowed system path (synchronous) + */ +export function systemPathReaddirSync(dirPath: string): string[] { + if (!isAllowedSystemPath(dirPath)) { + throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`); + } + return fsSync.readdirSync(dirPath); +} + +/** + * Get file stats from a system path (synchronous) + */ +export function systemPathStatSync(filePath: string): fsSync.Stats { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fsSync.statSync(filePath); +} + +/** + * Get file stats from a system path (async) + */ +export async function systemPathStat(filePath: string): Promise { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fs.stat(filePath); +} + +// ============================================================================= +// Path Validation +// ============================================================================= + +/** + * All paths that are allowed for system tool detection + */ +function getAllAllowedSystemPaths(): string[] { + return [ + // GitHub CLI paths + ...getGitHubCliPaths(), + // Claude CLI paths + ...getClaudeCliPaths(), + // Claude config directory and files + getClaudeConfigDir(), + ...getClaudeCredentialPaths(), + getClaudeSettingsPath(), + getClaudeStatsCachePath(), + getClaudeProjectsDir(), + // Shell paths + ...getShellPaths(), + // Node.js system paths + ...getNodeSystemPaths(), + getScoopNodePath(), + getChocolateyNodePath(), + // WSL detection + getWslVersionPath(), + ]; +} + +/** + * Get all allowed directories (for recursive access) + */ +function getAllAllowedSystemDirs(): string[] { + return [ + // Claude config + getClaudeConfigDir(), + getClaudeProjectsDir(), + // Version managers (need recursive access for version directories) + ...getNvmPaths(), + ...getFnmPaths(), + ]; +} + +/** + * Check if a path is an allowed system path + * Paths must either be exactly in the allowed list, or be inside an allowed directory + */ +export function isAllowedSystemPath(filePath: string): boolean { + const normalizedPath = path.resolve(filePath); + const allowedPaths = getAllAllowedSystemPaths(); + + // Check for exact match + if (allowedPaths.includes(normalizedPath)) { + return true; + } + + // Check if the path is inside an allowed directory + const allowedDirs = getAllAllowedSystemDirs(); + + for (const allowedDir of allowedDirs) { + const normalizedAllowedDir = path.resolve(allowedDir); + // Check if path is exactly the allowed dir or inside it + if ( + normalizedPath === normalizedAllowedDir || + normalizedPath.startsWith(normalizedAllowedDir + path.sep) + ) { + return true; + } + } + + return false; +} + +// ============================================================================= +// Electron userData Operations +// ============================================================================= + +// Store the Electron userData path (set by Electron main process) +let electronUserDataPath: string | null = null; + +/** + * Set the Electron userData path (called from Electron main process) + */ +export function setElectronUserDataPath(userDataPath: string): void { + electronUserDataPath = userDataPath; +} + +/** + * Get the Electron userData path + */ +export function getElectronUserDataPath(): string | null { + return electronUserDataPath; +} + +/** + * Check if a path is within the Electron userData directory + */ +export function isElectronUserDataPath(filePath: string): boolean { + if (!electronUserDataPath) return false; + const normalizedPath = path.resolve(filePath); + const normalizedUserData = path.resolve(electronUserDataPath); + return ( + normalizedPath === normalizedUserData || + normalizedPath.startsWith(normalizedUserData + path.sep) + ); +} + +/** + * Read a file from Electron userData directory + */ +export function electronUserDataReadFileSync( + relativePath: string, + encoding: BufferEncoding = 'utf-8' +): string { + if (!electronUserDataPath) { + throw new Error('[SystemPaths] Electron userData path not initialized'); + } + const fullPath = path.join(electronUserDataPath, relativePath); + return fsSync.readFileSync(fullPath, encoding); +} + +/** + * Write a file to Electron userData directory + */ +export function electronUserDataWriteFileSync( + relativePath: string, + data: string, + options?: { encoding?: BufferEncoding; mode?: number } +): void { + if (!electronUserDataPath) { + throw new Error('[SystemPaths] Electron userData path not initialized'); + } + const fullPath = path.join(electronUserDataPath, relativePath); + fsSync.writeFileSync(fullPath, data, options); +} + +/** + * Check if a file exists in Electron userData directory + */ +export function electronUserDataExists(relativePath: string): boolean { + if (!electronUserDataPath) return false; + const fullPath = path.join(electronUserDataPath, relativePath); + return fsSync.existsSync(fullPath); +} + +// ============================================================================= +// Script Directory Operations (for init.mjs and similar) +// ============================================================================= + +// Store the script's base directory +let scriptBaseDir: string | null = null; + +/** + * Set the script base directory + */ +export function setScriptBaseDir(baseDir: string): void { + scriptBaseDir = baseDir; +} + +/** + * Get the script base directory + */ +export function getScriptBaseDir(): string | null { + return scriptBaseDir; +} + +/** + * Check if a file exists relative to script base directory + */ +export function scriptDirExists(relativePath: string): boolean { + if (!scriptBaseDir) { + throw new Error('[SystemPaths] Script base directory not initialized'); + } + const fullPath = path.join(scriptBaseDir, relativePath); + return fsSync.existsSync(fullPath); +} + +/** + * Create a directory relative to script base directory + */ +export function scriptDirMkdirSync(relativePath: string, options?: { recursive?: boolean }): void { + if (!scriptBaseDir) { + throw new Error('[SystemPaths] Script base directory not initialized'); + } + const fullPath = path.join(scriptBaseDir, relativePath); + fsSync.mkdirSync(fullPath, options); +} + +/** + * Create a write stream for a file relative to script base directory + */ +export function scriptDirCreateWriteStream(relativePath: string): fsSync.WriteStream { + if (!scriptBaseDir) { + throw new Error('[SystemPaths] Script base directory not initialized'); + } + const fullPath = path.join(scriptBaseDir, relativePath); + return fsSync.createWriteStream(fullPath); +} + +// ============================================================================= +// Electron App Bundle Operations (for accessing app's own files) +// ============================================================================= + +// Store the Electron app bundle paths (can have multiple allowed directories) +let electronAppDirs: string[] = []; +let electronResourcesPath: string | null = null; + +/** + * Set the Electron app directories (called from Electron main process) + * In development mode, pass the project root to allow access to source files. + * In production mode, pass __dirname and process.resourcesPath. + * + * @param appDirOrDirs - Single directory or array of directories to allow + * @param resourcesPath - Optional resources path (for packaged apps) + */ +export function setElectronAppPaths(appDirOrDirs: string | string[], resourcesPath?: string): void { + electronAppDirs = Array.isArray(appDirOrDirs) ? appDirOrDirs : [appDirOrDirs]; + electronResourcesPath = resourcesPath || null; +} + +/** + * Check if a path is within the Electron app bundle (any of the allowed directories) + */ +function isElectronAppPath(filePath: string): boolean { + const normalizedPath = path.resolve(filePath); + + // Check against all allowed app directories + for (const appDir of electronAppDirs) { + const normalizedAppDir = path.resolve(appDir); + if ( + normalizedPath === normalizedAppDir || + normalizedPath.startsWith(normalizedAppDir + path.sep) + ) { + return true; + } + } + + // Check against resources path (for packaged apps) + if (electronResourcesPath) { + const normalizedResources = path.resolve(electronResourcesPath); + if ( + normalizedPath === normalizedResources || + normalizedPath.startsWith(normalizedResources + path.sep) + ) { + return true; + } + } + + return false; +} + +/** + * Check if a file exists within the Electron app bundle + */ +export function electronAppExists(filePath: string): boolean { + if (!isElectronAppPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`); + } + return fsSync.existsSync(filePath); +} + +/** + * Read a file from the Electron app bundle + */ +export function electronAppReadFileSync(filePath: string): Buffer { + if (!isElectronAppPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`); + } + return fsSync.readFileSync(filePath); +} + +/** + * Get file stats from the Electron app bundle + */ +export function electronAppStatSync(filePath: string): fsSync.Stats { + if (!isElectronAppPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`); + } + return fsSync.statSync(filePath); +} + +/** + * Get file stats from the Electron app bundle (async with callback for compatibility) + */ +export function electronAppStat( + filePath: string, + callback: (err: NodeJS.ErrnoException | null, stats: fsSync.Stats | undefined) => void +): void { + if (!isElectronAppPath(filePath)) { + callback( + new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`), + undefined + ); + return; + } + fsSync.stat(filePath, callback); +} + +/** + * Read a file from the Electron app bundle (async with callback for compatibility) + */ +export function electronAppReadFile( + filePath: string, + callback: (err: NodeJS.ErrnoException | null, data: Buffer | undefined) => void +): void { + if (!isElectronAppPath(filePath)) { + callback( + new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`), + undefined + ); + return; + } + fsSync.readFile(filePath, callback); +} + +// ============================================================================= +// High-level Tool Detection Methods +// ============================================================================= + +/** + * Find the first existing path from a list of system paths + */ +export async function findFirstExistingPath(paths: string[]): Promise { + for (const p of paths) { + if (await systemPathAccess(p)) { + return p; + } + } + return null; +} + +/** + * Check if GitHub CLI is installed and return its path + */ +export async function findGitHubCliPath(): Promise { + return findFirstExistingPath(getGitHubCliPaths()); +} + +/** + * Check if Claude CLI is installed and return its path + */ +export async function findClaudeCliPath(): Promise { + return findFirstExistingPath(getClaudeCliPaths()); +} + +/** + * Get Claude authentication status by checking various indicators + */ +export interface ClaudeAuthIndicators { + hasCredentialsFile: boolean; + hasSettingsFile: boolean; + hasStatsCacheWithActivity: boolean; + hasProjectsSessions: boolean; + credentials: { + hasOAuthToken: boolean; + hasApiKey: boolean; + } | null; +} + +export async function getClaudeAuthIndicators(): Promise { + const result: ClaudeAuthIndicators = { + hasCredentialsFile: false, + hasSettingsFile: false, + hasStatsCacheWithActivity: false, + hasProjectsSessions: false, + credentials: null, + }; + + // Check settings file + try { + if (await systemPathAccess(getClaudeSettingsPath())) { + result.hasSettingsFile = true; + } + } catch { + // Ignore errors + } + + // Check stats cache for recent activity + try { + const statsContent = await systemPathReadFile(getClaudeStatsCachePath()); + const stats = JSON.parse(statsContent); + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + result.hasStatsCacheWithActivity = true; + } + } catch { + // Ignore errors + } + + // Check for sessions in projects directory + try { + const sessions = await systemPathReaddir(getClaudeProjectsDir()); + if (sessions.length > 0) { + result.hasProjectsSessions = true; + } + } catch { + // Ignore errors + } + + // Check credentials files + const credentialPaths = getClaudeCredentialPaths(); + for (const credPath of credentialPaths) { + try { + const content = await systemPathReadFile(credPath); + const credentials = JSON.parse(content); + result.hasCredentialsFile = true; + result.credentials = { + hasOAuthToken: !!(credentials.oauth_token || credentials.access_token), + hasApiKey: !!credentials.api_key, + }; + break; + } catch { + // Continue to next path + } + } + + return result; +} diff --git a/libs/utils/src/context-loader.ts b/libs/utils/src/context-loader.ts index 0e94092b..ee04b980 100644 --- a/libs/utils/src/context-loader.ts +++ b/libs/utils/src/context-loader.ts @@ -10,7 +10,7 @@ */ import path from 'path'; -import fs from 'fs/promises'; +import { secureFs } from '@automaker/platform'; /** * Metadata structure for context files @@ -38,6 +38,16 @@ export interface ContextFilesResult { formattedPrompt: string; } +/** + * File system module interface for context loading + * Compatible with secureFs from @automaker/platform + */ +export interface ContextFsModule { + access: (path: string) => Promise; + readdir: (path: string) => Promise; + readFile: (path: string, encoding?: BufferEncoding) => Promise; +} + /** * Options for loading context files */ @@ -45,11 +55,7 @@ export interface LoadContextFilesOptions { /** Project path to load context from */ projectPath: string; /** Optional custom secure fs module (for dependency injection) */ - fsModule?: { - access: (path: string) => Promise; - readdir: (path: string) => Promise; - readFile: (path: string, encoding: string) => Promise; - }; + fsModule?: ContextFsModule; } /** @@ -64,12 +70,12 @@ function getContextDir(projectPath: string): string { */ async function loadContextMetadata( contextDir: string, - fsModule: typeof fs + fsModule: ContextFsModule ): Promise { const metadataPath = path.join(contextDir, 'context-metadata.json'); try { const content = await fsModule.readFile(metadataPath, 'utf-8'); - return JSON.parse(content); + return JSON.parse(content as string); } catch { // Metadata file doesn't exist yet - that's fine return { files: {} }; @@ -148,7 +154,7 @@ ${formattedFiles.join('\n\n---\n\n')} export async function loadContextFiles( options: LoadContextFilesOptions ): Promise { - const { projectPath, fsModule = fs } = options; + const { projectPath, fsModule = secureFs } = options; const contextDir = path.resolve(getContextDir(projectPath)); try { @@ -169,7 +175,7 @@ export async function loadContextFiles( } // Load metadata for descriptions - const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs); + const metadata = await loadContextMetadata(contextDir, fsModule); // Load each file with its content and metadata const files: ContextFileInfo[] = []; @@ -180,7 +186,7 @@ export async function loadContextFiles( files.push({ name: fileName, path: filePath, - content, + content: content as string, description: metadata.files[fileName]?.description, }); } catch (error) { @@ -209,7 +215,7 @@ export async function loadContextFiles( export async function getContextFilesSummary( options: LoadContextFilesOptions ): Promise> { - const { projectPath, fsModule = fs } = options; + const { projectPath, fsModule = secureFs } = options; const contextDir = path.resolve(getContextDir(projectPath)); try { @@ -225,7 +231,7 @@ export async function getContextFilesSummary( return []; } - const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs); + const metadata = await loadContextMetadata(contextDir, fsModule); return textFiles.map((fileName) => ({ name: fileName, From 59bbbd43c5990899532cc08b3eda59bd668d1877 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 18:42:33 -0500 Subject: [PATCH 05/24] feat: add Node.js version management and improve error handling - Introduced a .nvmrc file to specify the Node.js version (22) for the project, ensuring consistent development environments. - Enhanced error handling in the startServer function to provide clearer messages when the Node.js executable cannot be found, improving debugging experience. - Updated package.json files across various modules to enforce Node.js version compatibility and ensure consistent dependency versions. These changes aim to streamline development processes and enhance the application's reliability by enforcing version control and improving error reporting. --- .nvmrc | 2 + apps/server/package.json | 57 ++-- apps/server/src/lib/auth.ts | 7 +- apps/server/src/providers/claude-provider.ts | 21 ++ apps/server/src/services/terminal-service.ts | 12 +- .../unit/services/terminal-service.test.ts | 271 ++++++++++-------- apps/ui/package.json | 149 +++++----- apps/ui/src/main.ts | 7 +- init.mjs | 4 +- libs/dependency-resolver/package.json | 11 +- libs/git-utils/package.json | 13 +- libs/model-resolver/package.json | 11 +- libs/platform/package.json | 13 +- libs/platform/src/secure-fs.ts | 13 +- libs/platform/src/system-paths.ts | 5 +- libs/prompts/package.json | 11 +- libs/prompts/src/defaults.ts | 6 + libs/types/package.json | 7 +- libs/utils/package.json | 13 +- package-lock.json | 8 +- package.json | 15 +- 21 files changed, 387 insertions(+), 269 deletions(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..42126c05 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +22 + diff --git a/apps/server/package.json b/apps/server/package.json index 1eb415a8..d923fa9b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,6 +5,9 @@ "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "type": "module", "main": "dist/index.js", "scripts": { @@ -21,35 +24,35 @@ "test:unit": "vitest run tests/unit" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.72", - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/git-utils": "^1.0.0", - "@automaker/model-resolver": "^1.0.0", - "@automaker/platform": "^1.0.0", - "@automaker/prompts": "^1.0.0", - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.25.1", - "cookie-parser": "^1.4.7", - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.2.1", - "morgan": "^1.10.1", + "@anthropic-ai/claude-agent-sdk": "0.1.72", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/git-utils": "1.0.0", + "@automaker/model-resolver": "1.0.0", + "@automaker/platform": "1.0.0", + "@automaker/prompts": "1.0.0", + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0", + "@modelcontextprotocol/sdk": "1.25.1", + "cookie-parser": "1.4.7", + "cors": "2.8.5", + "dotenv": "17.2.3", + "express": "5.2.1", + "morgan": "1.10.1", "node-pty": "1.1.0-beta41", - "ws": "^8.18.3" + "ws": "8.18.3" }, "devDependencies": { - "@types/cookie": "^0.6.0", - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/morgan": "^1.9.10", - "@types/node": "^22", - "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "tsx": "^4.21.0", - "typescript": "^5", - "vitest": "^4.0.16" + "@types/cookie": "0.6.0", + "@types/cookie-parser": "1.4.10", + "@types/cors": "2.8.19", + "@types/express": "5.0.6", + "@types/morgan": "1.9.10", + "@types/node": "22.19.3", + "@types/ws": "8.18.1", + "@vitest/coverage-v8": "4.0.16", + "@vitest/ui": "4.0.16", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index acf8bb26..5f24b319 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -76,7 +76,10 @@ async function saveSessions(): Promise { try { await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); const sessions = Array.from(validSessions.entries()); - await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), 'utf-8'); + await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { + encoding: 'utf-8', + mode: 0o600, + }); } catch (error) { console.error('[Auth] Failed to save sessions:', error); } @@ -113,7 +116,7 @@ function ensureApiKey(): string { const newKey = crypto.randomUUID(); try { secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); - secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8' }); + secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); console.log('[Auth] Generated new API key'); } catch (error) { console.error('[Auth] Failed to save API key:', error); diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 286a733f..1a5e83d2 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -15,6 +15,24 @@ import type { ModelDefinition, } from './types.js'; +// Automaker-specific environment variables that should not pollute agent processes +// These are internal to Automaker and would interfere with user projects +// (e.g., PORT=3008 would cause Next.js/Vite to use the wrong port) +const AUTOMAKER_ENV_VARS = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH']; + +/** + * Build a clean environment for the SDK, excluding Automaker-specific variables + */ +function buildCleanEnv(): Record { + const cleanEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!AUTOMAKER_ENV_VARS.includes(key)) { + cleanEnv[key] = value; + } + } + return cleanEnv; +} + export class ClaudeProvider extends BaseProvider { getName(): string { return 'claude'; @@ -57,6 +75,9 @@ export class ClaudeProvider extends BaseProvider { systemPrompt, maxTurns, cwd, + // Pass clean environment to SDK, excluding Automaker-specific variables + // This prevents PORT, DATA_DIR, etc. from polluting agent-spawned processes + env: buildCleanEnv(), // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 1aea267d..06d56981 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -257,8 +257,18 @@ export class TerminalService extends EventEmitter { // Build environment with some useful defaults // These settings ensure consistent terminal behavior across platforms + // First, create a clean copy of process.env excluding Automaker-specific variables + // that could pollute user shells (e.g., PORT would affect Next.js/other dev servers) + const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH']; + const cleanEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !automakerEnvVars.includes(key)) { + cleanEnv[key] = value; + } + } + const env: Record = { - ...process.env, + ...cleanEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'automaker-terminal', diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index 44e823b0..ca90937d 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -2,11 +2,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { TerminalService, getTerminalService } from '@/services/terminal-service.js'; import * as pty from 'node-pty'; import * as os from 'os'; -import * as fs from 'fs'; +import * as platform from '@automaker/platform'; +import * as secureFs from '@/lib/secure-fs.js'; vi.mock('node-pty'); -vi.mock('fs'); vi.mock('os'); +vi.mock('@automaker/platform', async () => { + const actual = await vi.importActual('@automaker/platform'); + return { + ...actual, + systemPathExists: vi.fn(), + systemPathReadFileSync: vi.fn(), + getWslVersionPath: vi.fn(), + isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests + }; +}); +vi.mock('@/lib/secure-fs.js'); describe('terminal-service.ts', () => { let service: TerminalService; @@ -29,6 +40,12 @@ describe('terminal-service.ts', () => { vi.mocked(os.homedir).mockReturnValue('/home/user'); vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); + + // Default mocks for system paths and secureFs + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue(''); + vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version'); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); }); afterEach(() => { @@ -38,7 +55,7 @@ describe('terminal-service.ts', () => { describe('detectShell', () => { it('should detect PowerShell Core on Windows when available', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; }); @@ -50,7 +67,7 @@ describe('terminal-service.ts', () => { it('should fall back to PowerShell on Windows if Core not available', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; }); @@ -62,7 +79,7 @@ describe('terminal-service.ts', () => { it('should fall back to cmd.exe on Windows if no PowerShell', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -73,7 +90,7 @@ describe('terminal-service.ts', () => { it('should detect user shell on macOS', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' }); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); const result = service.detectShell(); @@ -84,7 +101,7 @@ describe('terminal-service.ts', () => { it('should fall back to zsh on macOS if user shell not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === '/bin/zsh'; }); @@ -97,7 +114,7 @@ describe('terminal-service.ts', () => { it('should fall back to bash on macOS if zsh not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -108,7 +125,7 @@ describe('terminal-service.ts', () => { it('should detect user shell on Linux', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); const result = service.detectShell(); @@ -119,7 +136,7 @@ describe('terminal-service.ts', () => { it('should fall back to bash on Linux if user shell not available', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === '/bin/bash'; }); @@ -132,7 +149,7 @@ describe('terminal-service.ts', () => { it('should fall back to sh on Linux if bash not available', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -143,8 +160,10 @@ describe('terminal-service.ts', () => { it('should detect WSL and use appropriate shell', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); const result = service.detectShell(); @@ -155,43 +174,45 @@ describe('terminal-service.ts', () => { describe('isWSL', () => { it('should return true if /proc/version contains microsoft', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); expect(service.isWSL()).toBe(true); }); it('should return true if /proc/version contains wsl', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); expect(service.isWSL()).toBe(true); }); it('should return true if WSL_DISTRO_NAME is set', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' }); expect(service.isWSL()).toBe(true); }); it('should return true if WSLENV is set', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' }); expect(service.isWSL()).toBe(true); }); it('should return false if not in WSL', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({}); expect(service.isWSL()).toBe(false); }); it('should return false if error reading /proc/version', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockImplementation(() => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => { throw new Error('Permission denied'); }); @@ -203,7 +224,7 @@ describe('terminal-service.ts', () => { it('should return platform information', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const info = service.getPlatformInfo(); @@ -216,20 +237,21 @@ describe('terminal-service.ts', () => { }); describe('createSession', () => { - it('should create a new terminal session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should create a new terminal session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '/test/dir', cols: 100, rows: 30, }); - expect(session.id).toMatch(/^term-/); - expect(session.cwd).toBe('/test/dir'); - expect(session.shell).toBe('/bin/bash'); + expect(session).not.toBeNull(); + expect(session!.id).toMatch(/^term-/); + expect(session!.cwd).toBe('/test/dir'); + expect(session!.shell).toBe('/bin/bash'); expect(pty.spawn).toHaveBeenCalledWith( '/bin/bash', ['--login'], @@ -241,12 +263,12 @@ describe('terminal-service.ts', () => { ); }); - it('should use default cols and rows if not provided', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should use default cols and rows if not provided', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - service.createSession(); + await service.createSession(); expect(pty.spawn).toHaveBeenCalledWith( expect.any(String), @@ -258,66 +280,68 @@ describe('terminal-service.ts', () => { ); }); - it('should fall back to home directory if cwd does not exist', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockImplementation(() => { - throw new Error('ENOENT'); - }); + it('should fall back to home directory if cwd does not exist', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT')); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '/nonexistent', }); - expect(session.cwd).toBe('/home/user'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/home/user'); }); - it('should fall back to home directory if cwd is not a directory', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any); + it('should fall back to home directory if cwd is not a directory', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '/file.txt', }); - expect(session.cwd).toBe('/home/user'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/home/user'); }); - it('should fix double slashes in path', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should fix double slashes in path', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '//test/dir', }); - expect(session.cwd).toBe('/test/dir'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/test/dir'); }); - it('should preserve WSL UNC paths', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should preserve WSL UNC paths', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '//wsl$/Ubuntu/home', }); - expect(session.cwd).toBe('//wsl$/Ubuntu/home'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('//wsl$/Ubuntu/home'); }); - it('should handle data events from PTY', () => { + it('should handle data events from PTY', async () => { vi.useFakeTimers(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const dataCallback = vi.fn(); service.onData(dataCallback); - service.createSession(); + await service.createSession(); // Simulate data event const onDataHandler = mockPtyProcess.onData.mock.calls[0][0]; @@ -331,33 +355,34 @@ describe('terminal-service.ts', () => { vi.useRealTimers(); }); - it('should handle exit events from PTY', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle exit events from PTY', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const exitCallback = vi.fn(); service.onExit(exitCallback); - const session = service.createSession(); + const session = await service.createSession(); // Simulate exit event const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0]; onExitHandler({ exitCode: 0 }); - expect(exitCallback).toHaveBeenCalledWith(session.id, 0); - expect(service.getSession(session.id)).toBeUndefined(); + expect(session).not.toBeNull(); + expect(exitCallback).toHaveBeenCalledWith(session!.id, 0); + expect(service.getSession(session!.id)).toBeUndefined(); }); }); describe('write', () => { - it('should write data to existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should write data to existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const result = service.write(session.id, 'ls\n'); + const session = await service.createSession(); + const result = service.write(session!.id, 'ls\n'); expect(result).toBe(true); expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n'); @@ -372,13 +397,13 @@ describe('terminal-service.ts', () => { }); describe('resize', () => { - it('should resize existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should resize existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const result = service.resize(session.id, 120, 40); + const session = await service.createSession(); + const result = service.resize(session!.id, 120, 40); expect(result).toBe(true); expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); @@ -391,30 +416,30 @@ describe('terminal-service.ts', () => { expect(mockPtyProcess.resize).not.toHaveBeenCalled(); }); - it('should handle resize errors', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle resize errors', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.resize.mockImplementation(() => { throw new Error('Resize failed'); }); - const session = service.createSession(); - const result = service.resize(session.id, 120, 40); + const session = await service.createSession(); + const result = service.resize(session!.id, 120, 40); expect(result).toBe(false); }); }); describe('killSession', () => { - it('should kill existing session', () => { + it('should kill existing session', async () => { vi.useFakeTimers(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const result = service.killSession(session.id); + const session = await service.createSession(); + const result = service.killSession(session!.id); expect(result).toBe(true); expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM'); @@ -423,7 +448,7 @@ describe('terminal-service.ts', () => { vi.advanceTimersByTime(1000); expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL'); - expect(service.getSession(session.id)).toBeUndefined(); + expect(service.getSession(session!.id)).toBeUndefined(); vi.useRealTimers(); }); @@ -434,29 +459,29 @@ describe('terminal-service.ts', () => { expect(result).toBe(false); }); - it('should handle kill errors', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle kill errors', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { throw new Error('Kill failed'); }); - const session = service.createSession(); - const result = service.killSession(session.id); + const session = await service.createSession(); + const result = service.killSession(session!.id); expect(result).toBe(false); }); }); describe('getSession', () => { - it('should return existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should return existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const retrieved = service.getSession(session.id); + const session = await service.createSession(); + const retrieved = service.getSession(session!.id); expect(retrieved).toBe(session); }); @@ -469,15 +494,15 @@ describe('terminal-service.ts', () => { }); describe('getScrollback', () => { - it('should return scrollback buffer for existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should return scrollback buffer for existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - session.scrollbackBuffer = 'test scrollback'; + const session = await service.createSession(); + session!.scrollbackBuffer = 'test scrollback'; - const scrollback = service.getScrollback(session.id); + const scrollback = service.getScrollback(session!.id); expect(scrollback).toBe('test scrollback'); }); @@ -490,19 +515,21 @@ describe('terminal-service.ts', () => { }); describe('getAllSessions', () => { - it('should return all active sessions', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should return all active sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session1 = service.createSession({ cwd: '/dir1' }); - const session2 = service.createSession({ cwd: '/dir2' }); + const session1 = await service.createSession({ cwd: '/dir1' }); + const session2 = await service.createSession({ cwd: '/dir2' }); const sessions = service.getAllSessions(); expect(sessions).toHaveLength(2); - expect(sessions[0].id).toBe(session1.id); - expect(sessions[1].id).toBe(session2.id); + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(sessions[0].id).toBe(session1!.id); + expect(sessions[1].id).toBe(session2!.id); expect(sessions[0].cwd).toBe('/dir1'); expect(sessions[1].cwd).toBe('/dir2'); }); @@ -535,30 +562,32 @@ describe('terminal-service.ts', () => { }); describe('cleanup', () => { - it('should clean up all sessions', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should clean up all sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session1 = service.createSession(); - const session2 = service.createSession(); + const session1 = await service.createSession(); + const session2 = await service.createSession(); service.cleanup(); - expect(service.getSession(session1.id)).toBeUndefined(); - expect(service.getSession(session2.id)).toBeUndefined(); + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(service.getSession(session1!.id)).toBeUndefined(); + expect(service.getSession(session2!.id)).toBeUndefined(); expect(service.getAllSessions()).toHaveLength(0); }); - it('should handle cleanup errors gracefully', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle cleanup errors gracefully', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { throw new Error('Kill failed'); }); - service.createSession(); + await service.createSession(); expect(() => service.cleanup()).not.toThrow(); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index b069e28c..fb846c15 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -10,6 +10,9 @@ "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "main": "dist-electron/main.js", "scripts": { "dev": "vite", @@ -35,87 +38,87 @@ "dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite" }, "dependencies": { - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/types": "^1.0.0", - "@codemirror/lang-xml": "^6.1.0", - "@codemirror/theme-one-dark": "^6.1.3", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@lezer/highlight": "^1.2.3", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-router": "^1.141.6", - "@uiw/react-codemirror": "^4.25.4", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.10.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "dagre": "^0.8.5", - "dotenv": "^17.2.3", - "geist": "^1.5.1", - "lucide-react": "^0.562.0", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/types": "1.0.0", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/theme-one-dark": "6.1.3", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@lezer/highlight": "1.2.3", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-query": "5.90.12", + "@tanstack/react-router": "1.141.6", + "@uiw/react-codemirror": "4.25.4", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "@xyflow/react": "12.10.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "dagre": "0.8.5", + "dotenv": "17.2.3", + "geist": "1.5.1", + "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^3.0.6", - "rehype-raw": "^7.0.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "usehooks-ts": "^3.1.1", - "zustand": "^5.0.9" + "react-markdown": "10.1.0", + "react-resizable-panels": "3.0.6", + "rehype-raw": "7.0.0", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "usehooks-ts": "3.1.1", + "zustand": "5.0.9" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "^1.29.2", - "lightningcss-darwin-x64": "^1.29.2", - "lightningcss-linux-arm-gnueabihf": "^1.29.2", - "lightningcss-linux-arm64-gnu": "^1.29.2", - "lightningcss-linux-arm64-musl": "^1.29.2", - "lightningcss-linux-x64-gnu": "^1.29.2", - "lightningcss-linux-x64-musl": "^1.29.2", - "lightningcss-win32-arm64-msvc": "^1.29.2", - "lightningcss-win32-x64-msvc": "^1.29.2" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" }, "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@eslint/js": "^9.0.0", - "@playwright/test": "^1.57.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.141.7", - "@types/dagre": "^0.7.53", - "@types/node": "^22", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.50.0", - "@typescript-eslint/parser": "^8.50.0", - "@vitejs/plugin-react": "^5.1.2", - "cross-env": "^10.1.0", + "@electron/rebuild": "4.0.2", + "@eslint/js": "9.0.0", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "4.1.18", + "@tanstack/router-plugin": "1.141.7", + "@types/dagre": "0.7.53", + "@types/node": "22.19.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@vitejs/plugin-react": "5.1.2", + "cross-env": "10.1.0", "electron": "39.2.7", - "electron-builder": "^26.0.12", - "eslint": "^9.39.2", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", + "electron-builder": "26.0.12", + "eslint": "9.39.2", + "tailwindcss": "4.1.18", + "tw-animate-css": "1.4.0", "typescript": "5.9.3", - "vite": "^7.3.0", - "vite-plugin-electron": "^0.29.0", - "vite-plugin-electron-renderer": "^0.14.6" + "vite": "7.3.0", + "vite-plugin-electron": "0.29.0", + "vite-plugin-electron-renderer": "0.14.6" }, "build": { "appId": "com.automaker.app", diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index e8bcaaa9..88c97ee9 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -355,8 +355,11 @@ async function startServer(): Promise { `Node.js executable not found at: ${command} (source: ${nodeResult.source})` ); } - } catch { - throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } catch (error) { + const originalError = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}` + ); } } diff --git a/init.mjs b/init.mjs index 68387ba5..f9d7d69c 100644 --- a/init.mjs +++ b/init.mjs @@ -39,7 +39,9 @@ function validateScriptPath(targetPath) { const resolved = path.resolve(__dirname, targetPath); const normalizedBase = path.resolve(__dirname); if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { - throw new Error(`[init.mjs] Security: Path access denied outside script directory: ${targetPath}`); + throw new Error( + `[init.mjs] Security: Path access denied outside script directory: ${targetPath}` + ); } return resolved; } diff --git a/libs/dependency-resolver/package.json b/libs/dependency-resolver/package.json index 0ba6f756..4f7c30fd 100644 --- a/libs/dependency-resolver/package.json +++ b/libs/dependency-resolver/package.json @@ -25,12 +25,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/git-utils/package.json b/libs/git-utils/package.json index a34ac9af..ee8fbb79 100644 --- a/libs/git-utils/package.json +++ b/libs/git-utils/package.json @@ -18,13 +18,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0" + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/model-resolver/package.json b/libs/model-resolver/package.json index 742144f7..06a0d252 100644 --- a/libs/model-resolver/package.json +++ b/libs/model-resolver/package.json @@ -18,12 +18,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/platform/package.json b/libs/platform/package.json index 35663d05..21729ef9 100644 --- a/libs/platform/package.json +++ b/libs/platform/package.json @@ -17,13 +17,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0", - "p-limit": "^6.2.0" + "@automaker/types": "1.0.0", + "p-limit": "6.2.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/platform/src/secure-fs.ts b/libs/platform/src/secure-fs.ts index e324b0c3..919e555d 100644 --- a/libs/platform/src/secure-fs.ts +++ b/libs/platform/src/secure-fs.ts @@ -165,17 +165,26 @@ export async function readFile( }, `readFile(${filePath})`); } +/** + * Options for writeFile + */ +export interface WriteFileOptions { + encoding?: BufferEncoding; + mode?: number; + flag?: string; +} + /** * Wrapper around fs.writeFile that validates path first */ export async function writeFile( filePath: string, data: string | Buffer, - encoding?: BufferEncoding + optionsOrEncoding?: BufferEncoding | WriteFileOptions ): Promise { const validatedPath = validatePath(filePath); return executeWithRetry( - () => fs.writeFile(validatedPath, data, encoding), + () => fs.writeFile(validatedPath, data, optionsOrEncoding), `writeFile(${filePath})` ); } diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 30a8aef8..95fa4b24 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -114,13 +114,16 @@ export function getShellPaths(): string[] { if (process.platform === 'win32') { return [ process.env.COMSPEC || 'cmd.exe', + 'cmd.exe', 'powershell.exe', + 'pwsh.exe', // PowerShell Core short form 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', // Preview versions ]; } - return ['/bin/zsh', '/bin/bash', '/bin/sh']; + return ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/zsh', '/usr/bin/bash']; } // ============================================================================= diff --git a/libs/prompts/package.json b/libs/prompts/package.json index e5954174..0012859f 100644 --- a/libs/prompts/package.json +++ b/libs/prompts/package.json @@ -18,12 +18,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index 09e4f644..43213a1d 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -208,6 +208,9 @@ This feature depends on: {{dependencies}} **Verification:** {{verificationInstructions}} {{/if}} + +**CRITICAL - Port Protection:** +NEVER kill or terminate processes running on ports 3007 or 3008. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session. `; export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation @@ -299,6 +302,9 @@ You have access to several tools: 4. Ask questions when requirements are unclear 5. Guide users toward good software design principles +**CRITICAL - Port Protection:** +NEVER kill or terminate processes running on ports 3007 or 3008. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session. + Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`; /** diff --git a/libs/types/package.json b/libs/types/package.json index acd0ba75..3a5c2a83 100644 --- a/libs/types/package.json +++ b/libs/types/package.json @@ -15,8 +15,11 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "@types/node": "22.19.3", + "typescript": "5.9.3" } } diff --git a/libs/utils/package.json b/libs/utils/package.json index c7d612e8..118747be 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -17,13 +17,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/platform": "^1.0.0", - "@automaker/types": "^1.0.0" + "@automaker/platform": "1.0.0", + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/package-lock.json b/package-lock.json index d8190a03..401c398f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1246,7 +1246,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -13953,9 +13953,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index fb5d89b6..9aff9d1a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "automaker", "version": "1.0.0", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "workspaces": [ "apps/*", "libs/*" @@ -53,13 +56,13 @@ ] }, "dependencies": { - "cross-spawn": "^7.0.6", - "rehype-sanitize": "^6.0.0", - "tree-kill": "^1.2.2" + "cross-spawn": "7.0.6", + "rehype-sanitize": "6.0.0", + "tree-kill": "1.2.2" }, "devDependencies": { - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4" + "husky": "9.1.7", + "lint-staged": "16.2.7", + "prettier": "3.7.4" } } From eafe474dbc26122b77909a2cc853e56bb89d0833 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 18:53:47 -0500 Subject: [PATCH 06/24] fix: update node-gyp repository URL to use HTTPS Changed the resolved URL for the @electron/node-gyp dependency in package-lock.json from SSH to HTTPS for improved accessibility and compatibility across different environments. --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 401c398f..5c911ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1246,7 +1246,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From 63816043cf690351fe412584c7828f8bbe65b84c Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 19:06:13 -0500 Subject: [PATCH 07/24] feat: enhance shell detection logic and improve cross-platform support - Updated the TerminalService to utilize getShellPaths() for better shell detection across platforms. - Improved logic for detecting user-configured shells in WSL and added fallbacks for various platforms. - Enhanced unit tests to mock shell paths for comprehensive cross-platform testing, ensuring accurate shell detection behavior. These changes aim to streamline shell detection and improve the user experience across different operating systems. --- apps/server/src/services/terminal-service.ts | 127 ++++++++++++------ .../unit/services/terminal-service.test.ts | 40 +++++- libs/platform/src/system-paths.ts | 38 +++++- 3 files changed, 154 insertions(+), 51 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 06d56981..81a1585a 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -14,7 +14,12 @@ import * as path from 'path'; import * as secureFs from '../lib/secure-fs.js'; // System paths module handles shell binary checks and WSL detection // These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing -import { systemPathExists, systemPathReadFileSync, getWslVersionPath } from '@automaker/platform'; +import { + systemPathExists, + systemPathReadFileSync, + getWslVersionPath, + getShellPaths, +} from '@automaker/platform'; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -65,60 +70,96 @@ export class TerminalService extends EventEmitter { /** * Detect the best shell for the current platform + * Uses getShellPaths() to iterate through allowed shell paths */ detectShell(): { shell: string; args: string[] } { const platform = os.platform(); + const shellPaths = getShellPaths(); - // Check if running in WSL + // Helper to get basename handling both path separators + const getBasename = (shellPath: string): string => { + const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); + return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; + }; + + // Helper to get shell args based on shell name + const getShellArgs = (shell: string): string[] => { + const shellName = getBasename(shell).toLowerCase().replace('.exe', ''); + // PowerShell and cmd don't need --login + if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { + return []; + } + // sh doesn't support --login in all implementations + if (shellName === 'sh') { + return []; + } + // bash, zsh, and other POSIX shells support --login + return ['--login']; + }; + + // Check if running in WSL - prefer user's shell or bash with --login if (platform === 'linux' && this.isWSL()) { - // In WSL, prefer the user's configured shell or bash - const userShell = process.env.SHELL || '/bin/bash'; - if (systemPathExists(userShell)) { - return { shell: userShell, args: ['--login'] }; + const userShell = process.env.SHELL; + if (userShell) { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgs(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } + } + } + } + // Fall back to first available POSIX shell + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgs(shell) }; + } + } catch { + // Path not allowed, continue + } } return { shell: '/bin/bash', args: ['--login'] }; } - switch (platform) { - case 'win32': { - // Windows: prefer PowerShell, fall back to cmd - const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; - const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; - - if (systemPathExists(pwshCore)) { - return { shell: pwshCore, args: [] }; + // For all platforms: first try user's shell if set + const userShell = process.env.SHELL; + if (userShell && platform !== 'win32') { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgs(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } } - if (systemPathExists(pwsh)) { - return { shell: pwsh, args: [] }; - } - return { shell: 'cmd.exe', args: [] }; - } - - case 'darwin': { - // macOS: prefer user's shell, then zsh, then bash - const userShell = process.env.SHELL; - if (userShell && systemPathExists(userShell)) { - return { shell: userShell, args: ['--login'] }; - } - if (systemPathExists('/bin/zsh')) { - return { shell: '/bin/zsh', args: ['--login'] }; - } - return { shell: '/bin/bash', args: ['--login'] }; - } - - case 'linux': - default: { - // Linux: prefer user's shell, then bash, then sh - const userShell = process.env.SHELL; - if (userShell && systemPathExists(userShell)) { - return { shell: userShell, args: ['--login'] }; - } - if (systemPathExists('/bin/bash')) { - return { shell: '/bin/bash', args: ['--login'] }; - } - return { shell: '/bin/sh', args: [] }; } } + + // Iterate through allowed shell paths and return first existing one + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgs(shell) }; + } + } catch { + // Path not allowed or doesn't exist, continue to next + } + } + + // Ultimate fallbacks based on platform + if (platform === 'win32') { + return { shell: 'cmd.exe', args: [] }; + } + return { shell: '/bin/sh', args: [] }; } /** diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index ca90937d..88660f7f 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -14,6 +14,7 @@ vi.mock('@automaker/platform', async () => { systemPathExists: vi.fn(), systemPathReadFileSync: vi.fn(), getWslVersionPath: vi.fn(), + getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests }; }); @@ -23,6 +24,36 @@ describe('terminal-service.ts', () => { let service: TerminalService; let mockPtyProcess: any; + // Shell paths for each platform (matching system-paths.ts) + const linuxShellPaths = [ + '/bin/zsh', + '/bin/bash', + '/bin/sh', + '/usr/bin/zsh', + '/usr/bin/bash', + '/usr/bin/sh', + '/usr/local/bin/zsh', + '/usr/local/bin/bash', + '/opt/homebrew/bin/zsh', + '/opt/homebrew/bin/bash', + 'zsh', + 'bash', + 'sh', + ]; + + const windowsShellPaths = [ + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\cmd.exe', + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', + ]; + beforeEach(() => { vi.clearAllMocks(); service = new TerminalService(); @@ -45,6 +76,7 @@ describe('terminal-service.ts', () => { vi.mocked(platform.systemPathExists).mockReturnValue(true); vi.mocked(platform.systemPathReadFileSync).mockReturnValue(''); vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version'); + vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); }); @@ -55,6 +87,7 @@ describe('terminal-service.ts', () => { describe('detectShell', () => { it('should detect PowerShell Core on Windows when available', () => { vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; }); @@ -67,6 +100,7 @@ describe('terminal-service.ts', () => { it('should fall back to PowerShell on Windows if Core not available', () => { vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; }); @@ -79,6 +113,7 @@ describe('terminal-service.ts', () => { it('should fall back to cmd.exe on Windows if no PowerShell', () => { vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -114,7 +149,10 @@ describe('terminal-service.ts', () => { it('should fall back to bash on macOS if zsh not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(platform.systemPathExists).mockReturnValue(false); + // zsh not available, but bash is + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === '/bin/bash'; + }); const result = service.detectShell(); diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 95fa4b24..2824d623 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -109,21 +109,45 @@ export function getClaudeProjectsDir(): string { /** * Get common shell paths for shell detection + * Includes both full paths and short names to match $SHELL or PATH entries */ export function getShellPaths(): string[] { if (process.platform === 'win32') { return [ - process.env.COMSPEC || 'cmd.exe', - 'cmd.exe', - 'powershell.exe', - 'pwsh.exe', // PowerShell Core short form - 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + // Full paths (most specific first) 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', - 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', // Preview versions + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + // COMSPEC environment variable (typically cmd.exe) + process.env.COMSPEC || 'C:\\Windows\\System32\\cmd.exe', + // Short names (for PATH resolution) + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', ]; } - return ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/zsh', '/usr/bin/bash']; + // POSIX (macOS, Linux) + return [ + // Full paths + '/bin/zsh', + '/bin/bash', + '/bin/sh', + '/usr/bin/zsh', + '/usr/bin/bash', + '/usr/bin/sh', + '/usr/local/bin/zsh', + '/usr/local/bin/bash', + '/opt/homebrew/bin/zsh', + '/opt/homebrew/bin/bash', + // Short names (for PATH resolution or $SHELL matching) + 'zsh', + 'bash', + 'sh', + ]; } // ============================================================================= From f6ce03d59ac970ce3d83a4211b3dab39a9d6653a Mon Sep 17 00:00:00 2001 From: RayFernando <104695450+RayFernando1337@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:05:34 -0800 Subject: [PATCH 08/24] fix: resolve auth race condition causing 401 errors on Electron startup API requests were being made before initApiKey() completed, causing 401 Unauthorized errors on app startup in Electron mode. Changes: - Add waitForApiKeyInit() to track and await API key initialization - Make HTTP methods (get/post/put/delete) wait for auth before requests - Defer WebSocket connection until API key is ready - Add explicit auth wait in useSettingsMigration hook Fixes race condition introduced in PR #321 --- apps/ui/src/hooks/use-settings-migration.ts | 6 +- apps/ui/src/lib/http-api-client.ts | 71 +++++++++++++++++---- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 568ca182..54b24b24 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -18,7 +18,7 @@ */ import { useEffect, useState, useRef } from 'react'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client'; import { isElectron } from '@/lib/electron'; import { getItem, removeItem } from '@/lib/storage'; import { useAppStore } from '@/store/app-store'; @@ -99,6 +99,10 @@ export function useSettingsMigration(): MigrationState { } try { + // Wait for API key to be initialized before making any API calls + // This prevents 401 errors on startup in Electron mode + await waitForApiKeyInit(); + const api = getHttpApiClient(); // Check if server has settings files diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b856bd51..296fcfb1 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -44,6 +44,7 @@ const getServerUrl = (): string => { // Cached API key for authentication (Electron mode only) let cachedApiKey: string | null = null; let apiKeyInitialized = false; +let apiKeyInitPromise: Promise | null = null; // Cached session token for authentication (Web mode - explicit header auth) let cachedSessionToken: string | null = null; @@ -52,6 +53,17 @@ let cachedSessionToken: string | null = null; // Exported for use in WebSocket connections that need auth export const getApiKey = (): string | null => cachedApiKey; +/** + * Wait for API key initialization to complete. + * Returns immediately if already initialized. + */ +export const waitForApiKeyInit = (): Promise => { + if (apiKeyInitialized) return Promise.resolve(); + if (apiKeyInitPromise) return apiKeyInitPromise; + // If not started yet, start it now + return initApiKey(); +}; + // Get session token for Web mode (returns cached value after login or token fetch) export const getSessionToken = (): string | null => cachedSessionToken; @@ -79,24 +91,37 @@ export const isElectronMode = (): boolean => { * This should be called early in app initialization. */ export const initApiKey = async (): Promise => { + // Return existing promise if already in progress + if (apiKeyInitPromise) return apiKeyInitPromise; + + // Return immediately if already initialized if (apiKeyInitialized) return; - apiKeyInitialized = true; - // Only Electron mode uses API key header auth - if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { + // Create and store the promise so concurrent calls wait for the same initialization + apiKeyInitPromise = (async () => { try { - cachedApiKey = await window.electronAPI.getApiKey(); - if (cachedApiKey) { - console.log('[HTTP Client] Using API key from Electron'); - return; + // Only Electron mode uses API key header auth + if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { + try { + cachedApiKey = await window.electronAPI.getApiKey(); + if (cachedApiKey) { + console.log('[HTTP Client] Using API key from Electron'); + return; + } + } catch (error) { + console.warn('[HTTP Client] Failed to get API key from Electron:', error); + } } - } catch (error) { - console.warn('[HTTP Client] Failed to get API key from Electron:', error); - } - } - // In web mode, authentication is handled via HTTP-only cookies - console.log('[HTTP Client] Web mode - using cookie-based authentication'); + // In web mode, authentication is handled via HTTP-only cookies + console.log('[HTTP Client] Web mode - using cookie-based authentication'); + } finally { + // Mark as initialized after completion, regardless of success or failure + apiKeyInitialized = true; + } + })(); + + return apiKeyInitPromise; }; /** @@ -296,7 +321,17 @@ export class HttpApiClient implements ElectronAPI { constructor() { this.serverUrl = getServerUrl(); - this.connectWebSocket(); + // Wait for API key initialization before connecting WebSocket + // This prevents 401 errors on startup in Electron mode + waitForApiKeyInit() + .then(() => { + this.connectWebSocket(); + }) + .catch((error) => { + console.error('[HttpApiClient] API key initialization failed:', error); + // Still attempt WebSocket connection - it may work with cookie auth + this.connectWebSocket(); + }); } /** @@ -460,6 +495,8 @@ export class HttpApiClient implements ElectronAPI { } private async post(endpoint: string, body?: unknown): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'POST', headers: this.getHeaders(), @@ -470,6 +507,8 @@ export class HttpApiClient implements ElectronAPI { } private async get(endpoint: string): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth @@ -478,6 +517,8 @@ export class HttpApiClient implements ElectronAPI { } private async put(endpoint: string, body?: unknown): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'PUT', headers: this.getHeaders(), @@ -488,6 +529,8 @@ export class HttpApiClient implements ElectronAPI { } private async httpDelete(endpoint: string): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'DELETE', headers: this.getHeaders(), From 3f4f2199ebf12e74c2ded4d0aa4a5c5158511c54 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 20:00:54 -0500 Subject: [PATCH 09/24] feat: initialize API key on module import for improved async handling - Start API key initialization immediately upon importing the HTTP API client module to ensure the init promise is created early. - Log errors during API key initialization to aid in debugging. Additionally, added a version field to the setup store for proper state hydration, aligning with the app-store pattern. --- apps/ui/src/lib/http-api-client.ts | 7 +++++++ apps/ui/src/store/setup-store.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 3a9b269b..1c9d0533 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1630,3 +1630,10 @@ export function getHttpApiClient(): HttpApiClient { } return httpApiClientInstance; } + +// Start API key initialization immediately when this module is imported +// This ensures the init promise is created early, even before React components mount +// The actual async work happens in the background and won't block module loading +initApiKey().catch((error) => { + console.error('[HTTP Client] Failed to initialize API key:', error); +}); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 1c84e59a..281fc539 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -172,6 +172,7 @@ export const useSetupStore = create()( }), { name: 'automaker-setup', + version: 1, // Add version field for proper hydration (matches app-store pattern) partialize: (state) => ({ isFirstRun: state.isFirstRun, setupComplete: state.setupComplete, From 2828431cca064f4ad350a1d12eb1805280347b77 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 20:36:20 -0500 Subject: [PATCH 10/24] feat: add test validation command and improve environment variable handling - Introduced a new command for validating tests, providing detailed instructions for running tests and fixing failures based on code changes. - Updated the environment variable handling in the Claude provider to only allow explicitly defined variables, enhancing security and preventing leakage of sensitive information. - Improved feature loading to handle errors more gracefully and load features concurrently, optimizing performance. - Centralized port configuration for the Automaker application to prevent accidental termination of critical services. --- .claude/commands/validate-tests.md | 36 ++++++++++++++++++++ apps/server/src/providers/claude-provider.ts | 35 +++++++++++-------- apps/server/src/services/feature-loader.ts | 17 +++++---- apps/ui/src/main.ts | 10 +++--- libs/platform/src/config/ports.ts | 15 ++++++++ libs/platform/src/index.ts | 3 ++ libs/platform/src/secure-fs.ts | 15 +++----- libs/prompts/package.json | 1 + libs/prompts/src/defaults.ts | 5 +-- 9 files changed, 98 insertions(+), 39 deletions(-) create mode 100644 .claude/commands/validate-tests.md create mode 100644 libs/platform/src/config/ports.ts diff --git a/.claude/commands/validate-tests.md b/.claude/commands/validate-tests.md new file mode 100644 index 00000000..3a19b5d1 --- /dev/null +++ b/.claude/commands/validate-tests.md @@ -0,0 +1,36 @@ +# Project Test and Fix Command + +Run all tests and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run all tests** + + ```bash + npm run test:all + ``` + +2. **If all tests pass**, report success and stop. + +3. **If any tests fail**, analyze the failures: + - Note which tests failed and their error messages + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the change**: + - **If the logic change is intentional** (new feature, refactor, behavior change): + - Update the failing tests to match the new expected behavior + - The tests should reflect what the code NOW does correctly + + - **If the logic change appears to be a bug** (regression, unintended side effect): + - Fix the source code to restore the expected behavior + - Do NOT modify the tests - they are catching a real bug + +5. **How to decide if it's a bug vs intentional change**: + - Look at the git diff and commit messages + - If the change was deliberate and the test expectations are now outdated → update tests + - If the change broke existing functionality that should still work → fix the code + - When in doubt, ask the user + +6. **After making fixes**, re-run the tests to verify everything passes. + +7. **Report summary** of what was fixed (tests updated vs code fixed). diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 1a5e83d2..33494535 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -15,22 +15,30 @@ import type { ModelDefinition, } from './types.js'; -// Automaker-specific environment variables that should not pollute agent processes -// These are internal to Automaker and would interfere with user projects -// (e.g., PORT=3008 would cause Next.js/Vite to use the wrong port) -const AUTOMAKER_ENV_VARS = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH']; +// Explicit allowlist of environment variables to pass to the SDK. +// Only these vars are passed - nothing else from process.env leaks through. +const ALLOWED_ENV_VARS = [ + 'ANTHROPIC_API_KEY', + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', +]; /** - * Build a clean environment for the SDK, excluding Automaker-specific variables + * Build environment for the SDK with only explicitly allowed variables */ -function buildCleanEnv(): Record { - const cleanEnv: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (!AUTOMAKER_ENV_VARS.includes(key)) { - cleanEnv[key] = value; +function buildEnv(): Record { + const env: Record = {}; + for (const key of ALLOWED_ENV_VARS) { + if (process.env[key]) { + env[key] = process.env[key]; } } - return cleanEnv; + return env; } export class ClaudeProvider extends BaseProvider { @@ -75,9 +83,8 @@ export class ClaudeProvider extends BaseProvider { systemPrompt, maxTurns, cwd, - // Pass clean environment to SDK, excluding Automaker-specific variables - // This prevents PORT, DATA_DIR, etc. from polluting agent-spawned processes - env: buildCleanEnv(), + // Pass only explicitly allowed environment variables to SDK + env: buildEnv(), // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index fbf86d49..4f1b937c 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -185,9 +185,8 @@ export class FeatureLoader { })) as any[]; const featureDirs = entries.filter((entry) => entry.isDirectory()); - // Load each feature - const features: Feature[] = []; - for (const dir of featureDirs) { + // Load all features concurrently (secureFs has built-in concurrency limiting) + const featurePromises = featureDirs.map(async (dir) => { const featureId = dir.name; const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); @@ -199,13 +198,13 @@ export class FeatureLoader { logger.warn( `[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping` ); - continue; + return null; } - features.push(feature); + return feature as Feature; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - continue; + return null; } else if (error instanceof SyntaxError) { logger.warn( `[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}` @@ -216,8 +215,12 @@ export class FeatureLoader { (error as Error).message ); } + return null; } - } + }); + + const results = await Promise.all(featurePromises); + const features = results.filter((f): f is Feature => f !== null); // Sort by creation order (feature IDs contain timestamp) features.sort((a, b) => { diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 88c97ee9..09fe21a9 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -349,18 +349,18 @@ async function startServer(): Promise { // Validate that the found Node executable actually exists // systemPathExists is used because node-finder returns system paths if (command !== 'node') { + let exists: boolean; try { - if (!systemPathExists(command)) { - throw new Error( - `Node.js executable not found at: ${command} (source: ${nodeResult.source})` - ); - } + exists = systemPathExists(command); } catch (error) { const originalError = error instanceof Error ? error.message : String(error); throw new Error( `Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}` ); } + if (!exists) { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } } let args: string[]; diff --git a/libs/platform/src/config/ports.ts b/libs/platform/src/config/ports.ts new file mode 100644 index 00000000..451ecdd7 --- /dev/null +++ b/libs/platform/src/config/ports.ts @@ -0,0 +1,15 @@ +/** + * Centralized port configuration for AutoMaker + * + * These ports are reserved for the Automaker application and should never be + * killed or terminated by AI agents during feature implementation. + */ + +/** Port for the static/UI server (Vite dev server) */ +export const STATIC_PORT = 3007; + +/** Port for the backend API server (Express + WebSocket) */ +export const SERVER_PORT = 3008; + +/** Array of all reserved Automaker ports */ +export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const; diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index c30f7d73..81ffe224 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -115,3 +115,6 @@ export { electronAppStat, electronAppReadFile, } from './system-paths.js'; + +// Port configuration +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js'; diff --git a/libs/platform/src/secure-fs.ts b/libs/platform/src/secure-fs.ts index 919e555d..95ec503a 100644 --- a/libs/platform/src/secure-fs.ts +++ b/libs/platform/src/secure-fs.ts @@ -574,11 +574,11 @@ export function removeEnvKeySync(envPath: string, key: string): void { */ function updateEnvContent(content: string, key: string, value: string): string { const lines = content.split('\n'); - const keyRegex = new RegExp(`^${escapeRegex(key)}=`); + const keyPrefix = `${key}=`; let found = false; const newLines = lines.map((line) => { - if (keyRegex.test(line.trim())) { + if (line.trim().startsWith(keyPrefix)) { found = true; return `${key}=${value}`; } @@ -612,8 +612,8 @@ function updateEnvContent(content: string, key: string, value: string): string { */ function removeEnvKeyFromContent(content: string, key: string): string { const lines = content.split('\n'); - const keyRegex = new RegExp(`^${escapeRegex(key)}=`); - const newLines = lines.filter((line) => !keyRegex.test(line.trim())); + const keyPrefix = `${key}=`; + const newLines = lines.filter((line) => !line.trim().startsWith(keyPrefix)); // Remove trailing empty lines while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') { @@ -627,10 +627,3 @@ function removeEnvKeyFromContent(content: string, key: string): string { } return result; } - -/** - * Escape special regex characters in a string - */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/libs/prompts/package.json b/libs/prompts/package.json index 0012859f..8de01d9b 100644 --- a/libs/prompts/package.json +++ b/libs/prompts/package.json @@ -22,6 +22,7 @@ "node": ">=22.0.0 <23.0.0" }, "dependencies": { + "@automaker/platform": "1.0.0", "@automaker/types": "1.0.0" }, "devDependencies": { diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index 43213a1d..57646330 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -16,6 +16,7 @@ import type { ResolvedBacklogPlanPrompts, ResolvedEnhancementPrompts, } from '@automaker/types'; +import { STATIC_PORT, SERVER_PORT } from '@automaker/platform'; /** * ======================================================================== @@ -210,7 +211,7 @@ This feature depends on: {{dependencies}} {{/if}} **CRITICAL - Port Protection:** -NEVER kill or terminate processes running on ports 3007 or 3008. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session. +NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session. `; export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation @@ -303,7 +304,7 @@ You have access to several tools: 5. Guide users toward good software design principles **CRITICAL - Port Protection:** -NEVER kill or terminate processes running on ports 3007 or 3008. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session. +NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session. Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`; From b9a6e29ee86850b8b323a0b5831977e732886de3 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 21:00:23 -0500 Subject: [PATCH 11/24] feat: add sandbox environment checks and user confirmation dialogs - Introduced a new endpoint to check if the application is running in a containerized environment, allowing the UI to display appropriate risk warnings. - Added a confirmation dialog for users when running outside a sandbox, requiring acknowledgment of potential risks before proceeding. - Implemented a rejection screen for users who deny sandbox risk confirmation, providing options to restart in a container or reload the application. - Updated the main application logic to handle sandbox status checks and user responses effectively, enhancing security and user experience. --- apps/server/src/routes/health/index.ts | 7 +- .../src/routes/health/routes/environment.ts | 20 +++ apps/ui/src/components/dialogs/index.ts | 2 + .../dialogs/sandbox-rejection-screen.tsx | 90 +++++++++++++ .../dialogs/sandbox-risk-dialog.tsx | 112 +++++++++++++++++ apps/ui/src/lib/electron.ts | 1 + apps/ui/src/lib/http-api-client.ts | 26 ++++ apps/ui/src/main.ts | 6 + apps/ui/src/preload.ts | 3 + apps/ui/src/routes/__root.tsx | 119 +++++++++++++++++- apps/ui/src/types/electron.d.ts | 1 + docker-compose.yml | 4 + 12 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/routes/health/routes/environment.ts create mode 100644 apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx create mode 100644 apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts index 688fdbc5..083a8703 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -1,12 +1,13 @@ /** * Health check routes * - * NOTE: Only the basic health check (/) is unauthenticated. + * NOTE: Only the basic health check (/) and environment check are unauthenticated. * The /detailed endpoint requires authentication. */ import { Router } from 'express'; import { createIndexHandler } from './routes/index.js'; +import { createEnvironmentHandler } from './routes/environment.js'; /** * Create unauthenticated health routes (basic check only) @@ -18,6 +19,10 @@ export function createHealthRoutes(): Router { // Basic health check - no sensitive info router.get('/', createIndexHandler()); + // Environment info including containerization status + // This is unauthenticated so the UI can check on startup + router.get('/environment', createEnvironmentHandler()); + return router; } diff --git a/apps/server/src/routes/health/routes/environment.ts b/apps/server/src/routes/health/routes/environment.ts new file mode 100644 index 00000000..ee5f7d53 --- /dev/null +++ b/apps/server/src/routes/health/routes/environment.ts @@ -0,0 +1,20 @@ +/** + * GET /environment endpoint - Environment information including containerization status + * + * This endpoint is unauthenticated so the UI can check it on startup + * before login to determine if sandbox risk warnings should be shown. + */ + +import type { Request, Response } from 'express'; + +export interface EnvironmentResponse { + isContainerized: boolean; +} + +export function createEnvironmentHandler() { + return (_req: Request, res: Response): void => { + res.json({ + isContainerized: process.env.IS_CONTAINERIZED === 'true', + } satisfies EnvironmentResponse); + }; +} diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 4cadb26d..dd2597f5 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; +export { SandboxRejectionScreen } from './sandbox-rejection-screen'; +export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx new file mode 100644 index 00000000..32be56d4 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx @@ -0,0 +1,90 @@ +/** + * Sandbox Rejection Screen + * + * Shown in web mode when user denies the sandbox risk confirmation. + * Prompts them to either restart the app in a container or reload to try again. + */ + +import { useState } from 'react'; +import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRejectionScreen() { + const [copied, setCopied] = useState(false); + + const handleReload = () => { + // Clear the rejection state and reload + sessionStorage.removeItem('automaker-sandbox-denied'); + window.location.reload(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+
+
+
+ +
+
+ +
+

Access Denied

+

+ You declined to accept the risks of running Automaker outside a sandbox environment. +

+
+ +
+
+ +
+

Run in Docker (Recommended)

+

+ Run Automaker in a containerized sandbox environment: +

+
+ {DOCKER_COMMAND} + +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx new file mode 100644 index 00000000..905d82a1 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -0,0 +1,112 @@ +/** + * Sandbox Risk Confirmation Dialog + * + * Shows when the app is running outside a containerized environment. + * Users must acknowledge the risks before proceeding. + */ + +import { useState } from 'react'; +import { ShieldAlert, Copy, Check } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface SandboxRiskDialogProps { + open: boolean; + onConfirm: () => void; + onDeny: () => void; +} + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + showCloseButton={false} + > + + + + Sandbox Environment Not Detected + + +
+

+ Warning: This application is running outside of a containerized + sandbox environment. AI agents will have direct access to your filesystem and can + execute commands on your system. +

+ +
+

Potential Risks:

+
    +
  • Agents can read, modify, or delete files on your system
  • +
  • Agents can execute arbitrary commands and install software
  • +
  • Agents can access environment variables and credentials
  • +
  • Unintended side effects from agent actions may affect your system
  • +
+
+ +
+

+ For safer operation, consider running Automaker in Docker: +

+
+ {DOCKER_COMMAND} + +
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5b3abeab..0a17eae1 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -432,6 +432,7 @@ export interface SaveImageResult { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openDirectory: () => Promise; openFile: (options?: object) => Promise; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1c9d0533..a0455c98 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -294,6 +294,32 @@ export const verifySession = async (): Promise => { } }; +/** + * Check if the server is running in a containerized (sandbox) environment. + * This endpoint is unauthenticated so it can be checked before login. + */ +export const checkSandboxEnvironment = async (): Promise<{ + isContainerized: boolean; + error?: string; +}> => { + try { + const response = await fetch(`${getServerUrl()}/api/health/environment`, { + method: 'GET', + }); + + if (!response.ok) { + console.warn('[HTTP Client] Failed to check sandbox environment'); + return { isContainerized: false, error: 'Failed to check environment' }; + } + + const data = await response.json(); + return { isContainerized: data.isContainerized ?? false }; + } catch (error) { + console.error('[HTTP Client] Sandbox environment check failed:', error); + return { isContainerized: false, error: 'Network error' }; + } +}; + type EventType = | 'agent:stream' | 'auto-mode:event' diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 09fe21a9..e56a2583 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -838,3 +838,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { // Always use the smaller minimum width - horizontal scrolling handles any overflow mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT); }); + +// Quit the application (used when user denies sandbox risk confirmation) +ipcMain.handle('app:quit', () => { + console.log('[Electron] Quitting application via IPC request'); + app.quit(); +}); diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index 4a1aa6f1..0955ab1b 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Window management updateMinWidth: (sidebarExpanded: boolean): Promise => ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded), + + // App control + quit: (): Promise => ipcRenderer.invoke('app:quit'), }); console.log('[Preload] Electron API exposed (TypeScript)'); diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 23a4fa30..79262add 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -8,10 +8,21 @@ import { } from '@/contexts/file-browser-context'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; -import { getElectronAPI } from '@/lib/electron'; -import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client'; +import { getElectronAPI, isElectron } from '@/lib/electron'; +import { + initApiKey, + isElectronMode, + verifySession, + checkSandboxEnvironment, +} from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; +import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; +import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; + +// Session storage key for sandbox risk acknowledgment +const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged'; +const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied'; function RootLayoutContent() { const location = useLocation(); @@ -27,6 +38,20 @@ function RootLayoutContent() { const [isAuthenticated, setIsAuthenticated] = useState(false); const { openFileBrowser } = useFileBrowser(); + // Sandbox environment check state + type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; + const [sandboxStatus, setSandboxStatus] = useState(() => { + // Check if user previously denied in this session + if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) { + return 'denied'; + } + // Check if user previously acknowledged in this session + if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) { + return 'confirmed'; + } + return 'pending'; + }); + // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -73,6 +98,63 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Check sandbox environment on mount + useEffect(() => { + // Skip if already decided + if (sandboxStatus !== 'pending') { + return; + } + + const checkSandbox = async () => { + try { + const result = await checkSandboxEnvironment(); + + if (result.isContainerized) { + // Running in a container, no warning needed + setSandboxStatus('containerized'); + } else { + // Not containerized, show warning dialog + setSandboxStatus('needs-confirmation'); + } + } catch (error) { + console.error('[Sandbox] Failed to check environment:', error); + // On error, assume not containerized and show warning + setSandboxStatus('needs-confirmation'); + } + }; + + checkSandbox(); + }, [sandboxStatus]); + + // Handle sandbox risk confirmation + const handleSandboxConfirm = useCallback(() => { + sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true'); + setSandboxStatus('confirmed'); + }, []); + + // Handle sandbox risk denial + const handleSandboxDeny = useCallback(async () => { + sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true'); + + if (isElectron()) { + // In Electron mode, quit the application + // Use window.electronAPI directly since getElectronAPI() returns the HTTP client + try { + const electronAPI = window.electronAPI; + if (electronAPI?.quit) { + await electronAPI.quit(); + } else { + console.error('[Sandbox] quit() not available on electronAPI'); + } + } catch (error) { + console.error('[Sandbox] Failed to quit app:', error); + } + } else { + // In web mode, show rejection screen + setSandboxStatus('denied'); + } + }, []); + // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) // - Web mode: Uses HTTP-only session cookie @@ -201,11 +283,31 @@ function RootLayoutContent() { const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; + // Show rejection screen if user denied sandbox risk (web mode only) + if (sandboxStatus === 'denied' && !isElectron()) { + return ; + } + + // Show loading while checking sandbox environment + if (sandboxStatus === 'pending') { + return ( +
+
Checking environment...
+
+ ); + } + // Show login page (full screen, no sidebar) if (isLoginRoute) { return (
+ {/* Show sandbox dialog on top of login page if needed */} +
); } @@ -228,6 +330,12 @@ function RootLayoutContent() { return (
+ {/* Show sandbox dialog on top of setup page if needed */} +
); } @@ -249,6 +357,13 @@ function RootLayoutContent() { }`} /> + + {/* Show sandbox dialog if needed */} + ); } diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 44985d6b..15c61f8c 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -465,6 +465,7 @@ export interface AutoModeAPI { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; // Dialog APIs diff --git a/docker-compose.yml b/docker-compose.yml index 8bbf2e84..2026ff0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,10 @@ services: # Optional - CORS origin (default allows all) - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007} + + # Internal - indicates the API is running in a containerized sandbox environment + # This is used by the UI to determine if sandbox risk warnings should be shown + - IS_CONTAINERIZED=true volumes: # ONLY named volumes - these are isolated from your host filesystem # This volume persists data between restarts but is container-managed From 79bf1c9bec93bf8eda83c00d27de89ae243d267e Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 21:07:26 -0500 Subject: [PATCH 12/24] feat: add centralized build validation command and refactor port configuration - Introduced a new command for validating project builds, providing detailed instructions for running builds and intelligently fixing failures based on recent changes. - Refactored port configuration by centralizing it in the @automaker/types package for improved maintainability and backward compatibility. - Updated imports in various modules to reflect the new centralized port configuration, ensuring consistent usage across the application. --- .claude/commands/validate-build.md | 49 ++++++++++++++++++++++++++++++ libs/platform/src/config/ports.ts | 13 ++------ libs/prompts/package.json | 1 - libs/prompts/src/defaults.ts | 2 +- libs/types/src/index.ts | 3 ++ libs/types/src/ports.ts | 15 +++++++++ 6 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 .claude/commands/validate-build.md create mode 100644 libs/types/src/ports.ts diff --git a/.claude/commands/validate-build.md b/.claude/commands/validate-build.md new file mode 100644 index 00000000..790992b1 --- /dev/null +++ b/.claude/commands/validate-build.md @@ -0,0 +1,49 @@ +# Project Build and Fix Command + +Run all builds and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run the build** + + ```bash + npm run build + ``` + + This builds all packages and the UI application. + +2. **If the build succeeds**, report success and stop. + +3. **If the build fails**, analyze the failures: + - Note which build step failed and the error messages + - Check for TypeScript compilation errors, missing dependencies, or configuration issues + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the failure**: + - **If the failure is due to intentional changes** (new features, refactoring, dependency updates): + - Fix any TypeScript type errors introduced by the changes + - Update build configuration if needed (e.g., tsconfig.json, vite.config.mts) + - Ensure all new dependencies are properly installed + - Fix import paths or module resolution issues + + - **If the failure appears to be a regression** (broken imports, missing files, configuration errors): + - Fix the source code to restore the build + - Check for accidentally deleted files or broken references + - Verify build configuration files are correct + +5. **Common build issues to check**: + - **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports + - **Missing dependencies**: Run `npm install` if packages are missing + - **Import/export errors**: Fix incorrect import paths or missing exports + - **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs + - **Package build order**: Ensure `build:packages` completes before building apps + +6. **How to decide if it's intentional vs regression**: + - Look at the git diff and commit messages + - If the change was deliberate and introduced new code that needs fixing → fix the new code + - If the change broke existing functionality that should still build → fix the regression + - When in doubt, ask the user + +7. **After making fixes**, re-run the build to verify everything compiles successfully. + +8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.). diff --git a/libs/platform/src/config/ports.ts b/libs/platform/src/config/ports.ts index 451ecdd7..1089e966 100644 --- a/libs/platform/src/config/ports.ts +++ b/libs/platform/src/config/ports.ts @@ -1,15 +1,8 @@ /** * Centralized port configuration for AutoMaker * - * These ports are reserved for the Automaker application and should never be - * killed or terminated by AI agents during feature implementation. + * Re-exports from @automaker/types for backward compatibility. + * The canonical definition is in @automaker/types to allow browser-safe imports. */ -/** Port for the static/UI server (Vite dev server) */ -export const STATIC_PORT = 3007; - -/** Port for the backend API server (Express + WebSocket) */ -export const SERVER_PORT = 3008; - -/** Array of all reserved Automaker ports */ -export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const; +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from '@automaker/types'; diff --git a/libs/prompts/package.json b/libs/prompts/package.json index 8de01d9b..0012859f 100644 --- a/libs/prompts/package.json +++ b/libs/prompts/package.json @@ -22,7 +22,6 @@ "node": ">=22.0.0 <23.0.0" }, "dependencies": { - "@automaker/platform": "1.0.0", "@automaker/types": "1.0.0" }, "devDependencies": { diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index 57646330..c0ae7e0b 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -16,7 +16,7 @@ import type { ResolvedBacklogPlanPrompts, ResolvedEnhancementPrompts, } from '@automaker/types'; -import { STATIC_PORT, SERVER_PORT } from '@automaker/platform'; +import { STATIC_PORT, SERVER_PORT } from '@automaker/types'; /** * ======================================================================== diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 30a903e1..be714877 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -140,3 +140,6 @@ export type { PipelineStatus, FeatureStatusWithPipeline, } from './pipeline.js'; + +// Port configuration +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; diff --git a/libs/types/src/ports.ts b/libs/types/src/ports.ts new file mode 100644 index 00000000..451ecdd7 --- /dev/null +++ b/libs/types/src/ports.ts @@ -0,0 +1,15 @@ +/** + * Centralized port configuration for AutoMaker + * + * These ports are reserved for the Automaker application and should never be + * killed or terminated by AI agents during feature implementation. + */ + +/** Port for the static/UI server (Vite dev server) */ +export const STATIC_PORT = 3007; + +/** Port for the backend API server (Express + WebSocket) */ +export const SERVER_PORT = 3008; + +/** Array of all reserved Automaker ports */ +export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const; From af493fb73eb12f5aedfdada3b85d5b3c1ac45b92 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 21:21:35 -0500 Subject: [PATCH 13/24] feat: simulate containerized environment for testing - Added an environment variable to simulate a containerized environment, allowing the application to skip sandbox confirmation dialogs during testing. - This change aims to streamline the testing process by reducing unnecessary user interactions while ensuring the application behaves as expected in a containerized setup. --- apps/ui/playwright.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 80ba9af3..5ea2fb7b 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -49,6 +49,8 @@ export default defineConfig({ // Hide the API key banner to reduce log noise AUTOMAKER_HIDE_API_KEY: 'true', // No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing + // Simulate containerized environment to skip sandbox confirmation dialogs + IS_CONTAINERIZED: 'true', }, }, // Frontend Vite dev server From 6337e266c5d2116583c93db75847c7345f377828 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 31 Dec 2025 21:58:22 -0500 Subject: [PATCH 14/24] drag top bar --- apps/ui/src/routes/__root.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 79262add..3608334d 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -9,6 +9,7 @@ import { import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI, isElectron } from '@/lib/electron'; +import { isMac } from '@/lib/utils'; import { initApiKey, isElectronMode, @@ -342,6 +343,13 @@ function RootLayoutContent() { return (
+ {/* Full-width titlebar drag region for Electron window dragging */} + {isElectron() && ( +