mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
- Finalized core migration to Vite, ensuring feature parity and functionality. - Updated migration plan to reflect completed tasks and deferred items. - Renamed `apps/app` to `apps/ui` and adjusted related configurations. - Verified Zustand stores and HTTP API client functionality remain unchanged. - Added additional tasks completed during migration, including environment variable updates and memory history configuration for Electron. This commit marks the transition to the new build infrastructure, setting the stage for further component refactoring.
1766 lines
56 KiB
Markdown
1766 lines
56 KiB
Markdown
# 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<string> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<fs.Stats | null> {
|
|
try {
|
|
return await fs.promises.stat(filePath)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively removes a directory
|
|
*/
|
|
export async function removeDirectory(dirPath: string): Promise<void> {
|
|
await fs.promises.rm(dirPath, { recursive: true, force: true })
|
|
}
|
|
|
|
/**
|
|
* Copies a file or directory
|
|
*/
|
|
export async function copy(src: string, dest: string): Promise<void> {
|
|
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<T = unknown> {
|
|
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<OpenDialogOptions>
|
|
response: DialogResult<string[]>
|
|
}
|
|
"dialog:openFile": {
|
|
request: Partial<OpenDialogOptions>
|
|
response: DialogResult<string[]>
|
|
}
|
|
"dialog:saveFile": {
|
|
request: Partial<SaveDialogOptions>
|
|
response: DialogResult<string>
|
|
}
|
|
|
|
// 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<T extends IPCChannel> = IPCSchema[T]["request"]
|
|
export type IPCResponse<T extends IPCChannel> = 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<FilePickerResult> {
|
|
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<string, string[]>
|
|
}): Promise<FilePickerResult> {
|
|
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
|