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,