diff --git a/.claude/commands/gh-issue.md b/.claude/commands/gh-issue.md new file mode 100644 index 00000000..22c4925b --- /dev/null +++ b/.claude/commands/gh-issue.md @@ -0,0 +1,74 @@ +# GitHub Issue Fix Command + +Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid. + +## Usage + +This command accepts a GitHub issue number as input (e.g., `123`). + +## Instructions + +1. **Get the issue number from the user** + - The issue number should be provided as an argument to this command + - If no number is provided, ask the user for it + +2. **Fetch the GitHub issue** + - Determine the current project path (check if there's a current project context) + - Verify the project has a GitHub remote: + ```bash + git remote get-url origin + ``` + - Fetch the issue details using GitHub CLI: + ```bash + gh issue view --json number,title,state,author,createdAt,labels,url,body,assignees + ``` + - If the command fails, report the error and stop + +3. **Verify the issue is real and valid** + - Check that the issue exists (not 404) + - Check the issue state: + - If **closed**: Inform the user and ask if they still want to proceed + - If **open**: Proceed with validation + - Review the issue content: + - Read the title and body to understand what needs to be fixed + - Check labels for context (bug, enhancement, etc.) + - Note any assignees or linked PRs + +4. **Validate the issue** + - Determine if this is a legitimate issue that needs fixing: + - Is the description clear and actionable? + - Does it describe a real problem or feature request? + - Are there any obvious signs it's spam or invalid? + - If the issue seems invalid or unclear: + - Report findings to the user + - Ask if they want to proceed anyway + - Stop if user confirms it's not valid + +5. **If the issue is valid, proceed to fix it** + - Analyze what needs to be done based on the issue description + - Check the current codebase state: + - Run relevant tests to see current behavior + - Check if the issue is already fixed + - Look for related code that might need changes + - Implement the fix: + - Make necessary code changes + - Update or add tests as needed + - Ensure the fix addresses the issue description + - Verify the fix: + - Run tests to ensure nothing broke + - If possible, manually verify the fix addresses the issue + +6. **Report summary** + - Issue number and title + - Issue state (open/closed) + - Whether the issue was validated as real + - What was fixed (if anything) + - Any tests that were updated or added + - Next steps (if any) + +## Error Handling + +- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop +- If the project doesn't have a GitHub remote, report error and stop +- If the issue number doesn't exist, report error and stop +- If the issue is unclear or invalid, report findings and ask user before proceeding diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 00000000..f768ab53 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,77 @@ +# Release Command + +Bump the package.json version (major, minor, or patch) and build the Electron app with the new version. + +## Usage + +This command accepts a version bump type as input: + +- `patch` - Bump patch version (0.1.0 -> 0.1.1) +- `minor` - Bump minor version (0.1.0 -> 0.2.0) +- `major` - Bump major version (0.1.0 -> 1.0.0) + +## Instructions + +1. **Get the bump type from the user** + - The bump type should be provided as an argument (patch, minor, or major) + - If no type is provided, ask the user which type they want + +2. **Bump the version** + - Run the version bump script: + ```bash + node apps/ui/scripts/bump-version.mjs + ``` + - This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync) + - Verify the version was updated correctly by checking the output + +3. **Build the Electron app** + - Run the electron build: + ```bash + npm run build:electron --workspace=apps/ui + ``` + - The build process automatically: + - Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`) + - Injects the version into the app via Vite's `__APP_VERSION__` constant + - Displays the version below the logo in the sidebar + +4. **Commit the version bump** + - Stage the updated package.json files: + ```bash + git add apps/ui/package.json apps/server/package.json + ``` + - Commit with a release message: + ```bash + git commit -m "chore: release v" + ``` + +5. **Create and push the git tag** + - Create an annotated tag for the release: + ```bash + git tag -a v -m "Release v" + ``` + - Push the commit and tag to remote: + ```bash + git push && git push --tags + ``` + +6. **Verify the release** + - Check that the build completed successfully + - Confirm the version appears correctly in the built artifacts + - The version will be displayed in the app UI below the logo + - Verify the tag is visible on the remote repository + +## Version Centralization + +The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`: + +- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName` +- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`) +- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints) +- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string` + +This ensures consistency across: + +- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`) +- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`) +- Server health endpoints (`/` and `/detailed`) +- Package metadata (both UI and server packages stay in sync) diff --git a/.claude/commands/validate-build.md b/.claude/commands/validate-build.md new file mode 100644 index 00000000..790992b1 --- /dev/null +++ b/.claude/commands/validate-build.md @@ -0,0 +1,49 @@ +# Project Build and Fix Command + +Run all builds and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run the build** + + ```bash + npm run build + ``` + + This builds all packages and the UI application. + +2. **If the build succeeds**, report success and stop. + +3. **If the build fails**, analyze the failures: + - Note which build step failed and the error messages + - Check for TypeScript compilation errors, missing dependencies, or configuration issues + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the failure**: + - **If the failure is due to intentional changes** (new features, refactoring, dependency updates): + - Fix any TypeScript type errors introduced by the changes + - Update build configuration if needed (e.g., tsconfig.json, vite.config.mts) + - Ensure all new dependencies are properly installed + - Fix import paths or module resolution issues + + - **If the failure appears to be a regression** (broken imports, missing files, configuration errors): + - Fix the source code to restore the build + - Check for accidentally deleted files or broken references + - Verify build configuration files are correct + +5. **Common build issues to check**: + - **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports + - **Missing dependencies**: Run `npm install` if packages are missing + - **Import/export errors**: Fix incorrect import paths or missing exports + - **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs + - **Package build order**: Ensure `build:packages` completes before building apps + +6. **How to decide if it's intentional vs regression**: + - Look at the git diff and commit messages + - If the change was deliberate and introduced new code that needs fixing → fix the new code + - If the change broke existing functionality that should still build → fix the regression + - When in doubt, ask the user + +7. **After making fixes**, re-run the build to verify everything compiles successfully. + +8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.). diff --git a/.claude/commands/validate-tests.md b/.claude/commands/validate-tests.md new file mode 100644 index 00000000..3a19b5d1 --- /dev/null +++ b/.claude/commands/validate-tests.md @@ -0,0 +1,36 @@ +# Project Test and Fix Command + +Run all tests and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run all tests** + + ```bash + npm run test:all + ``` + +2. **If all tests pass**, report success and stop. + +3. **If any tests fail**, analyze the failures: + - Note which tests failed and their error messages + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the change**: + - **If the logic change is intentional** (new feature, refactor, behavior change): + - Update the failing tests to match the new expected behavior + - The tests should reflect what the code NOW does correctly + + - **If the logic change appears to be a bug** (regression, unintended side effect): + - Fix the source code to restore the expected behavior + - Do NOT modify the tests - they are catching a real bug + +5. **How to decide if it's a bug vs intentional change**: + - Look at the git diff and commit messages + - If the change was deliberate and the test expectations are now outdated → update tests + - If the change broke existing functionality that should still work → fix the code + - When in doubt, ask the user + +6. **After making fixes**, re-run the tests to verify everything passes. + +7. **Report summary** of what was fixed (tests updated vs code fixed). diff --git a/.claude_settings.json b/.claude_settings.json deleted file mode 100644 index 969f1214..00000000 --- a/.claude_settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "defaultMode": "acceptEdits", - "allow": [ - "Read(./**)", - "Write(./**)", - "Edit(./**)", - "Glob(./**)", - "Grep(./**)", - "Bash(*)", - "mcp__puppeteer__puppeteer_navigate", - "mcp__puppeteer__puppeteer_screenshot", - "mcp__puppeteer__puppeteer_click", - "mcp__puppeteer__puppeteer_fill", - "mcp__puppeteer__puppeteer_select", - "mcp__puppeteer__puppeteer_hover", - "mcp__puppeteer__puppeteer_evaluate" - ] - } -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..af6bb48b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,117 @@ +name: Bug Report +description: File a bug report to help us improve Automaker +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible. + + - type: dropdown + id: operating-system + attributes: + label: Operating System + description: What operating system are you using? + options: + - macOS + - Windows + - Linux + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: run-mode + attributes: + label: Run Mode + description: How are you running Automaker? + options: + - Electron (Desktop App) + - Web (Browser) + - Docker + default: 0 + validations: + required: true + + - type: input + id: app-version + attributes: + label: App Version + description: What version of Automaker are you using? (e.g., 0.1.0) + placeholder: '0.1.0' + validations: + required: true + + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: What should have happened? + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: A clear and concise description of what actually happened. + placeholder: What actually happened? + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. + placeholder: Drag and drop screenshots here or paste image URLs + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: If applicable, paste relevant logs or error messages. + placeholder: Paste logs here... + render: shell + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context about the problem here. + placeholder: Any additional information that might be helpful... + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this bug hasn't been reported already + required: true + - label: I have provided all required information above + required: true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..42126c05 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +22 + diff --git a/CHANGELOG_RATE_LIMIT_HANDLING.md b/CHANGELOG_RATE_LIMIT_HANDLING.md deleted file mode 100644 index 35e73b0e..00000000 --- a/CHANGELOG_RATE_LIMIT_HANDLING.md +++ /dev/null @@ -1,134 +0,0 @@ -# Improved Error Handling for Rate Limiting - -## Problem - -When running multiple features concurrently in auto-mode, the Claude API rate limits were being exceeded, resulting in cryptic error messages: - -``` -Error: Claude Code process exited with code 1 -``` - -This error provided no actionable information to users about: - -- What went wrong (rate limit exceeded) -- How long to wait before retrying -- How to prevent it in the future - -## Root Cause - -The Claude Agent SDK was terminating with exit code 1 when hitting rate limits (HTTP 429), but the error details were not being properly surfaced to the user. The error handling code only showed the generic exit code message instead of the actual API error. - -## Solution - -Implemented comprehensive rate limit error handling across the stack: - -### 1. Enhanced Error Classification (libs/utils) - -Added new error type and detection functions: - -- **New error type**: `'rate_limit'` added to `ErrorType` union -- **`isRateLimitError()`**: Detects 429 and rate_limit errors -- **`extractRetryAfter()`**: Extracts retry duration from error messages -- **Updated `classifyError()`**: Includes rate limit classification with retry-after metadata -- **Updated `getUserFriendlyErrorMessage()`**: Provides clear, actionable messages for rate limit errors - -### 2. Improved Claude Provider Error Handling (apps/server) - -Enhanced `ClaudeProvider.executeQuery()` to: - -- Classify all errors using the enhanced error utilities -- Detect rate limit errors specifically -- Provide user-friendly error messages with: - - Clear explanation of the problem (rate limit exceeded) - - Retry-after duration when available - - Actionable tip: reduce `maxConcurrency` in auto-mode -- Preserve original error details for debugging - -### 3. Comprehensive Test Coverage - -Added 8 new tests covering: - -- Rate limit error detection (429, rate_limit keywords) -- Retry-after extraction from various message formats -- Error classification with retry metadata -- User-friendly message generation -- Edge cases (null/undefined, non-rate-limit errors) - -**Total test suite**: 162 tests passing ✅ - -## User-Facing Changes - -### Before - -``` -[AutoMode] Feature touch-gesture-support failed: Error: Claude Code process exited with code 1 -``` - -### After - -``` -[AutoMode] Feature touch-gesture-support failed: Rate limit exceeded (429). Please wait 60 seconds before retrying. - -Tip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits. -``` - -## Benefits - -1. **Clear communication**: Users understand exactly what went wrong -2. **Actionable guidance**: Users know how long to wait and how to prevent future errors -3. **Better debugging**: Original error details preserved for technical investigation -4. **Type safety**: New `isRateLimit` and `retryAfter` fields properly typed in `ErrorInfo` -5. **Comprehensive testing**: All edge cases covered with automated tests - -## Technical Details - -### Files Modified - -- `libs/types/src/error.ts` - Added `'rate_limit'` type and `retryAfter` field -- `libs/utils/src/error-handler.ts` - Added rate limit detection and extraction logic -- `libs/utils/src/index.ts` - Exported new utility functions -- `libs/utils/tests/error-handler.test.ts` - Added 8 new test cases -- `apps/server/src/providers/claude-provider.ts` - Enhanced error handling with user-friendly messages - -### API Changes - -**ErrorInfo interface** (backwards compatible): - -```typescript -interface ErrorInfo { - type: ErrorType; // Now includes 'rate_limit' - message: string; - isAbort: boolean; - isAuth: boolean; - isCancellation: boolean; - isRateLimit: boolean; // NEW - retryAfter?: number; // NEW (seconds to wait) - originalError: unknown; -} -``` - -**New utility functions**: - -```typescript -isRateLimitError(error: unknown): boolean -extractRetryAfter(error: unknown): number | undefined -``` - -## Future Improvements - -This PR lays the groundwork for future enhancements: - -1. **Automatic retry with exponential backoff**: Use `retryAfter` to implement smart retry logic -2. **Global rate limiter**: Track requests to prevent hitting limits proactively -3. **Concurrency auto-adjustment**: Dynamically reduce concurrency when rate limits are detected -4. **User notifications**: Show toast/banner when rate limits are approaching - -## Testing - -Run tests with: - -```bash -npm run test -w @automaker/utils -``` - -All 162 tests pass, including 8 new rate limit tests. diff --git a/README.md b/README.md index 67dd17dd..c8e1b84e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Automaker Logo + Automaker Logo

> **[!TIP]** @@ -81,22 +81,6 @@ Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthro The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic. ---- - -> **[!CAUTION]** -> -> ## Security Disclaimer -> -> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.** -> -> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it. -> -> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine. -> -> **[Read the full disclaimer](./DISCLAIMER.md)** - ---- - ## Community & Support Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows. @@ -624,6 +608,22 @@ data/ └── {sessionId}.json ``` +--- + +> **[!CAUTION]** +> +> ## Security Disclaimer +> +> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.** +> +> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it. +> +> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine. +> +> **[Read the full disclaimer](./DISCLAIMER.md)** + +--- + ## Learn More ### Documentation 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/package.json b/apps/server/package.json index 1eb415a8..65f1222e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,10 +1,13 @@ { "name": "@automaker/server", - "version": "0.1.0", + "version": "0.7.3", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "type": "module", "main": "dist/index.js", "scripts": { @@ -21,35 +24,35 @@ "test:unit": "vitest run tests/unit" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.72", - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/git-utils": "^1.0.0", - "@automaker/model-resolver": "^1.0.0", - "@automaker/platform": "^1.0.0", - "@automaker/prompts": "^1.0.0", - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.25.1", - "cookie-parser": "^1.4.7", - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.2.1", - "morgan": "^1.10.1", + "@anthropic-ai/claude-agent-sdk": "0.1.76", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/git-utils": "1.0.0", + "@automaker/model-resolver": "1.0.0", + "@automaker/platform": "1.0.0", + "@automaker/prompts": "1.0.0", + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0", + "@modelcontextprotocol/sdk": "1.25.1", + "cookie-parser": "1.4.7", + "cors": "2.8.5", + "dotenv": "17.2.3", + "express": "5.2.1", + "morgan": "1.10.1", "node-pty": "1.1.0-beta41", - "ws": "^8.18.3" + "ws": "8.18.3" }, "devDependencies": { - "@types/cookie": "^0.6.0", - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/morgan": "^1.9.10", - "@types/node": "^22", - "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "tsx": "^4.21.0", - "typescript": "^5", - "vitest": "^4.0.16" + "@types/cookie": "0.6.0", + "@types/cookie-parser": "1.4.10", + "@types/cors": "2.8.19", + "@types/express": "5.0.6", + "@types/morgan": "1.9.10", + "@types/node": "22.19.3", + "@types/ws": "8.18.1", + "@vitest/coverage-v8": "4.0.16", + "@vitest/ui": "4.0.16", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 769b63ab..0f97255f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -133,7 +133,11 @@ app.use( } // For local development, allow localhost origins - if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) { + if ( + origin.startsWith('http://localhost:') || + origin.startsWith('http://127.0.0.1:') || + origin.startsWith('http://[::1]:') + ) { callback(null, origin); return; } diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index d8629d61..5f24b319 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,9 +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), { + await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { encoding: 'utf-8', mode: 0o600, }); @@ -101,8 +101,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 +115,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', mode: 0o600 }); console.log('[Auth] Generated new API key'); } catch (error) { console.error('[Auth] Failed to save API key:', error); diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 327ec059..d9b78398 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -16,6 +16,7 @@ */ import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import os from 'os'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; @@ -47,6 +48,128 @@ export function validateWorkingDirectory(cwd: string): void { } } +/** + * Known cloud storage path patterns where sandbox mode is incompatible. + * + * The Claude CLI sandbox feature uses filesystem isolation that conflicts with + * cloud storage providers' virtual filesystem implementations. This causes the + * Claude process to exit with code 1 when sandbox is enabled for these paths. + * + * Affected providers (macOS paths): + * - Dropbox: ~/Library/CloudStorage/Dropbox-* + * - Google Drive: ~/Library/CloudStorage/GoogleDrive-* + * - OneDrive: ~/Library/CloudStorage/OneDrive-* + * - iCloud Drive: ~/Library/Mobile Documents/ + * - Box: ~/Library/CloudStorage/Box-* + * + * @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue) + */ + +/** + * macOS-specific cloud storage patterns that appear under ~/Library/ + * These are specific enough to use with includes() safely. + */ +const MACOS_CLOUD_STORAGE_PATTERNS = [ + '/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS + '/Library/Mobile Documents/', // iCloud Drive on macOS +] as const; + +/** + * Generic cloud storage folder names that need to be anchored to the home directory + * to avoid false positives (e.g., /home/user/my-project-about-dropbox/). + */ +const HOME_ANCHORED_CLOUD_FOLDERS = [ + 'Google Drive', // Google Drive on some systems + 'Dropbox', // Dropbox on Linux/alternative installs + 'OneDrive', // OneDrive on Linux/alternative installs +] as const; + +/** + * Check if a path is within a cloud storage location. + * + * Cloud storage providers use virtual filesystem implementations that are + * incompatible with the Claude CLI sandbox feature, causing process crashes. + * + * Uses two detection strategies: + * 1. macOS-specific patterns (under ~/Library/) - checked via includes() + * 2. Generic folder names - anchored to home directory to avoid false positives + * + * @param cwd - The working directory path to check + * @returns true if the path is in a cloud storage location + */ +export function isCloudStoragePath(cwd: string): boolean { + const resolvedPath = path.resolve(cwd); + + // Check macOS-specific patterns (these are specific enough to use includes) + if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => resolvedPath.includes(pattern))) { + return true; + } + + // Check home-anchored patterns to avoid false positives + // e.g., /home/user/my-project-about-dropbox/ should NOT match + const home = os.homedir(); + for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) { + const cloudPath = path.join(home, folder); + // Check if resolved path starts with the cloud storage path followed by a separator + // This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool + if (resolvedPath === cloudPath || resolvedPath.startsWith(cloudPath + path.sep)) { + return true; + } + } + + return false; +} + +/** + * Result of sandbox compatibility check + */ +export interface SandboxCheckResult { + /** Whether sandbox should be enabled */ + enabled: boolean; + /** If disabled, the reason why */ + disabledReason?: 'cloud_storage' | 'user_setting'; + /** Human-readable message for logging/UI */ + message?: string; +} + +/** + * Determine if sandbox mode should be enabled for a given configuration. + * + * Sandbox mode is automatically disabled for cloud storage paths because the + * Claude CLI sandbox feature is incompatible with virtual filesystem + * implementations used by cloud storage providers (Dropbox, Google Drive, etc.). + * + * @param cwd - The working directory + * @param enableSandboxMode - User's sandbox mode setting + * @returns SandboxCheckResult with enabled status and reason if disabled + */ +export function checkSandboxCompatibility( + cwd: string, + enableSandboxMode?: boolean +): SandboxCheckResult { + // User has explicitly disabled sandbox mode + if (enableSandboxMode === false) { + return { + enabled: false, + disabledReason: 'user_setting', + }; + } + + // Check for cloud storage incompatibility (applies when enabled or undefined) + if (isCloudStoragePath(cwd)) { + return { + enabled: false, + disabledReason: 'cloud_storage', + message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`, + }; + } + + // Sandbox is compatible and enabled (true or undefined defaults to enabled) + return { + enabled: true, + }; +} + /** * Tool presets for different use cases */ @@ -381,7 +504,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option * - Full tool access for code modification * - Standard turns for interactive sessions * - Model priority: explicit model > session model > chat default - * - Sandbox mode controlled by enableSandboxMode setting + * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { @@ -397,6 +520,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), @@ -406,7 +532,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, @@ -425,7 +551,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { * - Full tool access for code modification and implementation * - Extended turns for thorough feature implementation * - Uses default model (can be overridden) - * - Sandbox mode controlled by enableSandboxMode setting + * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { @@ -438,6 +564,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), @@ -447,7 +576,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, 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/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 36775315..b6e86ff2 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -74,7 +74,7 @@ export async function getEnableSandboxModeSetting( try { const globalSettings = await settingsService.getGlobalSettings(); - const result = globalSettings.enableSandboxMode ?? true; + const result = globalSettings.enableSandboxMode ?? false; logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`); return result; } catch (error) { diff --git a/apps/server/src/lib/version.ts b/apps/server/src/lib/version.ts new file mode 100644 index 00000000..61e182e3 --- /dev/null +++ b/apps/server/src/lib/version.ts @@ -0,0 +1,33 @@ +/** + * Version utility - Reads version from package.json + */ + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let cachedVersion: string | null = null; + +/** + * Get the version from package.json + * Caches the result for performance + */ +export function getVersion(): string { + if (cachedVersion) { + return cachedVersion; + } + + try { + const packageJsonPath = join(__dirname, '..', '..', 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const version = packageJson.version || '0.0.0'; + cachedVersion = version; + return version; + } catch (error) { + console.warn('Failed to read version from package.json:', error); + return '0.0.0'; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 286a733f..33494535 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -15,6 +15,32 @@ import type { ModelDefinition, } from './types.js'; +// Explicit allowlist of environment variables to pass to the SDK. +// Only these vars are passed - nothing else from process.env leaks through. +const ALLOWED_ENV_VARS = [ + 'ANTHROPIC_API_KEY', + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', +]; + +/** + * Build environment for the SDK with only explicitly allowed variables + */ +function buildEnv(): Record { + const env: Record = {}; + for (const key of ALLOWED_ENV_VARS) { + if (process.env[key]) { + env[key] = process.env[key]; + } + } + return env; +} + export class ClaudeProvider extends BaseProvider { getName(): string { return 'claude'; @@ -57,6 +83,8 @@ export class ClaudeProvider extends BaseProvider { systemPrompt, maxTurns, cwd, + // Pass only explicitly allowed environment variables to SDK + env: buildEnv(), // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), diff --git a/apps/server/src/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/health/index.ts b/apps/server/src/routes/health/index.ts index 688fdbc5..083a8703 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -1,12 +1,13 @@ /** * Health check routes * - * NOTE: Only the basic health check (/) is unauthenticated. + * NOTE: Only the basic health check (/) and environment check are unauthenticated. * The /detailed endpoint requires authentication. */ import { Router } from 'express'; import { createIndexHandler } from './routes/index.js'; +import { createEnvironmentHandler } from './routes/environment.js'; /** * Create unauthenticated health routes (basic check only) @@ -18,6 +19,10 @@ export function createHealthRoutes(): Router { // Basic health check - no sensitive info router.get('/', createIndexHandler()); + // Environment info including containerization status + // This is unauthenticated so the UI can check on startup + router.get('/environment', createEnvironmentHandler()); + return router; } diff --git a/apps/server/src/routes/health/routes/detailed.ts b/apps/server/src/routes/health/routes/detailed.ts index 5aa2e6b1..d5198466 100644 --- a/apps/server/src/routes/health/routes/detailed.ts +++ b/apps/server/src/routes/health/routes/detailed.ts @@ -4,13 +4,14 @@ import type { Request, Response } from 'express'; import { getAuthStatus } from '../../../lib/auth.js'; +import { getVersion } from '../../../lib/version.js'; export function createDetailedHandler() { return (_req: Request, res: Response): void => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - version: process.env.npm_package_version || '0.1.0', + version: getVersion(), uptime: process.uptime(), memory: process.memoryUsage(), dataDir: process.env.DATA_DIR || './data', diff --git a/apps/server/src/routes/health/routes/environment.ts b/apps/server/src/routes/health/routes/environment.ts new file mode 100644 index 00000000..ee5f7d53 --- /dev/null +++ b/apps/server/src/routes/health/routes/environment.ts @@ -0,0 +1,20 @@ +/** + * GET /environment endpoint - Environment information including containerization status + * + * This endpoint is unauthenticated so the UI can check it on startup + * before login to determine if sandbox risk warnings should be shown. + */ + +import type { Request, Response } from 'express'; + +export interface EnvironmentResponse { + isContainerized: boolean; +} + +export function createEnvironmentHandler() { + return (_req: Request, res: Response): void => { + res.json({ + isContainerized: process.env.IS_CONTAINERIZED === 'true', + } satisfies EnvironmentResponse); + }; +} diff --git a/apps/server/src/routes/health/routes/index.ts b/apps/server/src/routes/health/routes/index.ts index 1501f6a6..f956a96f 100644 --- a/apps/server/src/routes/health/routes/index.ts +++ b/apps/server/src/routes/health/routes/index.ts @@ -3,13 +3,14 @@ */ import type { Request, Response } from 'express'; +import { getVersion } from '../../../lib/version.js'; export function createIndexHandler() { return (_req: Request, res: Response): void => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - version: process.env.npm_package_version || '0.1.0', + version: getVersion(), }); }; } 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/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 6527ab77..4f63a382 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -158,8 +158,13 @@ export const logError = createLogError(logger); /** * Ensure the repository has at least one commit so git commands that rely on HEAD work. * Returns true if an empty commit was created, false if the repo already had commits. + * @param repoPath - Path to the git repository + * @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL) */ -export async function ensureInitialCommit(repoPath: string): Promise { +export async function ensureInitialCommit( + repoPath: string, + env?: Record +): Promise { try { await execAsync('git rev-parse --verify HEAD', { cwd: repoPath }); return false; @@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise { try { await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, { cwd: repoPath, + env: { ...process.env, ...env }, }); logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`); return true; diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 943d3bdd..4eb2b2c9 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -100,7 +100,14 @@ export function createCreateHandler() { } // Ensure the repository has at least one commit so worktree commands referencing HEAD succeed - await ensureInitialCommit(projectPath); + // Pass git identity env vars so commits work without global git config + const gitEnv = { + GIT_AUTHOR_NAME: 'Automaker', + GIT_AUTHOR_EMAIL: 'automaker@localhost', + GIT_COMMITTER_NAME: 'Automaker', + GIT_COMMITTER_EMAIL: 'automaker@localhost', + }; + await ensureInitialCommit(projectPath, gitEnv); // First, check if git already has a worktree for this branch (anywhere) const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 5460b02c..32081cd0 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -208,6 +208,10 @@ interface AutoModeConfig { projectPath: string; } +// Constants for consecutive failure tracking +const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures +const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive + export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); @@ -218,12 +222,89 @@ export class AutoModeService { private config: AutoModeConfig | null = null; private pendingApprovals = new Map(); private settingsService: SettingsService | null = null; + // Track consecutive failures to detect quota/API issues + private consecutiveFailures: { timestamp: number; error: string }[] = []; + private pausedDueToFailures = false; constructor(events: EventEmitter, settingsService?: SettingsService) { this.events = events; this.settingsService = settingsService ?? null; } + /** + * Track a failure and check if we should pause due to consecutive failures. + * This handles cases where the SDK doesn't return useful error messages. + */ + private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean { + const now = Date.now(); + + // Add this failure + this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); + + // Remove old failures outside the window + this.consecutiveFailures = this.consecutiveFailures.filter( + (f) => now - f.timestamp < FAILURE_WINDOW_MS + ); + + // Check if we've hit the threshold + if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { + return true; // Should pause + } + + // Also immediately pause for known quota/rate limit errors + if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { + return true; + } + + return false; + } + + /** + * Signal that we should pause due to repeated failures or quota exhaustion. + * This will pause the auto loop to prevent repeated failures. + */ + private signalShouldPause(errorInfo: { type: string; message: string }): void { + if (this.pausedDueToFailures) { + return; // Already paused + } + + this.pausedDueToFailures = true; + const failureCount = this.consecutiveFailures.length; + console.log( + `[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` + ); + + // Emit event to notify UI + this.emitAutoModeEvent('auto_mode_paused_failures', { + message: + failureCount >= CONSECUTIVE_FAILURE_THRESHOLD + ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` + : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', + errorType: errorInfo.type, + originalError: errorInfo.message, + failureCount, + projectPath: this.config?.projectPath, + }); + + // Stop the auto loop + this.stopAutoLoop(); + } + + /** + * Reset failure tracking (called when user manually restarts auto mode) + */ + private resetFailureTracking(): void { + this.consecutiveFailures = []; + this.pausedDueToFailures = false; + } + + /** + * Record a successful feature completion to reset consecutive failure count + */ + private recordSuccess(): void { + this.consecutiveFailures = []; + } + /** * Start the auto mode loop - continuously picks and executes pending features */ @@ -232,6 +313,9 @@ export class AutoModeService { throw new Error('Auto mode is already running'); } + // Reset failure tracking when user manually starts auto mode + this.resetFailureTracking(); + this.autoLoopRunning = true; this.autoLoopAbortController = new AbortController(); this.config = { @@ -520,6 +604,9 @@ export class AutoModeService { const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Record success to reset consecutive failure tracking + this.recordSuccess(); + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, @@ -547,6 +634,21 @@ export class AutoModeService { errorType: errorInfo.type, projectPath, }); + + // Track this failure and check if we should pause auto mode + // This handles both specific quota/rate limit errors AND generic failures + // that may indicate quota exhaustion (SDK doesn't always return useful errors) + const shouldPause = this.trackFailureAndCheckPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + + if (shouldPause) { + this.signalShouldPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + } } } finally { console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`); @@ -707,6 +809,11 @@ Complete the pipeline step instructions above. Review the previous work and appl this.cancelPlanApproval(featureId); running.abortController.abort(); + + // Remove from running features immediately to allow resume + // The abort signal will still propagate to stop any ongoing execution + this.runningFeatures.delete(featureId); + return true; } @@ -1180,6 +1287,9 @@ Address the follow-up instructions above. Review the previous work and make the const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Record success to reset consecutive failure tracking + this.recordSuccess(); + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, @@ -1195,6 +1305,19 @@ Address the follow-up instructions above. Review the previous work and make the errorType: errorInfo.type, projectPath, }); + + // Track this failure and check if we should pause auto mode + const shouldPause = this.trackFailureAndCheckPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + + if (shouldPause) { + this.signalShouldPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + } } } finally { this.runningFeatures.delete(featureId); @@ -2194,7 +2317,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }; // Execute via provider + console.log(`[AutoMode] Starting stream for feature ${featureId}...`); const stream = provider.executeQuery(executeOptions); + console.log(`[AutoMode] Stream created, starting to iterate...`); // Initialize with previous content if this is a follow-up, with a separator let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` @@ -2232,6 +2357,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }; streamLoop: for await (const msg of stream) { + console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || ''); if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { @@ -2687,6 +2813,9 @@ Implement all the changes described in the plan above.`; // Only emit progress for non-marker text (marker was already handled above) if (!specDetected) { + console.log( + `[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}` + ); this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index d8c7d083..098ce29c 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -179,7 +179,12 @@ export class ClaudeUsageService { if (!settled) { settled = true; ptyProcess.kill(); - reject(new Error('Command timed out')); + // Don't fail if we have data - return it instead + if (output.includes('Current session')) { + resolve(output); + } else { + reject(new Error('Command timed out')); + } } }, this.timeout); @@ -193,6 +198,13 @@ export class ClaudeUsageService { setTimeout(() => { if (!settled) { ptyProcess.write('\x1b'); // Send escape key + + // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s + setTimeout(() => { + if (!settled) { + ptyProcess.kill('SIGTERM'); + } + }, 2000); } }, 2000); } diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index fbf86d49..4f1b937c 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -185,9 +185,8 @@ export class FeatureLoader { })) as any[]; const featureDirs = entries.filter((entry) => entry.isDirectory()); - // Load each feature - const features: Feature[] = []; - for (const dir of featureDirs) { + // Load all features concurrently (secureFs has built-in concurrency limiting) + const featurePromises = featureDirs.map(async (dir) => { const featureId = dir.name; const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); @@ -199,13 +198,13 @@ export class FeatureLoader { logger.warn( `[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping` ); - continue; + return null; } - features.push(feature); + return feature as Feature; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - continue; + return null; } else if (error instanceof SyntaxError) { logger.warn( `[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}` @@ -216,8 +215,12 @@ export class FeatureLoader { (error as Error).message ); } + return null; } - } + }); + + const results = await Promise.all(featurePromises); + const features = results.filter((f): f is Feature => f !== null); // Sort by creation order (feature IDs contain timestamp) features.sort((a, b) => { diff --git a/apps/server/src/services/mcp-test-service.ts b/apps/server/src/services/mcp-test-service.ts index d1662722..4232de60 100644 --- a/apps/server/src/services/mcp-test-service.ts +++ b/apps/server/src/services/mcp-test-service.ts @@ -9,10 +9,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; import type { MCPServerConfig, MCPToolInfo } from '@automaker/types'; import type { SettingsService } from './settings-service.js'; +const execAsync = promisify(exec); const DEFAULT_TIMEOUT = 10000; // 10 seconds +const IS_WINDOWS = process.platform === 'win32'; export interface MCPTestResult { success: boolean; @@ -41,6 +45,11 @@ export class MCPTestService { async testServer(serverConfig: MCPServerConfig): Promise { const startTime = Date.now(); let client: Client | null = null; + let transport: + | StdioClientTransport + | SSEClientTransport + | StreamableHTTPClientTransport + | null = null; try { client = new Client({ @@ -49,7 +58,7 @@ export class MCPTestService { }); // Create transport based on server type - const transport = await this.createTransport(serverConfig); + transport = await this.createTransport(serverConfig); // Connect with timeout await Promise.race([ @@ -98,13 +107,47 @@ export class MCPTestService { connectionTime, }; } finally { - // Clean up client connection - if (client) { - try { - await client.close(); - } catch { - // Ignore cleanup errors - } + // Clean up client connection and ensure process termination + await this.cleanupConnection(client, transport); + } + } + + /** + * Clean up MCP client connection and terminate spawned processes + * + * On Windows, child processes spawned via 'cmd /c' don't get terminated when the + * parent process is killed. We use taskkill with /t flag to kill the entire process tree. + * This prevents orphaned MCP server processes that would spam logs with ping warnings. + * + * IMPORTANT: We must run taskkill BEFORE client.close() because: + * - client.close() kills only the parent cmd.exe process + * - This orphans the child node.exe processes before we can kill them + * - taskkill /t needs the parent PID to exist to traverse the process tree + */ + private async cleanupConnection( + client: Client | null, + transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null + ): Promise { + // Get the PID before any cleanup (only available for stdio transports) + const pid = transport instanceof StdioClientTransport ? transport.pid : null; + + // On Windows with stdio transport, kill the entire process tree FIRST + // This must happen before client.close() which would orphan child processes + if (IS_WINDOWS && pid) { + try { + // taskkill /f = force, /t = kill process tree, /pid = process ID + await execAsync(`taskkill /f /t /pid ${pid}`); + } catch { + // Process may have already exited, which is fine + } + } + + // Now do the standard close (may be a no-op if taskkill already killed everything) + if (client) { + try { + await client.close(); + } catch { + // Expected if taskkill already terminated the process } } } diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 288bde18..a88d2421 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -124,6 +124,8 @@ export class SettingsService { * Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward * compatibility during schema migrations. * + * Also applies version-based migrations for breaking changes. + * * @returns Promise resolving to complete GlobalSettings object */ async getGlobalSettings(): Promise { @@ -131,7 +133,7 @@ export class SettingsService { const settings = await readJsonFile(settingsPath, DEFAULT_GLOBAL_SETTINGS); // Apply any missing defaults (for backwards compatibility) - return { + let result: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...settings, keyboardShortcuts: { @@ -139,6 +141,32 @@ export class SettingsService { ...settings.keyboardShortcuts, }, }; + + // Version-based migrations + const storedVersion = settings.version || 1; + let needsSave = false; + + // Migration v1 -> v2: Force enableSandboxMode to false for existing users + // Sandbox mode can cause issues on some systems, so we're disabling it by default + if (storedVersion < 2) { + logger.info('Migrating settings from v1 to v2: disabling sandbox mode'); + result.enableSandboxMode = false; + result.version = SETTINGS_VERSION; + needsSave = true; + } + + // Save migrated settings if needed + if (needsSave) { + try { + await ensureDataDir(this.dataDir); + await atomicWriteJson(settingsPath, result); + logger.info('Settings migration complete'); + } catch (error) { + logger.error('Failed to save migrated settings:', error); + } + } + + return result; } /** diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 7d59633e..81a1585a 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -8,8 +8,18 @@ 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, + getShellPaths, +} from '@automaker/platform'; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter { /** * Detect the best shell for the current platform + * Uses getShellPaths() to iterate through allowed shell paths */ detectShell(): { shell: string; args: string[] } { const platform = os.platform(); + const shellPaths = getShellPaths(); - // Check if running in WSL + // Helper to get basename handling both path separators + const getBasename = (shellPath: string): string => { + const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); + return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; + }; + + // Helper to get shell args based on shell name + const getShellArgs = (shell: string): string[] => { + const shellName = getBasename(shell).toLowerCase().replace('.exe', ''); + // PowerShell and cmd don't need --login + if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { + return []; + } + // sh doesn't support --login in all implementations + if (shellName === 'sh') { + return []; + } + // bash, zsh, and other POSIX shells support --login + return ['--login']; + }; + + // Check if running in WSL - prefer user's shell or bash with --login if (platform === 'linux' && this.isWSL()) { - // In WSL, prefer the user's configured shell or bash - const userShell = process.env.SHELL || '/bin/bash'; - if (fs.existsSync(userShell)) { - return { shell: userShell, args: ['--login'] }; + const userShell = process.env.SHELL; + if (userShell) { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgs(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } + } + } + } + // Fall back to first available POSIX shell + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgs(shell) }; + } + } catch { + // Path not allowed, continue + } } return { shell: '/bin/bash', args: ['--login'] }; } - switch (platform) { - case 'win32': { - // Windows: prefer PowerShell, fall back to cmd - const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; - const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; - - if (fs.existsSync(pwshCore)) { - return { shell: pwshCore, args: [] }; + // For all platforms: first try user's shell if set + const userShell = process.env.SHELL; + if (userShell && platform !== 'win32') { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgs(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } } - if (fs.existsSync(pwsh)) { - return { shell: pwsh, args: [] }; - } - return { shell: 'cmd.exe', args: [] }; - } - - case 'darwin': { - // macOS: prefer user's shell, then zsh, then bash - const userShell = process.env.SHELL; - if (userShell && fs.existsSync(userShell)) { - return { shell: userShell, args: ['--login'] }; - } - if (fs.existsSync('/bin/zsh')) { - return { shell: '/bin/zsh', args: ['--login'] }; - } - return { shell: '/bin/bash', args: ['--login'] }; - } - - case 'linux': - default: { - // Linux: prefer user's shell, then bash, then sh - const userShell = process.env.SHELL; - if (userShell && fs.existsSync(userShell)) { - return { shell: userShell, args: ['--login'] }; - } - if (fs.existsSync('/bin/bash')) { - return { shell: '/bin/bash', args: ['--login'] }; - } - return { shell: '/bin/sh', args: [] }; } } + + // Iterate through allowed shell paths and return first existing one + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgs(shell) }; + } + } catch { + // Path not allowed or doesn't exist, continue to next + } + } + + // Ultimate fallbacks based on platform + if (platform === 'win32') { + return { shell: 'cmd.exe', args: [] }; + } + return { shell: '/bin/sh', args: [] }; } /** @@ -122,8 +168,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 +204,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 +235,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 +280,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,12 +293,23 @@ 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 + // First, create a clean copy of process.env excluding Automaker-specific variables + // that could pollute user shells (e.g., PORT would affect Next.js/other dev servers) + const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH']; + const cleanEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !automakerEnvVars.includes(key)) { + cleanEnv[key] = value; + } + } + const env: Record = { - ...process.env, + ...cleanEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'automaker-terminal', diff --git a/apps/server/tests/integration/helpers/git-test-repo.ts b/apps/server/tests/integration/helpers/git-test-repo.ts index 4ec95926..7871e8e8 100644 --- a/apps/server/tests/integration/helpers/git-test-repo.ts +++ b/apps/server/tests/integration/helpers/git-test-repo.ts @@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise { // Initialize git repo await execAsync('git init', { cwd: tmpDir }); - await execAsync('git config user.email "test@example.com"', { cwd: tmpDir }); - await execAsync('git config user.name "Test User"', { cwd: tmpDir }); + + // Use environment variables instead of git config to avoid affecting user's git config + // These env vars override git config without modifying it + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', + }; // Create initial commit await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n'); - await execAsync('git add .', { cwd: tmpDir }); - await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); + await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); + await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); // Create main branch explicitly await execAsync('git branch -M main', { cwd: tmpDir }); diff --git a/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/apps/server/tests/integration/routes/worktree/create.integration.test.ts index 433b610a..6d274a0d 100644 --- a/apps/server/tests/integration/routes/worktree/create.integration.test.ts +++ b/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => { async function initRepoWithoutCommit() { repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); await execAsync('git init', { cwd: repoPath }); - await execAsync('git config user.email "test@example.com"', { - cwd: repoPath, - }); - await execAsync('git config user.name "Test User"', { cwd: repoPath }); + // Don't set git config - use environment variables in commit operations instead + // to avoid affecting user's git config // Intentionally skip creating an initial commit } diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index e5d4c7c0..3faea516 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -1,15 +1,161 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; describe('sdk-options.ts', () => { let originalEnv: NodeJS.ProcessEnv; + let homedirSpy: ReturnType; beforeEach(() => { originalEnv = { ...process.env }; vi.resetModules(); + // Spy on os.homedir and set default return value + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test'); }); afterEach(() => { process.env = originalEnv; + homedirSpy.mockRestore(); + }); + + describe('isCloudStoragePath', () => { + it('should detect Dropbox paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe( + true + ); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true); + }); + + it('should detect Google Drive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect( + isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project') + ).toBe(true); + }); + + it('should detect OneDrive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe( + true + ); + }); + + it('should detect iCloud Drive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect( + isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project') + ).toBe(true); + }); + + it('should detect home-anchored Dropbox paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true); + expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true); + expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true); + }); + + it('should detect home-anchored Google Drive paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true); + expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true); + }); + + it('should detect home-anchored OneDrive paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true); + expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true); + }); + + it('should return false for local paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false); + expect(isCloudStoragePath('/home/user/code/project')).toBe(false); + expect(isCloudStoragePath('/var/www/app')).toBe(false); + }); + + it('should return false for relative paths not in cloud storage', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('./project')).toBe(false); + expect(isCloudStoragePath('../other-project')).toBe(false); + }); + + // Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage + it('should NOT flag paths that merely contain "dropbox" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + // Projects with dropbox-like names + expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false); + expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false); + expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false); + // Dropbox folder that's NOT in the home directory + expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false); + }); + + it('should NOT flag paths that merely contain "Google Drive" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false); + expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false); + }); + + it('should NOT flag paths that merely contain "OneDrive" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false); + expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false); + }); + + it('should handle different home directories correctly', async () => { + // Change the mocked home directory + homedirSpy.mockReturnValue('/home/linuxuser'); + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + + // Should detect Dropbox under the Linux home directory + expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true); + // Should NOT detect Dropbox under the old home directory (since home changed) + expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false); + }); + }); + + describe('checkSandboxCompatibility', () => { + it('should return enabled=false when user disables sandbox', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', false); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('user_setting'); + }); + + it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility( + '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + true + ); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('cloud_storage'); + expect(result.message).toContain('cloud storage'); + }); + + it('should return enabled=true for local paths when sandbox enabled', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/projects/myapp', true); + expect(result.enabled).toBe(true); + expect(result.disabledReason).toBeUndefined(); + }); + + it('should return enabled=true when enableSandboxMode is undefined for local paths', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', undefined); + expect(result.enabled).toBe(true); + expect(result.disabledReason).toBeUndefined(); + }); + + it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility( + '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + undefined + ); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('cloud_storage'); + }); }); describe('TOOL_PRESETS', () => { @@ -224,13 +370,27 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); - it('should not set sandbox when enableSandboxMode is not provided', async () => { + it('should enable sandbox by default when enableSandboxMode is not provided', async () => { const { createChatOptions } = await import('@/lib/sdk-options.js'); const options = createChatOptions({ cwd: '/test/path', }); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + it('should auto-disable sandbox for cloud storage paths', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + enableSandboxMode: true, + }); + expect(options.sandbox).toBeUndefined(); }); }); @@ -285,13 +445,48 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); - it('should not set sandbox when enableSandboxMode is not provided', async () => { + it('should enable sandbox by default when enableSandboxMode is not provided', async () => { const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); const options = createAutoModeOptions({ cwd: '/test/path', }); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + it('should auto-disable sandbox for cloud storage paths', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + enableSandboxMode: true, + }); + + expect(options.sandbox).toBeUndefined(); + }); + + it('should auto-disable sandbox for cloud storage paths even when enableSandboxMode is not provided', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + }); + + expect(options.sandbox).toBeUndefined(); + }); + + it('should auto-disable sandbox for iCloud paths', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project', + enableSandboxMode: true, + }); + expect(options.sandbox).toBeUndefined(); }); }); diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 983e5806..d16802f6 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -485,7 +485,7 @@ Resets in 2h await expect(promise).rejects.toThrow('Authentication required'); }); - it('should handle timeout', async () => { + it('should handle timeout with no data', async () => { vi.useFakeTimers(); mockSpawnProcess.stdout = { @@ -619,7 +619,7 @@ Resets in 2h await expect(promise).rejects.toThrow('Authentication required'); }); - it('should handle timeout on Windows', async () => { + it('should handle timeout with no data on Windows', async () => { vi.useFakeTimers(); const windowsService = new ClaudeUsageService(); @@ -640,5 +640,69 @@ Resets in 2h vi.useRealTimers(); }); + + it('should return data on timeout if data was captured', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = windowsService.fetchUsageData(); + + // Simulate receiving usage data + dataCallback!('Current session\n65% left\nResets in 2h'); + + // Advance time past timeout (30 seconds) + vi.advanceTimersByTime(31000); + + // Should resolve with data instead of rejecting + const result = await promise; + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(mockPty.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should send SIGTERM after ESC if process does not exit', async () => { + vi.useFakeTimers(); + const windowsService = new ClaudeUsageService(); + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + windowsService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!('Current session\n65% left'); + + // Advance 2s to trigger ESC + vi.advanceTimersByTime(2100); + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + // Advance another 2s to trigger SIGTERM fallback + vi.advanceTimersByTime(2100); + expect(mockPty.kill).toHaveBeenCalledWith('SIGTERM'); + + vi.useRealTimers(); + }); }); }); diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index 44e823b0..88660f7f 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -2,16 +2,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { TerminalService, getTerminalService } from '@/services/terminal-service.js'; import * as pty from 'node-pty'; import * as os from 'os'; -import * as fs from 'fs'; +import * as platform from '@automaker/platform'; +import * as secureFs from '@/lib/secure-fs.js'; vi.mock('node-pty'); -vi.mock('fs'); vi.mock('os'); +vi.mock('@automaker/platform', async () => { + const actual = await vi.importActual('@automaker/platform'); + return { + ...actual, + systemPathExists: vi.fn(), + systemPathReadFileSync: vi.fn(), + getWslVersionPath: vi.fn(), + getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing + isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests + }; +}); +vi.mock('@/lib/secure-fs.js'); describe('terminal-service.ts', () => { let service: TerminalService; let mockPtyProcess: any; + // Shell paths for each platform (matching system-paths.ts) + const linuxShellPaths = [ + '/bin/zsh', + '/bin/bash', + '/bin/sh', + '/usr/bin/zsh', + '/usr/bin/bash', + '/usr/bin/sh', + '/usr/local/bin/zsh', + '/usr/local/bin/bash', + '/opt/homebrew/bin/zsh', + '/opt/homebrew/bin/bash', + 'zsh', + 'bash', + 'sh', + ]; + + const windowsShellPaths = [ + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\cmd.exe', + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', + ]; + beforeEach(() => { vi.clearAllMocks(); service = new TerminalService(); @@ -29,6 +71,13 @@ describe('terminal-service.ts', () => { vi.mocked(os.homedir).mockReturnValue('/home/user'); vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); + + // Default mocks for system paths and secureFs + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue(''); + vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version'); + vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); }); afterEach(() => { @@ -38,7 +87,8 @@ describe('terminal-service.ts', () => { describe('detectShell', () => { it('should detect PowerShell Core on Windows when available', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; }); @@ -50,7 +100,8 @@ describe('terminal-service.ts', () => { it('should fall back to PowerShell on Windows if Core not available', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; }); @@ -62,7 +113,8 @@ describe('terminal-service.ts', () => { it('should fall back to cmd.exe on Windows if no PowerShell', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -73,7 +125,7 @@ describe('terminal-service.ts', () => { it('should detect user shell on macOS', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' }); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); const result = service.detectShell(); @@ -84,7 +136,7 @@ describe('terminal-service.ts', () => { it('should fall back to zsh on macOS if user shell not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === '/bin/zsh'; }); @@ -97,7 +149,10 @@ describe('terminal-service.ts', () => { it('should fall back to bash on macOS if zsh not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockReturnValue(false); + // zsh not available, but bash is + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === '/bin/bash'; + }); const result = service.detectShell(); @@ -108,7 +163,7 @@ describe('terminal-service.ts', () => { it('should detect user shell on Linux', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); const result = service.detectShell(); @@ -119,7 +174,7 @@ describe('terminal-service.ts', () => { it('should fall back to bash on Linux if user shell not available', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === '/bin/bash'; }); @@ -132,7 +187,7 @@ describe('terminal-service.ts', () => { it('should fall back to sh on Linux if bash not available', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -143,8 +198,10 @@ describe('terminal-service.ts', () => { it('should detect WSL and use appropriate shell', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); const result = service.detectShell(); @@ -155,43 +212,45 @@ describe('terminal-service.ts', () => { describe('isWSL', () => { it('should return true if /proc/version contains microsoft', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); expect(service.isWSL()).toBe(true); }); it('should return true if /proc/version contains wsl', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); expect(service.isWSL()).toBe(true); }); it('should return true if WSL_DISTRO_NAME is set', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' }); expect(service.isWSL()).toBe(true); }); it('should return true if WSLENV is set', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' }); expect(service.isWSL()).toBe(true); }); it('should return false if not in WSL', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({}); expect(service.isWSL()).toBe(false); }); it('should return false if error reading /proc/version', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockImplementation(() => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => { throw new Error('Permission denied'); }); @@ -203,7 +262,7 @@ describe('terminal-service.ts', () => { it('should return platform information', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const info = service.getPlatformInfo(); @@ -216,20 +275,21 @@ describe('terminal-service.ts', () => { }); describe('createSession', () => { - it('should create a new terminal session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should create a new terminal session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '/test/dir', cols: 100, rows: 30, }); - expect(session.id).toMatch(/^term-/); - expect(session.cwd).toBe('/test/dir'); - expect(session.shell).toBe('/bin/bash'); + expect(session).not.toBeNull(); + expect(session!.id).toMatch(/^term-/); + expect(session!.cwd).toBe('/test/dir'); + expect(session!.shell).toBe('/bin/bash'); expect(pty.spawn).toHaveBeenCalledWith( '/bin/bash', ['--login'], @@ -241,12 +301,12 @@ describe('terminal-service.ts', () => { ); }); - it('should use default cols and rows if not provided', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should use default cols and rows if not provided', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - service.createSession(); + await service.createSession(); expect(pty.spawn).toHaveBeenCalledWith( expect.any(String), @@ -258,66 +318,68 @@ describe('terminal-service.ts', () => { ); }); - it('should fall back to home directory if cwd does not exist', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockImplementation(() => { - throw new Error('ENOENT'); - }); + it('should fall back to home directory if cwd does not exist', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT')); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '/nonexistent', }); - expect(session.cwd).toBe('/home/user'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/home/user'); }); - it('should fall back to home directory if cwd is not a directory', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any); + it('should fall back to home directory if cwd is not a directory', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '/file.txt', }); - expect(session.cwd).toBe('/home/user'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/home/user'); }); - it('should fix double slashes in path', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should fix double slashes in path', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '//test/dir', }); - expect(session.cwd).toBe('/test/dir'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/test/dir'); }); - it('should preserve WSL UNC paths', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should preserve WSL UNC paths', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession({ + const session = await service.createSession({ cwd: '//wsl$/Ubuntu/home', }); - expect(session.cwd).toBe('//wsl$/Ubuntu/home'); + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('//wsl$/Ubuntu/home'); }); - it('should handle data events from PTY', () => { + it('should handle data events from PTY', async () => { vi.useFakeTimers(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const dataCallback = vi.fn(); service.onData(dataCallback); - service.createSession(); + await service.createSession(); // Simulate data event const onDataHandler = mockPtyProcess.onData.mock.calls[0][0]; @@ -331,33 +393,34 @@ describe('terminal-service.ts', () => { vi.useRealTimers(); }); - it('should handle exit events from PTY', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle exit events from PTY', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const exitCallback = vi.fn(); service.onExit(exitCallback); - const session = service.createSession(); + const session = await service.createSession(); // Simulate exit event const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0]; onExitHandler({ exitCode: 0 }); - expect(exitCallback).toHaveBeenCalledWith(session.id, 0); - expect(service.getSession(session.id)).toBeUndefined(); + expect(session).not.toBeNull(); + expect(exitCallback).toHaveBeenCalledWith(session!.id, 0); + expect(service.getSession(session!.id)).toBeUndefined(); }); }); describe('write', () => { - it('should write data to existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should write data to existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const result = service.write(session.id, 'ls\n'); + const session = await service.createSession(); + const result = service.write(session!.id, 'ls\n'); expect(result).toBe(true); expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n'); @@ -372,13 +435,13 @@ describe('terminal-service.ts', () => { }); describe('resize', () => { - it('should resize existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should resize existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const result = service.resize(session.id, 120, 40); + const session = await service.createSession(); + const result = service.resize(session!.id, 120, 40); expect(result).toBe(true); expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); @@ -391,30 +454,30 @@ describe('terminal-service.ts', () => { expect(mockPtyProcess.resize).not.toHaveBeenCalled(); }); - it('should handle resize errors', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle resize errors', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.resize.mockImplementation(() => { throw new Error('Resize failed'); }); - const session = service.createSession(); - const result = service.resize(session.id, 120, 40); + const session = await service.createSession(); + const result = service.resize(session!.id, 120, 40); expect(result).toBe(false); }); }); describe('killSession', () => { - it('should kill existing session', () => { + it('should kill existing session', async () => { vi.useFakeTimers(); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const result = service.killSession(session.id); + const session = await service.createSession(); + const result = service.killSession(session!.id); expect(result).toBe(true); expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM'); @@ -423,7 +486,7 @@ describe('terminal-service.ts', () => { vi.advanceTimersByTime(1000); expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL'); - expect(service.getSession(session.id)).toBeUndefined(); + expect(service.getSession(session!.id)).toBeUndefined(); vi.useRealTimers(); }); @@ -434,29 +497,29 @@ describe('terminal-service.ts', () => { expect(result).toBe(false); }); - it('should handle kill errors', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle kill errors', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { throw new Error('Kill failed'); }); - const session = service.createSession(); - const result = service.killSession(session.id); + const session = await service.createSession(); + const result = service.killSession(session!.id); expect(result).toBe(false); }); }); describe('getSession', () => { - it('should return existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should return existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - const retrieved = service.getSession(session.id); + const session = await service.createSession(); + const retrieved = service.getSession(session!.id); expect(retrieved).toBe(session); }); @@ -469,15 +532,15 @@ describe('terminal-service.ts', () => { }); describe('getScrollback', () => { - it('should return scrollback buffer for existing session', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should return scrollback buffer for existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session = service.createSession(); - session.scrollbackBuffer = 'test scrollback'; + const session = await service.createSession(); + session!.scrollbackBuffer = 'test scrollback'; - const scrollback = service.getScrollback(session.id); + const scrollback = service.getScrollback(session!.id); expect(scrollback).toBe('test scrollback'); }); @@ -490,19 +553,21 @@ describe('terminal-service.ts', () => { }); describe('getAllSessions', () => { - it('should return all active sessions', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should return all active sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session1 = service.createSession({ cwd: '/dir1' }); - const session2 = service.createSession({ cwd: '/dir2' }); + const session1 = await service.createSession({ cwd: '/dir1' }); + const session2 = await service.createSession({ cwd: '/dir2' }); const sessions = service.getAllSessions(); expect(sessions).toHaveLength(2); - expect(sessions[0].id).toBe(session1.id); - expect(sessions[1].id).toBe(session2.id); + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(sessions[0].id).toBe(session1!.id); + expect(sessions[1].id).toBe(session2!.id); expect(sessions[0].cwd).toBe('/dir1'); expect(sessions[1].cwd).toBe('/dir2'); }); @@ -535,30 +600,32 @@ describe('terminal-service.ts', () => { }); describe('cleanup', () => { - it('should clean up all sessions', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should clean up all sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session1 = service.createSession(); - const session2 = service.createSession(); + const session1 = await service.createSession(); + const session2 = await service.createSession(); service.cleanup(); - expect(service.getSession(session1.id)).toBeUndefined(); - expect(service.getSession(session2.id)).toBeUndefined(); + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(service.getSession(session1!.id)).toBeUndefined(); + expect(service.getSession(session2!.id)).toBeUndefined(); expect(service.getAllSessions()).toHaveLength(0); }); - it('should handle cleanup errors gracefully', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle cleanup errors gracefully', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { throw new Error('Kill failed'); }); - service.createSession(); + await service.createSession(); expect(() => service.cleanup()).not.toThrow(); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index b069e28c..2a5c1ecc 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.1.0", + "version": "0.7.3", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { @@ -10,6 +10,9 @@ "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "main": "dist-electron/main.js", "scripts": { "dev": "vite", @@ -35,87 +38,87 @@ "dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite" }, "dependencies": { - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/types": "^1.0.0", - "@codemirror/lang-xml": "^6.1.0", - "@codemirror/theme-one-dark": "^6.1.3", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@lezer/highlight": "^1.2.3", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-router": "^1.141.6", - "@uiw/react-codemirror": "^4.25.4", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.10.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "dagre": "^0.8.5", - "dotenv": "^17.2.3", - "geist": "^1.5.1", - "lucide-react": "^0.562.0", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/types": "1.0.0", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/theme-one-dark": "6.1.3", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@lezer/highlight": "1.2.3", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-query": "5.90.12", + "@tanstack/react-router": "1.141.6", + "@uiw/react-codemirror": "4.25.4", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "@xyflow/react": "12.10.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "dagre": "0.8.5", + "dotenv": "17.2.3", + "geist": "1.5.1", + "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^3.0.6", - "rehype-raw": "^7.0.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "usehooks-ts": "^3.1.1", - "zustand": "^5.0.9" + "react-markdown": "10.1.0", + "react-resizable-panels": "3.0.6", + "rehype-raw": "7.0.0", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "usehooks-ts": "3.1.1", + "zustand": "5.0.9" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "^1.29.2", - "lightningcss-darwin-x64": "^1.29.2", - "lightningcss-linux-arm-gnueabihf": "^1.29.2", - "lightningcss-linux-arm64-gnu": "^1.29.2", - "lightningcss-linux-arm64-musl": "^1.29.2", - "lightningcss-linux-x64-gnu": "^1.29.2", - "lightningcss-linux-x64-musl": "^1.29.2", - "lightningcss-win32-arm64-msvc": "^1.29.2", - "lightningcss-win32-x64-msvc": "^1.29.2" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" }, "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@eslint/js": "^9.0.0", - "@playwright/test": "^1.57.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.141.7", - "@types/dagre": "^0.7.53", - "@types/node": "^22", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.50.0", - "@typescript-eslint/parser": "^8.50.0", - "@vitejs/plugin-react": "^5.1.2", - "cross-env": "^10.1.0", + "@electron/rebuild": "4.0.2", + "@eslint/js": "9.0.0", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "4.1.18", + "@tanstack/router-plugin": "1.141.7", + "@types/dagre": "0.7.53", + "@types/node": "22.19.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@vitejs/plugin-react": "5.1.2", + "cross-env": "10.1.0", "electron": "39.2.7", - "electron-builder": "^26.0.12", - "eslint": "^9.39.2", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", + "electron-builder": "26.0.12", + "eslint": "9.39.2", + "tailwindcss": "4.1.18", + "tw-animate-css": "1.4.0", "typescript": "5.9.3", - "vite": "^7.3.0", - "vite-plugin-electron": "^0.29.0", - "vite-plugin-electron-renderer": "^0.14.6" + "vite": "7.3.0", + "vite-plugin-electron": "0.29.0", + "vite-plugin-electron-renderer": "0.14.6" }, "build": { "appId": "com.automaker.app", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 80ba9af3..5ea2fb7b 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -49,6 +49,8 @@ export default defineConfig({ // Hide the API key banner to reduce log noise AUTOMAKER_HIDE_API_KEY: 'true', // No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing + // Simulate containerized environment to skip sandbox confirmation dialogs + IS_CONTAINERIZED: 'true', }, }, // Frontend Vite dev server diff --git a/apps/ui/public/readme_logo.svg b/apps/ui/public/readme_logo.svg new file mode 100644 index 00000000..86177aea --- /dev/null +++ b/apps/ui/public/readme_logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + automaker. + + diff --git a/apps/ui/scripts/bump-version.mjs b/apps/ui/scripts/bump-version.mjs new file mode 100755 index 00000000..ae4d9516 --- /dev/null +++ b/apps/ui/scripts/bump-version.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Bumps the version in apps/ui/package.json and apps/server/package.json + * Usage: node scripts/bump-version.mjs [major|minor|patch] + * Example: node scripts/bump-version.mjs patch + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const bumpType = process.argv[2]?.toLowerCase(); + +if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) { + console.error('Error: Bump type argument is required'); + console.error('Usage: node scripts/bump-version.mjs [major|minor|patch]'); + console.error('Example: node scripts/bump-version.mjs patch'); + process.exit(1); +} + +const uiPackageJsonPath = join(__dirname, '..', 'package.json'); +const serverPackageJsonPath = join(__dirname, '..', '..', 'server', 'package.json'); + +function bumpVersion(packageJsonPath, packageName) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const oldVersion = packageJson.version; + + // Parse version + const versionParts = oldVersion.split('.').map(Number); + if (versionParts.length !== 3) { + console.error(`Error: Invalid version format in ${packageName}: ${oldVersion}`); + console.error('Expected format: X.Y.Z (e.g., 1.2.3)'); + process.exit(1); + } + + // Bump version + let [major, minor, patch] = versionParts; + + switch (bumpType) { + case 'major': + major += 1; + minor = 0; + patch = 0; + break; + case 'minor': + minor += 1; + patch = 0; + break; + case 'patch': + patch += 1; + break; + } + + const newVersion = `${major}.${minor}.${patch}`; + packageJson.version = newVersion; + + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8'); + + return newVersion; + } catch (error) { + console.error(`Error bumping version in ${packageName}: ${error.message}`); + process.exit(1); + } +} + +try { + // Bump UI package version + const uiOldVersion = JSON.parse(readFileSync(uiPackageJsonPath, 'utf8')).version; + const uiNewVersion = bumpVersion(uiPackageJsonPath, '@automaker/ui'); + + // Bump server package version (sync with UI) + const serverOldVersion = JSON.parse(readFileSync(serverPackageJsonPath, 'utf8')).version; + const serverNewVersion = bumpVersion(serverPackageJsonPath, '@automaker/server'); + + // Verify versions match + if (uiNewVersion !== serverNewVersion) { + console.error(`Error: Version mismatch! UI: ${uiNewVersion}, Server: ${serverNewVersion}`); + process.exit(1); + } + + console.log(`✅ Bumped version from ${uiOldVersion} to ${uiNewVersion} (${bumpType})`); + console.log(`📦 Updated @automaker/ui: ${uiOldVersion} -> ${uiNewVersion}`); + console.log(`📦 Updated @automaker/server: ${serverOldVersion} -> ${serverNewVersion}`); + console.log(`📦 Version is now: ${uiNewVersion}`); +} catch (error) { + console.error(`Error bumping version: ${error.message}`); + process.exit(1); +} diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index 2738ec79..c1acdfd9 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client'; import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings'; import { toast } from 'sonner'; import { @@ -62,7 +62,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa // Update preview image when background settings change useEffect(() => { if (currentProject && backgroundSettings.imagePath) { - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); // Add cache-busting query parameter to force browser to reload image const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`; const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 4cadb26d..dd2597f5 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; +export { SandboxRejectionScreen } from './sandbox-rejection-screen'; +export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx new file mode 100644 index 00000000..32be56d4 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx @@ -0,0 +1,90 @@ +/** + * Sandbox Rejection Screen + * + * Shown in web mode when user denies the sandbox risk confirmation. + * Prompts them to either restart the app in a container or reload to try again. + */ + +import { useState } from 'react'; +import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRejectionScreen() { + const [copied, setCopied] = useState(false); + + const handleReload = () => { + // Clear the rejection state and reload + sessionStorage.removeItem('automaker-sandbox-denied'); + window.location.reload(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+
+
+
+ +
+
+ +
+

Access Denied

+

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

+
+ +
+
+ +
+

Run in Docker (Recommended)

+

+ Run Automaker in a containerized sandbox environment: +

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

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

+ +
+

Potential Risks:

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

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

+
+ {DOCKER_COMMAND} + +
+
+
+
+
+ + +
+ setSkipInFuture(checked === true)} + data-testid="sandbox-skip-checkbox" + /> + +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx index 66345b92..ac8ed22d 100644 --- a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx +++ b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx @@ -7,6 +7,8 @@ interface AutomakerLogoProps { } export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + return (
{!sidebarOpen ? ( -
+
+ + v{appVersion} +
) : ( -
- - - - - - - - - - - - +
+ - - - - - - - automaker. + + + + + + + + + + + + + + + + + + automaker. + +
+ + v{appVersion}
)} diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index 0a84ed8a..9df5e0e6 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import { ImageIcon, X, Loader2, FileText } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea'; import { getElectronAPI } from '@/lib/electron'; +import { getServerUrlSync } from '@/lib/http-api-client'; import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store'; import { sanitizeFilename, @@ -93,7 +94,7 @@ export function DescriptionImageDropZone({ // Construct server URL for loading saved images const getImageServerUrl = useCallback( (imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); const projectPath = currentProject?.path || ''; return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; }, diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2c39c1fe..65564304 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -206,6 +206,7 @@ export function BoardView() { checkContextExists, features: hookFeatures, isLoading, + featuresWithContext, setFeaturesWithContext, }); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 762d5a66..9cfcaee7 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -145,7 +145,7 @@ export function CardActions({ Verify - ) : hasContext && onResume ? ( + ) : onResume ? ( - ) : onVerify ? ( - ) : null} {onViewOutput && !feature.skipTests && (
-
-
-
-
- -
-
-

{project.name}

-

{project.path}

+
+ {/* Sandbox Warning Reset */} + {skipSandboxWarning && ( +
+
+
+ +
+
+

Sandbox Warning Disabled

+

+ The sandbox environment warning is hidden on startup +

+
+
- -
+ )} + + {/* Project Delete */} + {project && ( +
+
+
+ +
+
+

{project.name}

+

{project.path}

+
+
+ +
+ )} + + {/* Empty state when nothing to show */} + {!skipSandboxWarning && !project && ( +

+ No danger zone actions available. +

+ )}
); diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 2d2068c5..1b0c19b7 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -13,6 +13,7 @@ import { SquarePlus, Settings, } from 'lucide-react'; +import { getServerUrlSync } from '@/lib/http-api-client'; import { useAppStore, type TerminalPanelContent, @@ -272,7 +273,7 @@ export function TerminalView() { // Get the default run script from terminal settings const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript); - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); // Helper to collect all session IDs from all tabs const collectAllSessionIds = useCallback((): string[] => { diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index f7991873..674c87de 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -40,7 +40,7 @@ import { } from '@/config/terminal-themes'; import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; -import { getApiKey, getSessionToken } from '@/lib/http-api-client'; +import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client'; // Font size constraints const MIN_FONT_SIZE = 8; @@ -483,7 +483,7 @@ export function TerminalPanel({ [closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal] ); - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); const wsUrl = serverUrl.replace(/^http/, 'ws'); // Fetch a short-lived WebSocket token for secure authentication diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 568ca182..7abc86c2 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -18,7 +18,7 @@ */ import { useEffect, useState, useRef } from 'react'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client'; import { isElectron } from '@/lib/electron'; import { getItem, removeItem } from '@/lib/storage'; import { useAppStore } from '@/store/app-store'; @@ -99,6 +99,10 @@ export function useSettingsMigration(): MigrationState { } try { + // Wait for API key to be initialized before making any API calls + // This prevents 401 errors on startup in Electron mode + await waitForApiKeyInit(); + const api = getHttpApiClient(); // Check if server has settings files @@ -222,6 +226,7 @@ export async function syncSettingsToServer(): Promise { validationModel: state.validationModel, autoLoadClaudeMd: state.autoLoadClaudeMd, enableSandboxMode: state.enableSandboxMode, + skipSandboxWarning: state.skipSandboxWarning, keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, diff --git a/apps/ui/src/lib/api-fetch.ts b/apps/ui/src/lib/api-fetch.ts index a9d00b8f..fc76c266 100644 --- a/apps/ui/src/lib/api-fetch.ts +++ b/apps/ui/src/lib/api-fetch.ts @@ -9,16 +9,10 @@ * Use this instead of raw fetch() for all authenticated API calls. */ -import { getApiKey, getSessionToken } from './http-api-client'; +import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'; -// Server URL - configurable via environment variable -const getServerUrl = (): string => { - if (typeof window !== 'undefined') { - const envUrl = import.meta.env.VITE_SERVER_URL; - if (envUrl) return envUrl; - } - return 'http://localhost:3008'; -}; +// Server URL - uses shared cached URL from http-api-client +const getServerUrl = (): string => getServerUrlSync(); export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5b3abeab..58125806 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -95,7 +95,7 @@ import type { } from '@/types/electron'; // Import HTTP API client (ES module) -import { getHttpApiClient } from './http-api-client'; +import { getHttpApiClient, getServerUrlSync } from './http-api-client'; // Feature type - Import from app-store import type { Feature } from '@/store/app-store'; @@ -432,6 +432,7 @@ export interface SaveImageResult { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openDirectory: () => Promise; openFile: (options?: object) => Promise; @@ -694,7 +695,7 @@ export const checkServerAvailable = async (): Promise => { serverCheckPromise = (async () => { try { - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); const response = await fetch(`${serverUrl}/api/health`, { method: 'GET', signal: AbortSignal.timeout(2000), diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b856bd51..32bd88f8 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -32,8 +32,34 @@ import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; -// Server URL - configurable via environment variable +// Cached server URL (set during initialization in Electron mode) +let cachedServerUrl: string | null = null; + +/** + * Initialize server URL from Electron IPC. + * Must be called early in Electron mode before making API requests. + */ +export const initServerUrl = async (): Promise => { + // window.electronAPI is typed as ElectronAPI, but some Electron-only helpers + // (like getServerUrl) are not part of the shared interface. Narrow via `any`. + const electron = typeof window !== 'undefined' ? (window.electronAPI as any) : null; + if (electron?.getServerUrl) { + try { + cachedServerUrl = await electron.getServerUrl(); + console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl); + } catch (error) { + console.warn('[HTTP Client] Failed to get server URL from Electron:', error); + } + } +}; + +// Server URL - uses cached value from IPC or environment variable const getServerUrl = (): string => { + // Use cached URL from Electron IPC if available + if (cachedServerUrl) { + return cachedServerUrl; + } + if (typeof window !== 'undefined') { const envUrl = import.meta.env.VITE_SERVER_URL; if (envUrl) return envUrl; @@ -41,9 +67,15 @@ const getServerUrl = (): string => { return 'http://localhost:3008'; }; +/** + * Get the server URL (exported for use in other modules) + */ +export const getServerUrlSync = (): string => getServerUrl(); + // Cached API key for authentication (Electron mode only) let cachedApiKey: string | null = null; let apiKeyInitialized = false; +let apiKeyInitPromise: Promise | null = null; // Cached session token for authentication (Web mode - explicit header auth) let cachedSessionToken: string | null = null; @@ -52,6 +84,17 @@ let cachedSessionToken: string | null = null; // Exported for use in WebSocket connections that need auth export const getApiKey = (): string | null => cachedApiKey; +/** + * Wait for API key initialization to complete. + * Returns immediately if already initialized. + */ +export const waitForApiKeyInit = (): Promise => { + if (apiKeyInitialized) return Promise.resolve(); + if (apiKeyInitPromise) return apiKeyInitPromise; + // If not started yet, start it now + return initApiKey(); +}; + // Get session token for Web mode (returns cached value after login or token fetch) export const getSessionToken = (): string | null => cachedSessionToken; @@ -69,34 +112,56 @@ export const clearSessionToken = (): void => { * Check if we're running in Electron mode */ export const isElectronMode = (): boolean => { - return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey; + if (typeof window === 'undefined') return false; + + // Prefer a stable runtime marker from preload. + // In some dev/electron setups, method availability can be temporarily undefined + // during early startup, but `isElectron` remains reliable. + const api = window.electronAPI as any; + return api?.isElectron === true || !!api?.getApiKey; }; /** - * Initialize API key for Electron mode authentication. + * Initialize API key and server URL for Electron mode authentication. * In web mode, authentication uses HTTP-only cookies instead. * * This should be called early in app initialization. */ export const initApiKey = async (): Promise => { + // Return existing promise if already in progress + if (apiKeyInitPromise) return apiKeyInitPromise; + + // Return immediately if already initialized if (apiKeyInitialized) return; - apiKeyInitialized = true; - // Only Electron mode uses API key header auth - if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { + // Create and store the promise so concurrent calls wait for the same initialization + apiKeyInitPromise = (async () => { try { - cachedApiKey = await window.electronAPI.getApiKey(); - if (cachedApiKey) { - console.log('[HTTP Client] Using API key from Electron'); - return; - } - } catch (error) { - console.warn('[HTTP Client] Failed to get API key from Electron:', error); - } - } + // Initialize server URL from Electron IPC first (needed for API requests) + await initServerUrl(); - // In web mode, authentication is handled via HTTP-only cookies - console.log('[HTTP Client] Web mode - using cookie-based authentication'); + // Only Electron mode uses API key header auth + if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { + try { + cachedApiKey = await window.electronAPI.getApiKey(); + if (cachedApiKey) { + console.log('[HTTP Client] Using API key from Electron'); + return; + } + } catch (error) { + console.warn('[HTTP Client] Failed to get API key from Electron:', error); + } + } + + // In web mode, authentication is handled via HTTP-only cookies + console.log('[HTTP Client] Web mode - using cookie-based authentication'); + } finally { + // Mark as initialized after completion, regardless of success or failure + apiKeyInitialized = true; + } + })(); + + return apiKeyInitPromise; }; /** @@ -251,7 +316,9 @@ export const verifySession = async (): Promise => { // Try to clear the cookie via logout (fire and forget) fetch(`${getServerUrl()}/api/auth/logout`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: '{}', }).catch(() => {}); return false; } @@ -269,12 +336,39 @@ export const verifySession = async (): Promise => { } }; +/** + * Check if the server is running in a containerized (sandbox) environment. + * This endpoint is unauthenticated so it can be checked before login. + */ +export const checkSandboxEnvironment = async (): Promise<{ + isContainerized: boolean; + error?: string; +}> => { + try { + const response = await fetch(`${getServerUrl()}/api/health/environment`, { + method: 'GET', + }); + + if (!response.ok) { + console.warn('[HTTP Client] Failed to check sandbox environment'); + return { isContainerized: false, error: 'Failed to check environment' }; + } + + const data = await response.json(); + return { isContainerized: data.isContainerized ?? false }; + } catch (error) { + console.error('[HTTP Client] Sandbox environment check failed:', error); + return { isContainerized: false, error: 'Network error' }; + } +}; + type EventType = | 'agent:stream' | 'auto-mode:event' | 'suggestions:event' | 'spec-regeneration:event' - | 'issue-validation:event'; + | 'issue-validation:event' + | 'backlog-plan:event'; type EventCallback = (payload: unknown) => void; @@ -296,7 +390,20 @@ export class HttpApiClient implements ElectronAPI { constructor() { this.serverUrl = getServerUrl(); - this.connectWebSocket(); + // Electron mode: connect WebSocket immediately once API key is ready. + // Web mode: defer WebSocket connection until a consumer subscribes to events, + // to avoid noisy 401s on first-load/login/setup routes. + if (isElectronMode()) { + waitForApiKeyInit() + .then(() => { + this.connectWebSocket(); + }) + .catch((error) => { + console.error('[HttpApiClient] API key initialization failed:', error); + // Still attempt WebSocket connection - it may work with cookie auth + this.connectWebSocket(); + }); + } } /** @@ -344,9 +451,24 @@ export class HttpApiClient implements ElectronAPI { this.isConnecting = true; - // In Electron mode, use API key directly - const apiKey = getApiKey(); - if (apiKey) { + // Electron mode must authenticate with the injected API key. + // If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow). + if (isElectronMode()) { + const apiKey = getApiKey(); + if (!apiKey) { + console.warn( + '[HttpApiClient] Electron mode: API key not ready, delaying WebSocket connect' + ); + this.isConnecting = false; + if (!this.reconnectTimer) { + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connectWebSocket(); + }, 250); + } + return; + } + const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`); return; @@ -389,8 +511,17 @@ export class HttpApiClient implements ElectronAPI { this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); + console.log( + '[HttpApiClient] WebSocket message:', + data.type, + 'hasPayload:', + !!data.payload, + 'callbacksRegistered:', + this.eventCallbacks.has(data.type) + ); const callbacks = this.eventCallbacks.get(data.type); if (callbacks) { + console.log('[HttpApiClient] Dispatching to', callbacks.size, 'callbacks'); callbacks.forEach((cb) => cb(data.payload)); } } catch (error) { @@ -460,39 +591,103 @@ export class HttpApiClient implements ElectronAPI { } private async post(endpoint: string, body?: unknown): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'POST', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + // If parsing JSON fails, use status text + } + throw new Error(errorMessage); + } + return response.json(); } private async get(endpoint: string): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + // If parsing JSON fails, use status text + } + throw new Error(errorMessage); + } + return response.json(); } private async put(endpoint: string, body?: unknown): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'PUT', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + // If parsing JSON fails, use status text + } + throw new Error(errorMessage); + } + return response.json(); } private async httpDelete(endpoint: string): Promise { + // Ensure API key is initialized before making request + await waitForApiKeyInit(); const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'DELETE', headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + // If parsing JSON fails, use status text + } + throw new Error(errorMessage); + } + return response.json(); } @@ -566,14 +761,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: [] }; } @@ -1586,3 +1782,10 @@ export function getHttpApiClient(): HttpApiClient { } return httpApiClientInstance; } + +// Start API key initialization immediately when this module is imported +// This ensures the init promise is created early, even before React components mount +// The actual async work happens in the background and won't block module loading +initApiKey().catch((error) => { + console.error('[HTTP Client] Failed to initialize API key:', error); +}); diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 4e112f25..13cb27a3 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -3,15 +3,37 @@ * * 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, ChildProcess } from 'child_process'; -import fs from 'fs'; +import { spawn, execSync, ChildProcess } from 'child_process'; import crypto from 'crypto'; import http, { Server } from 'http'; +import net from 'net'; 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; @@ -30,8 +52,51 @@ if (isDev) { let mainWindow: BrowserWindow | null = null; let serverProcess: ChildProcess | null = null; let staticServer: Server | null = null; -const SERVER_PORT = 3008; -const STATIC_PORT = 3007; + +// Default ports (can be overridden via env) - will be dynamically assigned if these are in use +// When launched via root init.mjs we pass: +// - PORT (backend) +// - TEST_PORT (vite dev server / static) +const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10); +const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10); + +// Actual ports in use (set during startup) +let serverPort = DEFAULT_SERVER_PORT; +let staticPort = DEFAULT_STATIC_PORT; + +/** + * Check if a port is available + */ +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => { + resolve(false); + }); + server.once('listening', () => { + server.close(() => { + resolve(true); + }); + }); + // Use Node's default binding semantics (matches most dev servers) + // This avoids false-positives when a port is taken on IPv6/dual-stack. + server.listen(port); + }); +} + +/** + * Find an available port starting from the preferred port + * Tries up to 100 ports in sequence + */ +async function findAvailablePort(preferredPort: number): Promise { + for (let offset = 0; offset < 100; offset++) { + const port = preferredPort + offset; + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error(`Could not find an available port starting from ${preferredPort}`); +} // ============================================ // Window sizing constants for kanban layout @@ -64,21 +129,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 +155,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 +165,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 +181,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 +195,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 +226,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 +308,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 +321,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; @@ -298,8 +370,8 @@ async function startStaticServer(): Promise { }); return new Promise((resolve, reject) => { - staticServer!.listen(STATIC_PORT, () => { - console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`); + staticServer!.listen(staticPort, () => { + console.log(`[Electron] Static server running at http://localhost:${staticPort}`); resolve(); }); staticServer!.on('error', reject); @@ -308,6 +380,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 +391,20 @@ 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') { + let exists: boolean; + try { + exists = systemPathExists(command); + } catch (error) { + const originalError = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}` + ); + } + if (!exists) { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } } let args: string[]; @@ -332,11 +417,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 +447,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 +460,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) { @@ -369,7 +476,7 @@ async function startServer(): Promise { const env = { ...process.env, PATH: enhancedPath, - PORT: SERVER_PORT.toString(), + PORT: serverPort.toString(), DATA_DIR: app.getPath('userData'), NODE_PATH: serverNodeModules, // Pass API key to server for CSRF protection @@ -381,12 +488,15 @@ async function startServer(): Promise { }), }; + console.log(`[Electron] Server will use port ${serverPort}`); + 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'], }); @@ -419,7 +529,7 @@ async function waitForServer(maxAttempts = 30): Promise { for (let i = 0; i < maxAttempts; i++) { try { await new Promise((resolve, reject) => { - const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => { + const req = http.get(`http://localhost:${serverPort}/api/health`, (res) => { if (res.statusCode === 200) { resolve(); } else { @@ -484,9 +594,9 @@ function createWindow(): void { mainWindow.loadURL(VITE_DEV_SERVER_URL); } else if (isDev) { // Fallback for dev without Vite server URL - mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); + mainWindow.loadURL(`http://localhost:${staticPort}`); } else { - mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); + mainWindow.loadURL(`http://localhost:${staticPort}`); } if (isDev && process.env.OPEN_DEVTOOLS === 'true') { @@ -541,6 +651,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) { @@ -556,6 +688,21 @@ app.whenReady().then(async () => { ensureApiKey(); try { + // Find available ports (prevents conflicts with other apps using same ports) + serverPort = await findAvailablePort(DEFAULT_SERVER_PORT); + if (serverPort !== DEFAULT_SERVER_PORT) { + console.log( + `[Electron] Default server port ${DEFAULT_SERVER_PORT} in use, using port ${serverPort}` + ); + } + + staticPort = await findAvailablePort(DEFAULT_STATIC_PORT); + if (staticPort !== DEFAULT_STATIC_PORT) { + console.log( + `[Electron] Default static port ${DEFAULT_STATIC_PORT} in use, using port ${staticPort}` + ); + } + // Start static file server in production if (app.isPackaged) { await startStaticServer(); @@ -589,15 +736,48 @@ app.whenReady().then(async () => { }); app.on('window-all-closed', () => { + // On macOS, keep the app and servers running when all windows are closed + // (standard macOS behavior). On other platforms, stop servers and quit. if (process.platform !== 'darwin') { + if (serverProcess && serverProcess.pid) { + console.log('[Electron] All windows closed, stopping server...'); + if (process.platform === 'win32') { + try { + execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' }); + } catch (error) { + console.error('[Electron] Failed to kill server process:', (error as Error).message); + } + } else { + serverProcess.kill('SIGTERM'); + } + serverProcess = null; + } + + if (staticServer) { + console.log('[Electron] Stopping static server...'); + staticServer.close(); + staticServer = null; + } + app.quit(); } }); app.on('before-quit', () => { - if (serverProcess) { + if (serverProcess && serverProcess.pid) { console.log('[Electron] Stopping server...'); - serverProcess.kill(); + if (process.platform === 'win32') { + try { + // Windows: use taskkill with /t to kill entire process tree + // This prevents orphaned node processes when closing the app + // Using execSync to ensure process is killed before app exits + execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' }); + } catch (error) { + console.error('[Electron] Failed to kill server process:', (error as Error).message); + } + } else { + serverProcess.kill('SIGTERM'); + } serverProcess = null; } @@ -620,6 +800,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; }); @@ -709,7 +905,7 @@ ipcMain.handle('ping', async () => { // Get server URL for HTTP client ipcMain.handle('server:getUrl', async () => { - return `http://localhost:${SERVER_PORT}`; + return `http://localhost:${serverPort}`; }); // Get API key for authentication @@ -725,3 +921,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { // Always use the smaller minimum width - horizontal scrolling handles any overflow mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT); }); + +// Quit the application (used when user denies sandbox risk confirmation) +ipcMain.handle('app:quit', () => { + console.log('[Electron] Quitting application via IPC request'); + app.quit(); +}); diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index 4a1aa6f1..0955ab1b 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Window management updateMinWidth: (sidebarExpanded: boolean): Promise => ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded), + + // App control + quit: (): Promise => ipcRenderer.invoke('app:quit'), }); console.log('[Preload] Electron API exposed (TypeScript)'); diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 23a4fa30..d486ce59 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -1,5 +1,5 @@ import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; -import { useEffect, useState, useCallback, useDeferredValue } from 'react'; +import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react'; import { Sidebar } from '@/components/layout/sidebar'; import { FileBrowserProvider, @@ -8,14 +8,30 @@ import { } from '@/contexts/file-browser-context'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; -import { getElectronAPI } from '@/lib/electron'; -import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client'; +import { useAuthStore } from '@/store/auth-store'; +import { getElectronAPI, isElectron } from '@/lib/electron'; +import { isMac } from '@/lib/utils'; +import { + initApiKey, + isElectronMode, + verifySession, + checkSandboxEnvironment, + getServerUrlSync, +} from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; +import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; +import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; function RootLayoutContent() { const location = useLocation(); - const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore(); + const { + setIpcConnected, + currentProject, + getEffectiveTheme, + skipSandboxWarning, + setSkipSandboxWarning, + } = useAppStore(); const { setupComplete } = useSetupStore(); const navigate = useNavigate(); const [isMounted, setIsMounted] = useState(false); @@ -23,10 +39,19 @@ function RootLayoutContent() { const [setupHydrated, setSetupHydrated] = useState( () => useSetupStore.persist?.hasHydrated?.() ?? false ); - const [authChecked, setAuthChecked] = useState(false); - const [isAuthenticated, setIsAuthenticated] = useState(false); + const authChecked = useAuthStore((s) => s.authChecked); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const { openFileBrowser } = useFileBrowser(); + const isSetupRoute = location.pathname === '/setup'; + const isLoginRoute = location.pathname === '/login'; + + // Sandbox environment check state + type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; + // Always start from pending on a fresh page load so the user sees the prompt + // each time the app is launched/refreshed (unless running in a container). + const [sandboxStatus, setSandboxStatus] = useState('pending'); + // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -73,19 +98,95 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Check sandbox environment on mount + useEffect(() => { + // Skip if already decided + if (sandboxStatus !== 'pending') { + return; + } + + const checkSandbox = async () => { + try { + const result = await checkSandboxEnvironment(); + + if (result.isContainerized) { + // Running in a container, no warning needed + setSandboxStatus('containerized'); + } else if (skipSandboxWarning) { + // User opted to skip the warning, auto-confirm + setSandboxStatus('confirmed'); + } else { + // Not containerized, show warning dialog + setSandboxStatus('needs-confirmation'); + } + } catch (error) { + console.error('[Sandbox] Failed to check environment:', error); + // On error, assume not containerized and show warning + if (skipSandboxWarning) { + setSandboxStatus('confirmed'); + } else { + setSandboxStatus('needs-confirmation'); + } + } + }; + + checkSandbox(); + }, [sandboxStatus, skipSandboxWarning]); + + // Handle sandbox risk confirmation + const handleSandboxConfirm = useCallback( + (skipInFuture: boolean) => { + if (skipInFuture) { + setSkipSandboxWarning(true); + } + setSandboxStatus('confirmed'); + }, + [setSkipSandboxWarning] + ); + + // Handle sandbox risk denial + const handleSandboxDeny = useCallback(async () => { + if (isElectron()) { + // In Electron mode, quit the application + // Use window.electronAPI directly since getElectronAPI() returns the HTTP client + try { + const electronAPI = window.electronAPI; + if (electronAPI?.quit) { + await electronAPI.quit(); + } else { + console.error('[Sandbox] quit() not available on electronAPI'); + } + } catch (error) { + console.error('[Sandbox] Failed to quit app:', error); + } + } else { + // In web mode, show rejection screen + setSandboxStatus('denied'); + } + }, []); + + // Ref to prevent concurrent auth checks from running + const authCheckRunning = useRef(false); + // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) // - Web mode: Uses HTTP-only session cookie useEffect(() => { + // Prevent concurrent auth checks + if (authCheckRunning.current) { + return; + } + const initAuth = async () => { + authCheckRunning.current = true; + try { // Initialize API key for Electron mode await initApiKey(); // In Electron mode, we're always authenticated via header if (isElectronMode()) { - setIsAuthenticated(true); - setAuthChecked(true); + useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true }); return; } @@ -94,31 +195,23 @@ function RootLayoutContent() { const isValid = await verifySession(); if (isValid) { - setIsAuthenticated(true); - setAuthChecked(true); + useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true }); return; } - // Session is invalid or expired - redirect to login - console.log('Session invalid or expired - redirecting to login'); - setIsAuthenticated(false); - setAuthChecked(true); - - if (location.pathname !== '/login') { - navigate({ to: '/login' }); - } + // Session is invalid or expired - treat as not authenticated + useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); } catch (error) { console.error('Failed to initialize auth:', error); - setAuthChecked(true); - // On error, redirect to login to be safe - if (location.pathname !== '/login') { - navigate({ to: '/login' }); - } + // On error, treat as not authenticated + useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); + } finally { + authCheckRunning.current = false; } }; initAuth(); - }, [location.pathname, navigate]); + }, []); // Runs once per load; auth state drives routing rules // Wait for setup store hydration before enforcing routing rules useEffect(() => { @@ -138,16 +231,34 @@ function RootLayoutContent() { }; }, []); - // Redirect first-run users (or anyone who reopened the wizard) to /setup + // Routing rules (web mode): + // - If not authenticated: force /login (even /setup is protected) + // - If authenticated but setup incomplete: force /setup useEffect(() => { if (!setupHydrated) return; + // Wait for auth check to complete before enforcing any redirects + if (!isElectronMode() && !authChecked) return; + + // Unauthenticated -> force /login + if (!isElectronMode() && !isAuthenticated) { + if (location.pathname !== '/login') { + navigate({ to: '/login' }); + } + return; + } + + // Authenticated -> determine whether setup is required if (!setupComplete && location.pathname !== '/setup') { navigate({ to: '/setup' }); - } else if (setupComplete && location.pathname === '/setup') { + return; + } + + // Setup complete but user is still on /setup -> go to app + if (setupComplete && location.pathname === '/setup') { navigate({ to: '/' }); } - }, [setupComplete, setupHydrated, location.pathname, navigate]); + }, [authChecked, isAuthenticated, setupComplete, setupHydrated, location.pathname, navigate]); useEffect(() => { setGlobalFileBrowser(openFileBrowser); @@ -157,9 +268,19 @@ function RootLayoutContent() { useEffect(() => { const testConnection = async () => { try { - const api = getElectronAPI(); - const result = await api.ping(); - setIpcConnected(result === 'pong'); + if (isElectron()) { + const api = getElectronAPI(); + const result = await api.ping(); + setIpcConnected(result === 'pong'); + return; + } + + // Web mode: check backend availability without instantiating the full HTTP client + const response = await fetch(`${getServerUrlSync()}/api/health`, { + method: 'GET', + signal: AbortSignal.timeout(2000), + }); + setIpcConnected(response.ok); } catch (error) { console.error('IPC connection failed:', error); setIpcConnected(false); @@ -197,15 +318,31 @@ function RootLayoutContent() { } }, [deferredTheme]); - // Login and setup views are full-screen without sidebar - const isSetupRoute = location.pathname === '/setup'; - const isLoginRoute = location.pathname === '/login'; + // Show rejection screen if user denied sandbox risk (web mode only) + if (sandboxStatus === 'denied' && !isElectron()) { + return ; + } + + // Show loading while checking sandbox environment + if (sandboxStatus === 'pending') { + return ( +
+
Checking environment...
+
+ ); + } // Show login page (full screen, no sidebar) if (isLoginRoute) { return (
+ {/* Show sandbox dialog on top of login page if needed */} +
); } @@ -220,20 +357,39 @@ function RootLayoutContent() { } // Redirect to login if not authenticated (web mode) + // Show loading state while navigation to login is in progress if (!isElectronMode() && !isAuthenticated) { - return null; // Will redirect via useEffect + return ( +
+
Redirecting to login...
+
+ ); } + // Show setup page (full screen, no sidebar) - authenticated only if (isSetupRoute) { return (
+ {/* Show sandbox dialog on top of setup page if needed */} +
); } return (
+ {/* Full-width titlebar drag region for Electron window dragging */} + {isElectron() && ( +
); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 0d433287..a57e4d93 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -487,6 +487,7 @@ export interface AppState { // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems) + skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use @@ -775,6 +776,7 @@ export interface AppActions { // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; setEnableSandboxMode: (enabled: boolean) => Promise; + setSkipSandboxWarning: (skip: boolean) => Promise; setMcpAutoApproveTools: (enabled: boolean) => Promise; setMcpUnrestrictedTools: (enabled: boolean) => Promise; @@ -975,7 +977,8 @@ const initialState: AppState = { enhancementModel: 'sonnet', // Default to sonnet for feature enhancement validationModel: 'opus', // Default to opus for GitHub issue validation autoLoadClaudeMd: false, // Default to disabled (user must opt-in) - enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur) + enableSandboxMode: false, // Default to disabled (can be enabled for additional security) + skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled @@ -1623,6 +1626,12 @@ export const useAppStore = create()( const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, + setSkipSandboxWarning: async (skip) => { + set({ skipSandboxWarning: skip }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, setMcpAutoApproveTools: async (enabled) => { set({ mcpAutoApproveTools: enabled }); // Sync to server settings file @@ -2921,6 +2930,7 @@ export const useAppStore = create()( validationModel: state.validationModel, autoLoadClaudeMd: state.autoLoadClaudeMd, enableSandboxMode: state.enableSandboxMode, + skipSandboxWarning: state.skipSandboxWarning, // MCP settings mcpServers: state.mcpServers, mcpAutoApproveTools: state.mcpAutoApproveTools, diff --git a/apps/ui/src/store/auth-store.ts b/apps/ui/src/store/auth-store.ts new file mode 100644 index 00000000..7c594d0d --- /dev/null +++ b/apps/ui/src/store/auth-store.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +interface AuthState { + /** Whether we've attempted to determine auth status for this page load */ + authChecked: boolean; + /** Whether the user is currently authenticated (web mode: valid session cookie) */ + isAuthenticated: boolean; +} + +interface AuthActions { + setAuthState: (state: Partial) => void; + resetAuth: () => void; +} + +const initialState: AuthState = { + authChecked: false, + isAuthenticated: false, +}; + +/** + * Web authentication state. + * + * Intentionally NOT persisted: source of truth is the server session cookie. + */ +export const useAuthStore = create((set) => ({ + ...initialState, + setAuthState: (state) => set(state), + resetAuth: () => set(initialState), +})); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 1c84e59a..281fc539 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -172,6 +172,7 @@ export const useSetupStore = create()( }), { name: 'automaker-setup', + version: 1, // Add version field for proper hydration (matches app-store pattern) partialize: (state) => ({ isFirstRun: state.isFirstRun, setupComplete: state.setupComplete, diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 44985d6b..15c61f8c 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -465,6 +465,7 @@ export interface AutoModeAPI { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; // Dialog APIs diff --git a/apps/ui/src/vite-env.d.ts b/apps/ui/src/vite-env.d.ts index 2030df75..04745bb1 100644 --- a/apps/ui/src/vite-env.d.ts +++ b/apps/ui/src/vite-env.d.ts @@ -9,3 +9,6 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +// Global constants defined in vite.config.mts +declare const __APP_VERSION__: string; diff --git a/apps/ui/tests/utils/git/worktree.ts b/apps/ui/tests/utils/git/worktree.ts index e7c1b2f8..72e281d4 100644 --- a/apps/ui/tests/utils/git/worktree.ts +++ b/apps/ui/tests/utils/git/worktree.ts @@ -80,13 +80,21 @@ export async function createTestGitRepo(tempDir: string): Promise { // Initialize git repo await execAsync('git init', { cwd: tmpDir }); - await execAsync('git config user.email "test@example.com"', { cwd: tmpDir }); - await execAsync('git config user.name "Test User"', { cwd: tmpDir }); + + // Use environment variables instead of git config to avoid affecting user's git config + // These env vars override git config without modifying it + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', + }; // Create initial commit fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n'); - await execAsync('git add .', { cwd: tmpDir }); - await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); + await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); + await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); // Create main branch explicitly await execAsync('git branch -M main', { cwd: tmpDir }); @@ -248,9 +256,18 @@ export async function commitFile( content: string, message: string ): Promise { + // Use environment variables instead of git config to avoid affecting user's git config + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', + }; + fs.writeFileSync(path.join(repoPath, filePath), content); - await execAsync(`git add "${filePath}"`, { cwd: repoPath }); - await execAsync(`git commit -m "${message}"`, { cwd: repoPath }); + await execAsync(`git add "${filePath}"`, { cwd: repoPath, env: gitEnv }); + await execAsync(`git commit -m "${message}"`, { cwd: repoPath, env: gitEnv }); } /** diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts index 7d2b96a9..12243c8e 100644 --- a/apps/ui/vite.config.mts +++ b/apps/ui/vite.config.mts @@ -1,4 +1,5 @@ import * as path from 'path'; +import * as fs from 'fs'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; @@ -8,6 +9,10 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Read version from package.json +const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8')); +const appVersion = packageJson.version; + export default defineConfig(({ command }) => { // Only skip electron plugin during dev server in CI (no display available for Electron) // Always include it during build - we need dist-electron/main.js for electron-builder @@ -65,5 +70,8 @@ export default defineConfig(({ command }) => { build: { outDir: 'dist', }, + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, }; }); diff --git a/docker-compose.yml b/docker-compose.yml index 8bbf2e84..2026ff0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,10 @@ services: # Optional - CORS origin (default allows all) - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007} + + # Internal - indicates the API is running in a containerized sandbox environment + # This is used by the UI to determine if sandbox risk warnings should be shown + - IS_CONTAINERIZED=true volumes: # ONLY named volumes - these are isolated from your host filesystem # This volume persists data between restarts but is container-managed diff --git a/docs/migration-plan-nextjs-to-vite.md b/docs/migration-plan-nextjs-to-vite.md deleted file mode 100644 index 7211e1ec..00000000 --- a/docs/migration-plan-nextjs-to-vite.md +++ /dev/null @@ -1,1829 +0,0 @@ -# Migration Plan: Next.js to Vite + Electron + TanStack - -> **Document Version**: 1.1 -> **Date**: December 2025 -> **Status**: Phase 2 Complete - Core Migration Done -> **Branch**: refactor/frontend - ---- - -## Table of Contents - -1. [Executive Summary](#executive-summary) -2. [Current Architecture Assessment](#current-architecture-assessment) -3. [Proposed New Architecture](#proposed-new-architecture) -4. [Folder Structure](#folder-structure) -5. [Shared Packages (libs/)](#shared-packages-libs) -6. [Type-Safe Electron Implementation](#type-safe-electron-implementation) -7. [Components Refactoring](#components-refactoring) -8. [Web + Electron Dual Support](#web--electron-dual-support) -9. [Migration Phases](#migration-phases) -10. [Expected Benefits](#expected-benefits) -11. [Risk Mitigation](#risk-mitigation) - ---- - -## Executive Summary - -### Why Migrate? - -Our current Next.js implementation uses **less than 5%** of the framework's capabilities. We're essentially running a static SPA with unnecessary overhead: - -| Next.js Feature | Our Usage | -| ---------------------- | ---------------------------- | -| Server-Side Rendering | ❌ Not used | -| Static Site Generation | ❌ Not used | -| API Routes | ⚠️ Only 2 test endpoints | -| Image Optimization | ❌ Not used | -| Dynamic Routing | ❌ Not used | -| App Router | ⚠️ File structure only | -| Metadata API | ⚠️ Title/description only | -| Static Export | ✅ Used (`output: "export"`) | - -### Migration Benefits - -| Metric | Current (Next.js) | Expected (Vite) | -| ---------------------- | ----------------- | ------------------ | -| Dev server startup | ~8-15s | ~1-3s | -| HMR speed | ~500ms-2s | ~50-100ms | -| Production build | ~45-90s | ~15-30s | -| Bundle overhead | Next.js runtime | None | -| Type safety (Electron) | 0% | 100% | -| Debug capabilities | Limited | Full debug console | - -### Target Stack - -- **Bundler**: Vite -- **Framework**: React 19 -- **Routing**: TanStack Router (file-based) -- **Data Fetching**: TanStack Query -- **State**: Zustand (unchanged) -- **Styling**: Tailwind CSS 4 (unchanged) -- **Desktop**: Electron (TypeScript rewrite) - ---- - -## Current Architecture Assessment - -### Data Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ELECTRON APP │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ HTTP/WS ┌─────────────────┐ │ -│ │ React SPA │ ←──────────────────→ │ Backend Server │ │ -│ │ (Next.js) │ localhost:3008 │ (Express) │ │ -│ │ │ │ │ │ -│ │ • Zustand Store │ │ • AI Providers │ │ -│ │ • 16 Views │ │ • Git/FS Ops │ │ -│ │ • 180+ Comps │ │ • Terminal │ │ -│ └────────┬────────┘ └─────────────────┘ │ -│ │ │ -│ │ IPC (minimal - dialogs/shell only) │ -│ ↓ │ -│ ┌─────────────────┐ │ -│ │ Electron Main │ • File dialogs │ -│ │ (main.js) │ • Shell operations │ -│ │ **NO TYPES** │ • App paths │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Current Electron Layer Issues - -| Issue | Impact | Solution | -| ----------------------- | --------------------------- | ------------------------ | -| Pure JavaScript | No compile-time safety | Migrate to TypeScript | -| Untyped IPC handlers | Runtime errors | IPC Schema with generics | -| String literal channels | Typos cause silent failures | Const enums | -| No debug tooling | Hard to diagnose issues | Debug console feature | -| Monolithic main.js | Hard to maintain | Modular IPC organization | - -### Current Component Structure Issues - -| View File | Lines | Issue | -| ------------------ | ----- | ---------------------------------- | -| spec-view.tsx | 1,230 | Exceeds 500-line threshold | -| analysis-view.tsx | 1,134 | Exceeds 500-line threshold | -| agent-view.tsx | 916 | Exceeds 500-line threshold | -| welcome-view.tsx | 815 | Exceeds 500-line threshold | -| context-view.tsx | 735 | Exceeds 500-line threshold | -| terminal-view.tsx | 697 | Exceeds 500-line threshold | -| interview-view.tsx | 637 | Exceeds 500-line threshold | -| board-view.tsx | 685 | ✅ Already has subfolder structure | - ---- - -## Proposed New Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ MIGRATED ARCHITECTURE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @automaker/app (Vite + React) │ │ -│ ├──────────────────────────────────────────────────────────────────┤ │ -│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ │ -│ │ │ TanStack │ │ TanStack │ │ Zustand │ │ │ -│ │ │ Router │ │ Query │ │ Store │ │ │ -│ │ │ (file-based) │ │ (data fetch) │ │ (UI state) │ │ │ -│ │ └────────────────┘ └────────────────┘ └────────────────────┘ │ │ -│ │ │ │ -│ │ src/ │ │ -│ │ ├── routes/ # TanStack file-based routes │ │ -│ │ ├── components/ # Refactored per folder-pattern.md │ │ -│ │ ├── hooks/ # React hooks │ │ -│ │ ├── store/ # Zustand stores │ │ -│ │ ├── lib/ # Utilities │ │ -│ │ └── config/ # Configuration │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ HTTP/WS (unchanged) │ Type-Safe IPC │ -│ ↓ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Electron Layer (TypeScript) │ │ -│ ├──────────────────────────────────────────────────────────────────┤ │ -│ │ electron/ │ │ -│ │ ├── main.ts # Main process entry │ │ -│ │ ├── preload.ts # Context bridge exposure │ │ -│ │ ├── debug-console/ # Debug console feature │ │ -│ │ └── ipc/ # Modular IPC handlers │ │ -│ │ ├── ipc-schema.ts # Type definitions │ │ -│ │ ├── dialog/ # File dialogs │ │ -│ │ ├── shell/ # Shell operations │ │ -│ │ └── server/ # Server management │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @automaker/server (unchanged) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────┐ -│ SHARED PACKAGES (libs/) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ @automaker/types # API contracts, model definitions │ -│ @automaker/utils # Shared utilities (error handling, etc.) │ -│ @automaker/platform # OS-specific utilities, path handling │ -│ @automaker/model-resolver # Model string resolution │ -│ @automaker/ipc-types # IPC channel type definitions │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Folder Structure - -### apps/ui/ (After Migration) - -``` -apps/ui/ -├── electron/ # Electron main process (TypeScript) -│ ├── main.ts # Main entry point -│ ├── preload.ts # Context bridge -│ ├── tsconfig.json # Electron-specific TS config -│ ├── debug-console/ -│ │ ├── debug-console.html -│ │ ├── debug-console-preload.ts -│ │ └── debug-mode.ts -│ ├── ipc/ -│ │ ├── ipc-schema.ts # Central type definitions -│ │ ├── context-exposer.ts # Exposes all contexts to renderer -│ │ ├── listeners-register.ts # Registers all main process handlers -│ │ ├── dialog/ -│ │ │ ├── dialog-channels.ts # Channel constants -│ │ │ ├── dialog-context.ts # Preload exposure -│ │ │ └── dialog-listeners.ts # Main process handlers -│ │ ├── shell/ -│ │ │ ├── shell-channels.ts -│ │ │ ├── shell-context.ts -│ │ │ └── shell-listeners.ts -│ │ ├── app-info/ -│ │ │ ├── app-info-channels.ts -│ │ │ ├── app-info-context.ts -│ │ │ └── app-info-listeners.ts -│ │ └── server/ -│ │ ├── server-channels.ts -│ │ ├── server-context.ts -│ │ └── server-listeners.ts -│ └── helpers/ -│ ├── server-manager.ts # Backend server spawn/health -│ ├── static-server.ts # Production static file server -│ ├── window-helpers.ts # Window utilities -│ └── window-registry.ts # Multi-window tracking -│ -├── src/ -│ ├── routes/ # TanStack Router (file-based) -│ │ ├── __root.tsx # Root layout -│ │ ├── index.tsx # Welcome/home (default route) -│ │ ├── board.tsx # Board view -│ │ ├── agent.tsx # Agent view -│ │ ├── settings.tsx # Settings view -│ │ ├── setup.tsx # Setup view -│ │ ├── terminal.tsx # Terminal view -│ │ ├── spec.tsx # Spec view -│ │ ├── context.tsx # Context view -│ │ ├── profiles.tsx # Profiles view -│ │ ├── interview.tsx # Interview view -│ │ ├── wiki.tsx # Wiki view -│ │ ├── analysis.tsx # Analysis view -│ │ └── agent-tools.tsx # Agent tools view -│ │ -│ ├── components/ # Refactored per folder-pattern.md -│ │ ├── ui/ # Global UI primitives (unchanged) -│ │ ├── layout/ -│ │ │ ├── sidebar.tsx -│ │ │ ├── base-layout.tsx -│ │ │ └── index.ts -│ │ ├── dialogs/ # Global dialogs -│ │ │ ├── index.ts -│ │ │ ├── new-project-modal.tsx -│ │ │ ├── workspace-picker-modal.tsx -│ │ │ └── file-browser-dialog.tsx -│ │ └── views/ # Complex view components -│ │ ├── board-view/ # ✅ Already structured -│ │ ├── settings-view/ # Needs dialogs reorganization -│ │ ├── setup-view/ # ✅ Already structured -│ │ ├── profiles-view/ # ✅ Already structured -│ │ ├── agent-view/ # NEW: needs subfolder -│ │ │ ├── components/ -│ │ │ │ ├── index.ts -│ │ │ │ ├── message-list.tsx -│ │ │ │ ├── message-input.tsx -│ │ │ │ └── session-sidebar.tsx -│ │ │ ├── dialogs/ -│ │ │ │ ├── index.ts -│ │ │ │ ├── delete-session-dialog.tsx -│ │ │ │ └── delete-all-archived-dialog.tsx -│ │ │ └── hooks/ -│ │ │ ├── index.ts -│ │ │ └── use-agent-state.ts -│ │ ├── spec-view/ # NEW: needs subfolder (1230 lines!) -│ │ ├── analysis-view/ # NEW: needs subfolder (1134 lines!) -│ │ ├── context-view/ # NEW: needs subfolder -│ │ ├── welcome-view/ # NEW: needs subfolder -│ │ ├── interview-view/ # NEW: needs subfolder -│ │ └── terminal-view/ # Expand existing -│ │ -│ ├── hooks/ # Global hooks -│ ├── store/ # Zustand stores -│ ├── lib/ # Utilities -│ ├── config/ # Configuration -│ ├── contexts/ # React contexts -│ ├── types/ # Type definitions -│ ├── App.tsx # Root component -│ ├── renderer.ts # Vite entry point -│ └── routeTree.gen.ts # Generated by TanStack Router -│ -├── index.html # Vite HTML entry -├── vite.config.mts # Vite configuration -├── tsconfig.json # TypeScript config (renderer) -├── package.json -└── tailwind.config.ts -``` - ---- - -## Shared Packages (libs/) - -### Package Overview - -``` -libs/ -├── @automaker/types # API contracts, model definitions -├── @automaker/utils # General utilities (error handling, logger) -├── @automaker/platform # OS-specific utilities, path handling -├── @automaker/model-resolver # Model string resolution -└── @automaker/ipc-types # IPC channel type definitions -``` - -### @automaker/types - -Shared type definitions for API contracts between frontend and backend. - -``` -libs/types/ -├── src/ -│ ├── api.ts # API response types -│ ├── models.ts # ModelDefinition, ProviderStatus -│ ├── features.ts # Feature, FeatureStatus, Priority -│ ├── sessions.ts # Session, Message types -│ ├── agent.ts # Agent types -│ ├── git.ts # Git operation types -│ ├── worktree.ts # Worktree types -│ └── index.ts # Barrel export -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/types/src/models.ts -export interface ModelDefinition { - id: string; - name: string; - provider: ProviderType; - contextWindow: number; - maxOutputTokens: number; - capabilities: ModelCapabilities; -} - -export interface ModelCapabilities { - vision: boolean; - toolUse: boolean; - streaming: boolean; - computerUse: boolean; -} - -export type ProviderType = 'claude' | 'openai' | 'gemini' | 'ollama'; -``` - -### @automaker/utils - -General utilities shared between frontend and backend. - -``` -libs/utils/ -├── src/ -│ ├── error-handler.ts # Error classification & user-friendly messages -│ ├── logger.ts # Logging utilities -│ ├── conversation-utils.ts # Message formatting & history -│ ├── image-utils.ts # Image processing utilities -│ ├── string-utils.ts # String manipulation helpers -│ └── index.ts -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/utils/src/error-handler.ts -export type ErrorType = - | 'authentication' - | 'rate_limit' - | 'network' - | 'validation' - | 'not_found' - | 'server' - | 'unknown'; - -export interface ErrorInfo { - type: ErrorType; - message: string; - userMessage: string; - retryable: boolean; - statusCode?: number; -} - -export function classifyError(error: unknown): ErrorInfo; -export function getUserFriendlyErrorMessage(error: unknown): string; -export function isAbortError(error: unknown): boolean; -export function isAuthenticationError(error: unknown): boolean; -export function isRateLimitError(error: unknown): boolean; -``` - -### @automaker/platform - -**OS-specific utilities, path handling, and cross-platform helpers.** - -``` -libs/platform/ -├── src/ -│ ├── paths/ -│ │ ├── index.ts # Path utilities barrel export -│ │ ├── path-resolver.ts # Cross-platform path resolution -│ │ ├── path-constants.ts # Common path constants -│ │ └── path-validator.ts # Path validation utilities -│ ├── os/ -│ │ ├── index.ts # OS utilities barrel export -│ │ ├── platform-info.ts # Platform detection & info -│ │ ├── shell-commands.ts # OS-specific shell commands -│ │ └── env-utils.ts # Environment variable utilities -│ ├── fs/ -│ │ ├── index.ts # FS utilities barrel export -│ │ ├── safe-fs.ts # Symlink-safe file operations -│ │ ├── temp-files.ts # Temporary file handling -│ │ └── permissions.ts # File permission utilities -│ └── index.ts # Main barrel export -├── package.json -└── tsconfig.json -``` - -```typescript -// libs/platform/src/paths/path-resolver.ts -import path from 'path'; - -/** - * Platform-aware path separator - */ -export const SEP = path.sep; - -/** - * Normalizes a path to use the correct separator for the current OS - */ -export function normalizePath(inputPath: string): string { - return inputPath.replace(/[/\\]/g, SEP); -} - -/** - * Converts a path to POSIX format (forward slashes) - * Useful for consistent storage/comparison - */ -export function toPosixPath(inputPath: string): string { - return inputPath.replace(/\\/g, '/'); -} - -/** - * Converts a path to Windows format (backslashes) - */ -export function toWindowsPath(inputPath: string): string { - return inputPath.replace(/\//g, '\\'); -} - -/** - * Resolves a path relative to a base, handling platform differences - */ -export function resolvePath(basePath: string, ...segments: string[]): string { - return path.resolve(basePath, ...segments); -} - -/** - * Gets the relative path from one location to another - */ -export function getRelativePath(from: string, to: string): string { - return path.relative(from, to); -} - -/** - * Joins path segments with proper platform separator - */ -export function joinPath(...segments: string[]): string { - return path.join(...segments); -} - -/** - * Extracts directory name from a path - */ -export function getDirname(filePath: string): string { - return path.dirname(filePath); -} - -/** - * Extracts filename from a path - */ -export function getBasename(filePath: string, ext?: string): string { - return path.basename(filePath, ext); -} - -/** - * Extracts file extension from a path - */ -export function getExtension(filePath: string): string { - return path.extname(filePath); -} - -/** - * Checks if a path is absolute - */ -export function isAbsolutePath(inputPath: string): boolean { - return path.isAbsolute(inputPath); -} - -/** - * Ensures a path is absolute, resolving relative to cwd if needed - */ -export function ensureAbsolutePath(inputPath: string, basePath?: string): string { - if (isAbsolutePath(inputPath)) { - return inputPath; - } - return resolvePath(basePath || process.cwd(), inputPath); -} -``` - -```typescript -// libs/platform/src/paths/path-constants.ts -import path from 'path'; -import os from 'os'; - -/** - * Common system paths - */ -export const SYSTEM_PATHS = { - /** User's home directory */ - home: os.homedir(), - - /** System temporary directory */ - temp: os.tmpdir(), - - /** Current working directory */ - cwd: process.cwd(), -} as const; - -/** - * Gets the appropriate app data directory for the current platform - */ -export function getAppDataPath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - appName - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', appName); - default: // Linux and others - return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName); - } -} - -/** - * Gets the appropriate cache directory for the current platform - */ -export function getCachePath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), - appName, - 'Cache' - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Caches', appName); - default: - return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), appName); - } -} - -/** - * Gets the appropriate logs directory for the current platform - */ -export function getLogsPath(appName: string): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return path.join( - process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), - appName, - 'Logs' - ); - case 'darwin': - return path.join(os.homedir(), 'Library', 'Logs', appName); - default: - return path.join( - process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), - appName, - 'logs' - ); - } -} - -/** - * Gets the user's Documents directory - */ -export function getDocumentsPath(): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, 'Documents') - : path.join(os.homedir(), 'Documents'); - case 'darwin': - return path.join(os.homedir(), 'Documents'); - default: - return process.env.XDG_DOCUMENTS_DIR || path.join(os.homedir(), 'Documents'); - } -} - -/** - * Gets the user's Desktop directory - */ -export function getDesktopPath(): string { - const platform = process.platform; - - switch (platform) { - case 'win32': - return process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, 'Desktop') - : path.join(os.homedir(), 'Desktop'); - case 'darwin': - return path.join(os.homedir(), 'Desktop'); - default: - return process.env.XDG_DESKTOP_DIR || path.join(os.homedir(), 'Desktop'); - } -} -``` - -```typescript -// libs/platform/src/paths/path-validator.ts -import path from 'path'; -import { isAbsolutePath } from './path-resolver'; - -/** - * Characters that are invalid in file/directory names on Windows - */ -const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g; - -/** - * Reserved names on Windows (case-insensitive) - */ -const WINDOWS_RESERVED_NAMES = [ - 'CON', - 'PRN', - 'AUX', - 'NUL', - 'COM1', - 'COM2', - 'COM3', - 'COM4', - 'COM5', - 'COM6', - 'COM7', - 'COM8', - 'COM9', - 'LPT1', - 'LPT2', - 'LPT3', - 'LPT4', - 'LPT5', - 'LPT6', - 'LPT7', - 'LPT8', - 'LPT9', -]; - -export interface PathValidationResult { - valid: boolean; - errors: string[]; - sanitized?: string; -} - -/** - * Validates a filename for the current platform - */ -export function validateFilename(filename: string): PathValidationResult { - const errors: string[] = []; - - if (!filename || filename.trim().length === 0) { - return { valid: false, errors: ['Filename cannot be empty'] }; - } - - // Check for path separators (filename shouldn't be a path) - if (filename.includes('/') || filename.includes('\\')) { - errors.push('Filename cannot contain path separators'); - } - - // Platform-specific checks - if (process.platform === 'win32') { - if (WINDOWS_INVALID_CHARS.test(filename)) { - errors.push('Filename contains invalid characters for Windows'); - } - - const nameWithoutExt = filename.split('.')[0].toUpperCase(); - if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - errors.push(`"${nameWithoutExt}" is a reserved name on Windows`); - } - - if (filename.endsWith(' ') || filename.endsWith('.')) { - errors.push('Filename cannot end with a space or period on Windows'); - } - } - - // Check length - if (filename.length > 255) { - errors.push('Filename exceeds maximum length of 255 characters'); - } - - return { - valid: errors.length === 0, - errors, - sanitized: errors.length > 0 ? sanitizeFilename(filename) : filename, - }; -} - -/** - * Sanitizes a filename for cross-platform compatibility - */ -export function sanitizeFilename(filename: string): string { - let sanitized = filename.replace(WINDOWS_INVALID_CHARS, '_').replace(/[/\\]/g, '_').trim(); - - // Handle Windows reserved names - const nameWithoutExt = sanitized.split('.')[0].toUpperCase(); - if (WINDOWS_RESERVED_NAMES.includes(nameWithoutExt)) { - sanitized = '_' + sanitized; - } - - // Remove trailing spaces and periods (Windows) - sanitized = sanitized.replace(/[\s.]+$/, ''); - - // Ensure not empty - if (!sanitized) { - sanitized = 'unnamed'; - } - - // Truncate if too long - if (sanitized.length > 255) { - const ext = path.extname(sanitized); - const name = path.basename(sanitized, ext); - sanitized = name.slice(0, 255 - ext.length) + ext; - } - - return sanitized; -} - -/** - * Validates a full path for the current platform - */ -export function validatePath(inputPath: string): PathValidationResult { - const errors: string[] = []; - - if (!inputPath || inputPath.trim().length === 0) { - return { valid: false, errors: ['Path cannot be empty'] }; - } - - // Check total path length - const maxPathLength = process.platform === 'win32' ? 260 : 4096; - if (inputPath.length > maxPathLength) { - errors.push(`Path exceeds maximum length of ${maxPathLength} characters`); - } - - // Validate each segment - const segments = inputPath.split(/[/\\]/).filter(Boolean); - for (const segment of segments) { - // Skip drive letters on Windows - if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(segment)) { - continue; - } - - const segmentValidation = validateFilename(segment); - if (!segmentValidation.valid) { - errors.push(...segmentValidation.errors.map((e) => `Segment "${segment}": ${e}`)); - } - } - - return { - valid: errors.length === 0, - errors, - }; -} - -/** - * Checks if a path is within a base directory (prevents directory traversal) - */ -export function isPathWithin(childPath: string, parentPath: string): boolean { - const resolvedChild = path.resolve(childPath); - const resolvedParent = path.resolve(parentPath); - - return resolvedChild.startsWith(resolvedParent + path.sep) || resolvedChild === resolvedParent; -} -``` - -```typescript -// libs/platform/src/os/platform-info.ts -import os from 'os'; - -export type Platform = 'windows' | 'macos' | 'linux' | 'unknown'; -export type Architecture = 'x64' | 'arm64' | 'ia32' | 'unknown'; - -export interface PlatformInfo { - platform: Platform; - arch: Architecture; - release: string; - hostname: string; - username: string; - cpus: number; - totalMemory: number; - freeMemory: number; - isWsl: boolean; - isDocker: boolean; -} - -/** - * Gets the normalized platform name - */ -export function getPlatform(): Platform { - switch (process.platform) { - case 'win32': - return 'windows'; - case 'darwin': - return 'macos'; - case 'linux': - return 'linux'; - default: - return 'unknown'; - } -} - -/** - * Gets the normalized architecture - */ -export function getArchitecture(): Architecture { - switch (process.arch) { - case 'x64': - return 'x64'; - case 'arm64': - return 'arm64'; - case 'ia32': - return 'ia32'; - default: - return 'unknown'; - } -} - -/** - * Checks if running on Windows - */ -export function isWindows(): boolean { - return process.platform === 'win32'; -} - -/** - * Checks if running on macOS - */ -export function isMacOS(): boolean { - return process.platform === 'darwin'; -} - -/** - * Checks if running on Linux - */ -export function isLinux(): boolean { - return process.platform === 'linux'; -} - -/** - * Checks if running in WSL (Windows Subsystem for Linux) - */ -export function isWsl(): boolean { - if (process.platform !== 'linux') return false; - - try { - const release = os.release().toLowerCase(); - return release.includes('microsoft') || release.includes('wsl'); - } catch { - return false; - } -} - -/** - * Checks if running in Docker container - */ -export function isDocker(): boolean { - try { - const fs = require('fs'); - return ( - fs.existsSync('/.dockerenv') || - (fs.existsSync('/proc/1/cgroup') && - fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')) - ); - } catch { - return false; - } -} - -/** - * Gets comprehensive platform information - */ -export function getPlatformInfo(): PlatformInfo { - return { - platform: getPlatform(), - arch: getArchitecture(), - release: os.release(), - hostname: os.hostname(), - username: os.userInfo().username, - cpus: os.cpus().length, - totalMemory: os.totalmem(), - freeMemory: os.freemem(), - isWsl: isWsl(), - isDocker: isDocker(), - }; -} - -/** - * Gets the appropriate line ending for the current platform - */ -export function getLineEnding(): string { - return isWindows() ? '\r\n' : '\n'; -} - -/** - * Normalizes line endings to the current platform - */ -export function normalizeLineEndings(text: string): string { - const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - return isWindows() ? normalized.replace(/\n/g, '\r\n') : normalized; -} -``` - -```typescript -// libs/platform/src/os/shell-commands.ts -import { isWindows, isMacOS } from './platform-info'; - -export interface ShellCommand { - command: string; - args: string[]; -} - -/** - * Gets the appropriate command to open a file/URL with default application - */ -export function getOpenCommand(target: string): ShellCommand { - if (isWindows()) { - return { command: 'cmd', args: ['/c', 'start', '', target] }; - } else if (isMacOS()) { - return { command: 'open', args: [target] }; - } else { - return { command: 'xdg-open', args: [target] }; - } -} - -/** - * Gets the appropriate command to reveal a file in file manager - */ -export function getRevealCommand(filePath: string): ShellCommand { - if (isWindows()) { - return { command: 'explorer', args: ['/select,', filePath] }; - } else if (isMacOS()) { - return { command: 'open', args: ['-R', filePath] }; - } else { - // Linux: try multiple file managers - return { command: 'xdg-open', args: [require('path').dirname(filePath)] }; - } -} - -/** - * Gets the default shell for the current platform - */ -export function getDefaultShell(): string { - if (isWindows()) { - return process.env.COMSPEC || 'cmd.exe'; - } - return process.env.SHELL || '/bin/sh'; -} - -/** - * Gets shell-specific arguments for running a command - */ -export function getShellArgs(command: string): ShellCommand { - if (isWindows()) { - return { command: 'cmd.exe', args: ['/c', command] }; - } - return { command: '/bin/sh', args: ['-c', command] }; -} - -/** - * Escapes a string for safe use in shell commands - */ -export function escapeShellArg(arg: string): string { - if (isWindows()) { - // Windows cmd.exe escaping - return `"${arg.replace(/"/g, '""')}"`; - } - // POSIX shell escaping - return `'${arg.replace(/'/g, "'\\''")}'`; -} -``` - -```typescript -// libs/platform/src/os/env-utils.ts -import { isWindows } from './platform-info'; - -/** - * Gets an environment variable with a fallback - */ -export function getEnv(key: string, fallback?: string): string | undefined { - return process.env[key] ?? fallback; -} - -/** - * Gets an environment variable, throwing if not set - */ -export function requireEnv(key: string): string { - const value = process.env[key]; - if (value === undefined) { - throw new Error(`Required environment variable "${key}" is not set`); - } - return value; -} - -/** - * Parses a boolean environment variable - */ -export function getBoolEnv(key: string, fallback = false): boolean { - const value = process.env[key]; - if (value === undefined) return fallback; - return ['true', '1', 'yes', 'on'].includes(value.toLowerCase()); -} - -/** - * Parses a numeric environment variable - */ -export function getNumericEnv(key: string, fallback: number): number { - const value = process.env[key]; - if (value === undefined) return fallback; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? fallback : parsed; -} - -/** - * Expands environment variables in a string - * Supports both $VAR and ${VAR} syntax, plus %VAR% on Windows - */ -export function expandEnvVars(input: string): string { - let result = input; - - // Expand ${VAR} syntax - result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ''); - - // Expand $VAR syntax (not followed by another word char) - result = result.replace( - /\$([A-Za-z_][A-Za-z0-9_]*)(?![A-Za-z0-9_])/g, - (_, name) => process.env[name] || '' - ); - - // Expand %VAR% syntax (Windows) - if (isWindows()) { - result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ''); - } - - return result; -} - -/** - * Gets the PATH environment variable as an array - */ -export function getPathEntries(): string[] { - const pathVar = process.env.PATH || process.env.Path || ''; - const separator = isWindows() ? ';' : ':'; - return pathVar.split(separator).filter(Boolean); -} - -/** - * Checks if a command is available in PATH - */ -export function isCommandInPath(command: string): boolean { - const pathEntries = getPathEntries(); - const extensions = isWindows() ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : ['']; - const path = require('path'); - const fs = require('fs'); - - for (const dir of pathEntries) { - for (const ext of extensions) { - const fullPath = path.join(dir, command + ext); - try { - fs.accessSync(fullPath, fs.constants.X_OK); - return true; - } catch { - // Continue searching - } - } - } - - return false; -} -``` - -```typescript -// libs/platform/src/fs/safe-fs.ts -import fs from 'fs'; -import path from 'path'; - -/** - * Safely reads a file, following symlinks but preventing escape from base directory - */ -export async function safeReadFile( - filePath: string, - basePath: string, - encoding: BufferEncoding = 'utf8' -): Promise { - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(basePath); - - // Resolve symlinks - const realPath = await fs.promises.realpath(resolvedPath); - const realBase = await fs.promises.realpath(resolvedBase); - - // Ensure resolved path is within base - if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) { - throw new Error(`Path "${filePath}" resolves outside of allowed directory`); - } - - return fs.promises.readFile(realPath, encoding); -} - -/** - * Safely writes a file, preventing writes outside base directory - */ -export async function safeWriteFile( - filePath: string, - basePath: string, - content: string -): Promise { - const resolvedPath = path.resolve(filePath); - const resolvedBase = path.resolve(basePath); - - // Ensure path is within base before any symlink resolution - if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { - throw new Error(`Path "${filePath}" is outside of allowed directory`); - } - - // Check parent directory exists and is within base - const parentDir = path.dirname(resolvedPath); - - try { - const realParent = await fs.promises.realpath(parentDir); - const realBase = await fs.promises.realpath(resolvedBase); - - if (!realParent.startsWith(realBase + path.sep) && realParent !== realBase) { - throw new Error(`Parent directory resolves outside of allowed directory`); - } - } catch (error) { - // Parent doesn't exist, that's OK - we'll create it - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - - await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true }); - await fs.promises.writeFile(resolvedPath, content, 'utf8'); -} - -/** - * Checks if a path exists and is accessible - */ -export async function pathExists(filePath: string): Promise { - try { - await fs.promises.access(filePath); - return true; - } catch { - return false; - } -} - -/** - * Gets file stats, returning null if file doesn't exist - */ -export async function safeStat(filePath: string): Promise { - try { - return await fs.promises.stat(filePath); - } catch { - return null; - } -} - -/** - * Recursively removes a directory - */ -export async function removeDirectory(dirPath: string): Promise { - await fs.promises.rm(dirPath, { recursive: true, force: true }); -} - -/** - * Copies a file or directory - */ -export async function copy(src: string, dest: string): Promise { - const stats = await fs.promises.stat(src); - - if (stats.isDirectory()) { - await fs.promises.mkdir(dest, { recursive: true }); - const entries = await fs.promises.readdir(src, { withFileTypes: true }); - - for (const entry of entries) { - await copy(path.join(src, entry.name), path.join(dest, entry.name)); - } - } else { - await fs.promises.copyFile(src, dest); - } -} -``` - -```typescript -// libs/platform/src/index.ts -// Main barrel export - -// Path utilities -export * from './paths/path-resolver'; -export * from './paths/path-constants'; -export * from './paths/path-validator'; - -// OS utilities -export * from './os/platform-info'; -export * from './os/shell-commands'; -export * from './os/env-utils'; - -// File system utilities -export * from './fs/safe-fs'; -``` - -### @automaker/model-resolver - -Model string resolution shared between frontend and backend. - -``` -libs/model-resolver/ -├── src/ -│ ├── model-map.ts # CLAUDE_MODEL_MAP, DEFAULT_MODELS -│ ├── resolver.ts # resolveModelString, getEffectiveModel -│ └── index.ts -├── package.json -└── tsconfig.json -``` - -### @automaker/ipc-types - -IPC channel type definitions for type-safe Electron communication. - -``` -libs/ipc-types/ -├── src/ -│ ├── schema.ts # IPCSchema interface -│ ├── channels.ts # Channel constant enums -│ ├── helpers.ts # Type helper functions -│ └── index.ts -├── package.json -└── tsconfig.json -``` - ---- - -## Type-Safe Electron Implementation - -### IPC Schema Definition - -```typescript -// electron/ipc/ipc-schema.ts -import type { OpenDialogOptions, SaveDialogOptions } from 'electron'; - -// Dialog result types -export interface DialogResult { - canceled: boolean; - filePaths?: string[]; - filePath?: string; - data?: T; -} - -// App path names (from Electron) -export type AppPathName = - | 'home' - | 'appData' - | 'userData' - | 'sessionData' - | 'temp' - | 'exe' - | 'module' - | 'desktop' - | 'documents' - | 'downloads' - | 'music' - | 'pictures' - | 'videos' - | 'recent' - | 'logs' - | 'crashDumps'; - -// Complete IPC Schema with request/response types -export interface IPCSchema { - // Dialog operations - 'dialog:openDirectory': { - request: Partial; - response: DialogResult; - }; - 'dialog:openFile': { - request: Partial; - response: DialogResult; - }; - 'dialog:saveFile': { - request: Partial; - response: DialogResult; - }; - - // Shell operations - 'shell:openExternal': { - request: { url: string }; - response: { success: boolean; error?: string }; - }; - 'shell:openPath': { - request: { path: string }; - response: { success: boolean; error?: string }; - }; - - // App info - 'app:getPath': { - request: { name: AppPathName }; - response: string; - }; - 'app:getVersion': { - request: void; - response: string; - }; - 'app:isPackaged': { - request: void; - response: boolean; - }; - - // Server management - 'server:getUrl': { - request: void; - response: string; - }; - - // Connection test - ping: { - request: void; - response: 'pong'; - }; - - // Debug console - 'debug:log': { - request: { - level: DebugLogLevel; - category: DebugCategory; - message: string; - args: unknown[]; - }; - response: void; - }; -} - -export type DebugLogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success'; -export type DebugCategory = - | 'general' - | 'ipc' - | 'route' - | 'network' - | 'perf' - | 'state' - | 'lifecycle' - | 'updater'; - -// Type extractors -export type IPCChannel = keyof IPCSchema; -export type IPCRequest = IPCSchema[T]['request']; -export type IPCResponse = IPCSchema[T]['response']; -``` - -### Modular IPC Organization - -```typescript -// electron/ipc/dialog/dialog-channels.ts -export const DIALOG_CHANNELS = { - OPEN_DIRECTORY: 'dialog:openDirectory', - OPEN_FILE: 'dialog:openFile', - SAVE_FILE: 'dialog:saveFile', -} as const; - -// electron/ipc/dialog/dialog-context.ts -import { contextBridge, ipcRenderer } from 'electron'; -import { DIALOG_CHANNELS } from './dialog-channels'; -import type { IPCRequest, IPCResponse } from '../ipc-schema'; - -export function exposeDialogContext(): void { - contextBridge.exposeInMainWorld('dialogAPI', { - openDirectory: (options?: IPCRequest<'dialog:openDirectory'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_DIRECTORY, options), - - openFile: (options?: IPCRequest<'dialog:openFile'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.OPEN_FILE, options), - - saveFile: (options?: IPCRequest<'dialog:saveFile'>) => - ipcRenderer.invoke(DIALOG_CHANNELS.SAVE_FILE, options), - }); -} - -// electron/ipc/dialog/dialog-listeners.ts -import { ipcMain, dialog, BrowserWindow } from 'electron'; -import { DIALOG_CHANNELS } from './dialog-channels'; -import type { IPCRequest, IPCResponse } from '../ipc-schema'; -import { debugLog } from '../../helpers/debug-mode'; - -export function addDialogEventListeners(mainWindow: BrowserWindow): void { - ipcMain.handle( - DIALOG_CHANNELS.OPEN_DIRECTORY, - async (_, options: IPCRequest<'dialog:openDirectory'> = {}) => { - debugLog.ipc(`OPEN_DIRECTORY called with options: ${JSON.stringify(options)}`); - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory', 'createDirectory'], - ...options, - }); - - debugLog.ipc( - `OPEN_DIRECTORY result: canceled=${result.canceled}, paths=${result.filePaths.length}` - ); - - return { - canceled: result.canceled, - filePaths: result.filePaths, - } satisfies IPCResponse<'dialog:openDirectory'>; - } - ); - - ipcMain.handle( - DIALOG_CHANNELS.OPEN_FILE, - async (_, options: IPCRequest<'dialog:openFile'> = {}) => { - debugLog.ipc(`OPEN_FILE called`); - - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openFile'], - ...options, - }); - - return { - canceled: result.canceled, - filePaths: result.filePaths, - } satisfies IPCResponse<'dialog:openFile'>; - } - ); - - ipcMain.handle( - DIALOG_CHANNELS.SAVE_FILE, - async (_, options: IPCRequest<'dialog:saveFile'> = {}) => { - debugLog.ipc(`SAVE_FILE called`); - - const result = await dialog.showSaveDialog(mainWindow, options); - - return { - canceled: result.canceled, - filePath: result.filePath, - } satisfies IPCResponse<'dialog:saveFile'>; - } - ); -} -``` - ---- - -## Components Refactoring - -### Priority Matrix - -| Priority | View | Lines | Action Required | -| -------- | -------------- | ----- | ------------------------------------------------------ | -| 🔴 P0 | spec-view | 1,230 | Create subfolder with components/, dialogs/, hooks/ | -| 🔴 P0 | analysis-view | 1,134 | Create subfolder with components/, dialogs/, hooks/ | -| 🔴 P0 | agent-view | 916 | Create subfolder, extract message list, input, sidebar | -| 🟡 P1 | welcome-view | 815 | Create subfolder, extract sections | -| 🟡 P1 | context-view | 735 | Create subfolder, extract components | -| 🟡 P1 | terminal-view | 697 | Expand existing subfolder | -| 🟡 P1 | interview-view | 637 | Create subfolder | -| 🟢 P2 | settings-view | 178 | Move dialogs from components/ to dialogs/ | -| ✅ Done | board-view | 685 | Already properly structured | -| ✅ Done | setup-view | 144 | Already properly structured | -| ✅ Done | profiles-view | 300 | Already properly structured | - -### Immediate Dialog Reorganization - -```bash -# Settings-view: Move dialogs to proper location -mv settings-view/components/keyboard-map-dialog.tsx → settings-view/dialogs/ -mv settings-view/components/delete-project-dialog.tsx → settings-view/dialogs/ - -# Root components: Organize global dialogs -mv components/dialogs/board-background-modal.tsx → board-view/dialogs/ - -# Agent-related dialogs: Move to agent-view -mv components/delete-session-dialog.tsx → agent-view/dialogs/ -mv components/delete-all-archived-sessions-dialog.tsx → agent-view/dialogs/ -``` - ---- - -## Web + Electron Dual Support - -### Platform Detection - -```typescript -// src/lib/platform.ts -export const isElectron = typeof window !== 'undefined' && 'electronAPI' in window; - -export const platform = { - isElectron, - isWeb: !isElectron, - isMac: isElectron ? window.electronAPI.platform === 'darwin' : false, - isWindows: isElectron ? window.electronAPI.platform === 'win32' : false, - isLinux: isElectron ? window.electronAPI.platform === 'linux' : false, -}; -``` - -### API Abstraction Layer - -```typescript -// src/lib/api/file-picker.ts -import { platform } from '../platform'; - -export interface FilePickerResult { - canceled: boolean; - paths: string[]; -} - -export async function pickDirectory(): Promise { - if (platform.isElectron) { - const result = await window.dialogAPI.openDirectory(); - return { canceled: result.canceled, paths: result.filePaths || [] }; - } - - // Web fallback using File System Access API - try { - const handle = await window.showDirectoryPicker(); - return { canceled: false, paths: [handle.name] }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return { canceled: true, paths: [] }; - } - throw error; - } -} - -export async function pickFile(options?: { - accept?: Record; -}): Promise { - if (platform.isElectron) { - const result = await window.dialogAPI.openFile({ - filters: options?.accept - ? Object.entries(options.accept).map(([name, extensions]) => ({ - name, - extensions, - })) - : undefined, - }); - return { canceled: result.canceled, paths: result.filePaths || [] }; - } - - // Web fallback - try { - const [handle] = await window.showOpenFilePicker({ - types: options?.accept - ? Object.entries(options.accept).map(([description, accept]) => ({ - description, - accept: { 'application/*': accept }, - })) - : undefined, - }); - return { canceled: false, paths: [handle.name] }; - } catch (error) { - if ((error as Error).name === 'AbortError') { - return { canceled: true, paths: [] }; - } - throw error; - } -} -``` - ---- - -## Migration Phases - -### Phase 1: Foundation (Week 1-2) - -**Goal**: Set up new build infrastructure without breaking existing functionality. - -- [x] Create `vite.config.mts` with electron plugins -- [x] Create `electron/tsconfig.json` for Electron TypeScript -- [x] Convert `electron/main.js` → `electron/main.ts` -- [x] Convert `electron/preload.js` → `electron/preload.ts` -- [ ] Implement IPC schema and type-safe handlers (deferred - using existing HTTP API) -- [x] Set up TanStack Router configuration -- [ ] Port debug console from starter template (deferred) -- [x] Create `index.html` for Vite entry - -**Deliverables**: - -- [x] Working Vite dev server -- [x] TypeScript Electron main process -- [ ] Debug console functional (deferred) - -### Phase 2: Core Migration (Week 3-4) - -**Goal**: Replace Next.js with Vite while maintaining feature parity. - -- [x] Create `src/renderer.tsx` entry point -- [x] Create `src/App.tsx` root component -- [x] Set up TanStack Router with file-based routes -- [x] Port all views to route files -- [x] Update environment variables (`NEXT_PUBLIC_*` → `VITE_*`) -- [x] Verify Zustand stores work unchanged -- [x] Verify HTTP API client works unchanged -- [x] Test Electron build -- [ ] Test Web build (needs verification) - -**Additional completed tasks**: - -- [x] Remove all "use client" directives (not needed in Vite) -- [x] Replace all `setCurrentView()` calls with TanStack Router `navigate()` -- [x] Rename `apps/app` to `apps/ui` -- [x] Update package.json scripts -- [x] Configure memory history for Electron (no URL bar) -- [x] Fix ES module imports (replace `require()` with `import`) -- [x] Remove PostCSS config (using `@tailwindcss/vite` plugin) - -**Deliverables**: - -- [x] All views accessible via TanStack Router -- [x] Electron build functional -- [ ] Web build functional (needs testing) -- [x] No regression in existing functionality - -### Phase 3: Component Refactoring (Week 5-7) - -**Goal**: Refactor large view files to follow folder-pattern.md. - -- [ ] Refactor `spec-view.tsx` (1,230 lines) -- [ ] Refactor `analysis-view.tsx` (1,134 lines) -- [ ] Refactor `agent-view.tsx` (916 lines) -- [ ] Refactor `welcome-view.tsx` (815 lines) -- [ ] Refactor `context-view.tsx` (735 lines) -- [ ] Refactor `terminal-view.tsx` (697 lines) -- [ ] Refactor `interview-view.tsx` (637 lines) -- [ ] Reorganize `settings-view` dialogs - -**Deliverables**: - -- All views under 500 lines -- Consistent folder structure across all views -- Barrel exports for all component folders - -### Phase 4: Package Extraction (Week 8) - -**Goal**: Create shared packages for better modularity. - -- [ ] Create `libs/types/` package -- [ ] Create `libs/utils/` package -- [ ] Create `libs/platform/` package -- [ ] Create `libs/model-resolver/` package -- [ ] Create `libs/ipc-types/` package -- [ ] Update imports across apps - -**Deliverables**: - -- 5 new shared packages -- No code duplication between apps -- Clean dependency graph - -### Phase 5: Polish & Testing (Week 9-10) - -**Goal**: Ensure production readiness. - -- [ ] Write E2E tests with Playwright -- [ ] Performance benchmarking -- [ ] Bundle size optimization -- [ ] Documentation updates -- [ ] CI/CD pipeline updates -- [ ] Remove Next.js dependencies - -**Deliverables**: - -- Comprehensive test coverage -- Performance metrics documentation -- Updated CI/CD configuration -- Clean package.json (no Next.js) - ---- - -## Expected Benefits - -### Developer Experience - -| Aspect | Before | After | -| ---------------------- | ------------- | ------------------ | -| Dev server startup | 8-15 seconds | 1-3 seconds | -| Hot Module Replacement | 500ms-2s | 50-100ms | -| TypeScript in Electron | Not supported | Full support | -| Debug tooling | Limited | Full debug console | -| Build times | 45-90 seconds | 15-30 seconds | - -### Code Quality - -| Aspect | Before | After | -| ---------------------- | ------------ | --------------------- | -| Electron type safety | 0% | 100% | -| Component organization | Inconsistent | Standardized | -| Code sharing | None | 5 shared packages | -| Path handling | Ad-hoc | Centralized utilities | - -### Bundle Size - -| Aspect | Before | After | -| ------------------ | ------- | ------- | -| Next.js runtime | ~200KB | 0KB | -| Framework overhead | High | Minimal | -| Tree shaking | Limited | Full | - ---- - -## Risk Mitigation - -### Rollback Strategy - -1. **Branch-based development**: All work on feature branch -2. **Parallel running**: Keep Next.js functional until migration complete -3. **Feature flags**: Toggle between old/new implementations -4. **Comprehensive testing**: E2E tests before/after comparison - -### Known Challenges - -| Challenge | Mitigation | -| --------------------- | ------------------------------------------------ | -| Route migration | TanStack Router has similar file-based routing | -| Environment variables | Simple search/replace (`NEXT_PUBLIC_` → `VITE_`) | -| Build configuration | Reference electron-starter-template | -| SSR considerations | N/A - we don't use SSR | - -### Testing Strategy - -1. **Unit tests**: Vitest for component/utility testing -2. **Integration tests**: Test IPC communication -3. **E2E tests**: Playwright for full application testing -4. **Manual testing**: QA checklist for each view - ---- - -## Appendix: Vite Configuration Reference - -```typescript -// vite.config.mts -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import electron from 'vite-plugin-electron'; -import renderer from 'vite-plugin-electron-renderer'; -import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; -import tailwindcss from '@tailwindcss/vite'; -import path from 'path'; - -export default defineConfig({ - plugins: [ - react({ - babel: { - plugins: [['babel-plugin-react-compiler', {}]], - }, - }), - TanStackRouterVite({ - routesDirectory: './src/routes', - generatedRouteTree: './src/routeTree.gen.ts', - autoCodeSplitting: true, - }), - tailwindcss(), - electron([ - { - entry: 'electron/main.ts', - vite: { - build: { - outDir: 'dist-electron', - rollupOptions: { - external: ['electron'], - }, - }, - }, - }, - { - entry: 'electron/preload.ts', - onstart: ({ reload }) => reload(), - vite: { - build: { - outDir: 'dist-electron', - rollupOptions: { - external: ['electron'], - }, - }, - }, - }, - ]), - renderer(), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - '@electron': path.resolve(__dirname, 'electron'), - }, - }, - build: { - outDir: 'dist', - }, -}); -``` - ---- - -## Document History - -| Version | Date | Author | Changes | -| ------- | -------- | ------ | ------------------------------------------------------------------------------------- | -| 1.0 | Dec 2025 | Team | Initial migration plan | -| 1.1 | Dec 2025 | Team | Phase 1 & 2 complete. Updated checkboxes, added completed tasks, noted deferred items | - ---- - -**Next Steps**: - -1. ~~Review and approve this plan~~ ✅ -2. ~~Wait for `feature/worktrees` branch merge~~ ✅ -3. ~~Create migration branch~~ ✅ (refactor/frontend) -4. ~~Complete Phase 1 implementation~~ ✅ -5. ~~Complete Phase 2 implementation~~ ✅ -6. **Current**: Verify web build works, then begin Phase 3 (Component Refactoring) -7. Consider implementing deferred items: Debug console, IPC schema diff --git a/docs/pipeline-feature.md b/docs/pipeline-feature.md deleted file mode 100644 index 59238776..00000000 --- a/docs/pipeline-feature.md +++ /dev/null @@ -1,156 +0,0 @@ -# Pipeline Feature - -Custom pipeline steps that run automatically after a feature completes "In Progress", creating a sequential workflow for code review, security audits, testing, and more. - -## Overview - -The pipeline feature allows users to define custom workflow steps that execute automatically after the main implementation phase. Each step prompts the agent with specific instructions while maintaining the full conversation context. - -## How It Works - -1. **Feature completes "In Progress"** - When the agent finishes implementing a feature -2. **Pipeline steps execute sequentially** - Each configured step runs in order -3. **Agent receives instructions** - The step's instructions are sent to the agent -4. **Context preserved** - Full chat history is maintained between steps -5. **Final status** - After all steps complete, the feature moves to "Waiting Approval" or "Verified" - -## Configuration - -### Accessing Pipeline Settings - -- Click the **gear icon** on the "In Progress" column header -- Or click the gear icon on any pipeline step column - -### Adding Pipeline Steps - -1. Click **"Add Pipeline Step"** -2. Optionally select a **pre-built template** from the dropdown: - - Code Review - - Security Review - - Testing - - Documentation - - Performance Optimization -3. Customize the **Step Name** -4. Choose a **Color** for the column -5. Write or modify the **Agent Instructions** -6. Click **"Add Step"** - -### Managing Steps - -- **Reorder**: Use the up/down arrows to change step order -- **Edit**: Click the pencil icon to modify a step -- **Delete**: Click the trash icon to remove a step -- **Load from file**: Upload a `.md` or `.txt` file for instructions - -## Storage - -Pipeline configuration is stored per-project at: - -``` -{project}/.automaker/pipeline.json -``` - -## Pre-built Templates - -### Code Review - -Comprehensive code quality review covering: - -- Readability and maintainability -- DRY principle and single responsibility -- Best practices and conventions -- Performance considerations -- Test coverage - -### Security Review - -OWASP-focused security audit including: - -- Input validation and sanitization -- SQL injection and XSS prevention -- Authentication and authorization -- Data protection -- Common vulnerability checks (OWASP Top 10) - -### Testing - -Test coverage verification: - -- Unit test requirements -- Integration testing -- Test quality standards -- Running and validating tests - -### Documentation - -Documentation requirements: - -- Code documentation (JSDoc/docstrings) -- API documentation -- README updates -- Changelog entries - -### Performance Optimization - -Performance review covering: - -- Algorithm optimization -- Memory usage -- Database/API optimization -- Frontend performance (if applicable) - -## UI Changes - -### Kanban Board - -- Pipeline columns appear between "In Progress" and "Waiting Approval" -- Each pipeline column shows features currently in that step -- Gear icon on columns opens pipeline settings - -### Horizontal Scrolling - -- Board supports horizontal scrolling when many columns exist -- Minimum window width reduced to 600px to accommodate various screen sizes - -## Technical Details - -### Files Modified - -**Types:** - -- `libs/types/src/pipeline.ts` - PipelineStep, PipelineConfig types -- `libs/types/src/index.ts` - Export pipeline types - -**Server:** - -- `apps/server/src/services/pipeline-service.ts` - CRUD operations, status transitions -- `apps/server/src/routes/pipeline/` - API endpoints -- `apps/server/src/services/auto-mode-service.ts` - Pipeline execution integration - -**UI:** - -- `apps/ui/src/store/app-store.ts` - Pipeline state management -- `apps/ui/src/lib/http-api-client.ts` - Pipeline API client -- `apps/ui/src/components/views/board-view/constants.ts` - Dynamic column generation -- `apps/ui/src/components/views/board-view/kanban-board.tsx` - Pipeline props, scrolling -- `apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx` - Settings UI -- `apps/ui/src/hooks/use-responsive-kanban.ts` - Scroll support - -### API Endpoints - -``` -POST /api/pipeline/config - Get pipeline config -POST /api/pipeline/config/save - Save pipeline config -POST /api/pipeline/steps/add - Add a step -POST /api/pipeline/steps/update - Update a step -POST /api/pipeline/steps/delete - Delete a step -POST /api/pipeline/steps/reorder - Reorder steps -``` - -### Status Flow - -``` -backlog → in_progress → pipeline_step1 → pipeline_step2 → ... → verified/waiting_approval -``` - -Pipeline statuses use the format `pipeline_{stepId}` to support unlimited dynamic steps. diff --git a/docs/plans/2025-12-29-api-security-hardening-design.md b/docs/plans/2025-12-29-api-security-hardening-design.md deleted file mode 100644 index 54c0ca67..00000000 --- a/docs/plans/2025-12-29-api-security-hardening-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# API Security Hardening Design - -**Date:** 2025-12-29 -**Branch:** protect-api-with-api-key -**Status:** Approved - -## Overview - -Security improvements for the API authentication system before merging the PR. These changes harden the existing implementation for production deployment scenarios (local, Docker, internet-exposed). - -## Fixes to Implement - -### 1. Use Short-Lived wsToken for WebSocket Authentication - -**Problem:** The client currently passes `sessionToken` in WebSocket URL query parameters. Query params get logged and can leak credentials. - -**Solution:** Update the client to: - -1. Fetch a wsToken from `/api/auth/token` before each WebSocket connection -2. Use `wsToken` query param instead of `sessionToken` -3. Never put session tokens in URLs - -**Files to modify:** - -- `apps/ui/src/lib/http-api-client.ts` - Update `connectWebSocket()` to fetch wsToken first - ---- - -### 2. Add Environment Variable to Hide API Key from Logs - -**Problem:** The API key is printed to console on startup, which gets captured by logging systems in production. - -**Solution:** Add `AUTOMAKER_HIDE_API_KEY=true` env var to suppress the banner. - -**Files to modify:** - -- `apps/server/src/lib/auth.ts` - Wrap console.log banner in env var check - ---- - -### 3. Add Rate Limiting to Login Endpoint - -**Problem:** No brute force protection on `/api/auth/login`. Attackers could attempt many API keys. - -**Solution:** Add basic in-memory rate limiting: - -- ~5 attempts per minute per IP -- In-memory Map tracking (resets on server restart) -- Return 429 Too Many Requests when exceeded - -**Files to modify:** - -- `apps/server/src/routes/auth/index.ts` - Add rate limiting logic to login handler - ---- - -### 4. Use Timing-Safe Comparison for API Key - -**Problem:** Using `===` for API key comparison is vulnerable to timing attacks. - -**Solution:** Use `crypto.timingSafeEqual()` for constant-time comparison. - -**Files to modify:** - -- `apps/server/src/lib/auth.ts` - Update `validateApiKey()` function - ---- - -### 5. Make WebSocket Tokens Single-Use - -**Problem:** wsTokens can be reused within the 5-minute window. If intercepted, attackers have time to use them. - -**Solution:** Delete the token after first successful validation. - -**Files to modify:** - -- `apps/server/src/lib/auth.ts` - Update `validateWsConnectionToken()` to delete after use - ---- - -## Implementation Order - -1. Fix #4 (timing-safe comparison) - Simple, isolated change -2. Fix #5 (single-use wsToken) - Simple, isolated change -3. Fix #2 (hide API key env var) - Simple, isolated change -4. Fix #3 (rate limiting) - Moderate complexity -5. Fix #1 (client wsToken usage) - Requires coordination with server - -## Testing Notes - -- Test login with rate limiting (verify 429 after 5 attempts) -- Test WebSocket connection with new wsToken flow -- Test wsToken is invalidated after first use -- Verify `AUTOMAKER_HIDE_API_KEY=true` suppresses banner diff --git a/init.mjs b/init.mjs index 4fcf8b08..49d47fa6 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,45 @@ 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', @@ -127,6 +170,14 @@ function killProcess(pid) { } } +/** + * Check if a port is in use (without killing) + */ +function isPortInUse(port) { + const pids = getProcessesOnPort(port); + return pids.length > 0; +} + /** * Kill processes on a port and wait for it to be freed */ @@ -168,9 +219,9 @@ function sleep(ms) { /** * Check if the server health endpoint is responding */ -function checkHealth() { +function checkHealth(port = 3008) { return new Promise((resolve) => { - const req = http.get('http://localhost:3008/api/health', (res) => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { resolve(res.statusCode === 200); }); req.on('error', () => resolve(false)); @@ -202,15 +253,35 @@ function prompt(question) { * Run npm command using cross-spawn for Windows compatibility */ function runNpm(args, options = {}) { + const { env, ...restOptions } = options; const spawnOptions = { stdio: 'inherit', cwd: __dirname, - ...options, + ...restOptions, + // Ensure environment variables are properly merged with process.env + env: { + ...process.env, + ...(env || {}), + }, }; // cross-spawn handles Windows .cmd files automatically return crossSpawn('npm', args, spawnOptions); } +/** + * Run an npm command and wait for completion + */ +function runNpmAndWait(args, options = {}) { + const child = runNpm(args, options); + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + /** * Run npx command using cross-spawn for Windows compatibility */ @@ -309,10 +380,134 @@ async function main() { log('Playwright installation skipped', 'yellow'); } - // Kill any existing processes on required ports + // Check for processes on required ports and prompt user log('Checking for processes on ports 3007 and 3008...', 'yellow'); - await killPort(3007); - await killPort(3008); + + const webPortInUse = isPortInUse(3007); + const serverPortInUse = isPortInUse(3008); + + let webPort = 3007; + let serverPort = 3008; + let corsOriginEnv = process.env.CORS_ORIGIN || ''; + + if (webPortInUse || serverPortInUse) { + console.log(''); + if (webPortInUse) { + const pids = getProcessesOnPort(3007); + log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + if (serverPortInUse) { + const pids = getProcessesOnPort(3008); + log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + console.log(''); + + while (true) { + const choice = await prompt( + 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' + ); + const lowerChoice = choice.toLowerCase(); + + if (lowerChoice === 'k' || lowerChoice === 'kill') { + if (webPortInUse) { + await killPort(3007); + } else { + log(`✓ Port 3007 is available`, 'green'); + } + if (serverPortInUse) { + await killPort(3008); + } else { + log(`✓ Port 3008 is available`, 'green'); + } + break; + } else if (lowerChoice === 'u' || lowerChoice === 'use') { + // Prompt for new ports + while (true) { + const newWebPort = await prompt('Enter web port (default 3007): '); + const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; + + if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (isPortInUse(parsedWebPort)) { + const pids = getProcessesOnPort(parsedWebPort); + log( + `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, + 'red' + ); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + webPort = parsedWebPort; + break; + } + + while (true) { + const newServerPort = await prompt('Enter server port (default 3008): '); + const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; + + if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (parsedServerPort === webPort) { + log('Server port cannot be the same as web port.', 'red'); + continue; + } + + if (isPortInUse(parsedServerPort)) { + const pids = getProcessesOnPort(parsedServerPort); + log( + `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, + 'red' + ); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + serverPort = parsedServerPort; + break; + } + + log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); + break; + } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { + log('Cancelled.', 'yellow'); + process.exit(0); + } else { + log( + 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', + 'red' + ); + } + } + } else { + log(`✓ Port 3007 is available`, 'green'); + log(`✓ Port 3008 is available`, 'green'); + } + + // Ensure backend CORS allows whichever UI port we ended up using. + // If CORS_ORIGIN is set, server enforces it strictly (see apps/server/src/index.ts), + // so we must include the selected web origin(s) in that list. + { + const existing = (process.env.CORS_ORIGIN || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => o !== '*'); + const origins = new Set(existing); + origins.add(`http://localhost:${webPort}`); + origins.add(`http://127.0.0.1:${webPort}`); + corsOriginEnv = Array.from(origins).join(','); + } console.log(''); // Show menu @@ -344,8 +539,12 @@ async function main() { console.log(''); log('Launching Web Application...', 'blue'); + // Build shared packages once (dev:server and dev:web both do this at the root level) + log('Building shared packages...', 'blue'); + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); + // Start the backend server - log('Starting backend server on port 3008...', 'blue'); + log(`Starting backend server on port ${serverPort}...`, 'blue'); // Create logs directory if (!fs.existsSync(path.join(__dirname, 'logs'))) { @@ -354,8 +553,12 @@ async function main() { // Start server in background, showing output in console AND logging to file const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); - serverProcess = runNpm(['run', 'dev:server'], { + serverProcess = runNpm(['run', '_dev:server'], { stdio: ['ignore', 'pipe', 'pipe'], + env: { + PORT: String(serverPort), + CORS_ORIGIN: corsOriginEnv, + }, }); // Pipe to both log file and console so user can see API key @@ -375,7 +578,7 @@ async function main() { let serverReady = false; for (let i = 0; i < maxRetries; i++) { - if (await checkHealth()) { + if (await checkHealth(serverPort)) { serverReady = true; break; } @@ -393,11 +596,17 @@ async function main() { } log('✓ Server is ready!', 'green'); - log(`The application will be available at: http://localhost:3007`, 'green'); + log(`The application will be available at: http://localhost:${webPort}`, 'green'); console.log(''); // Start web app - webProcess = runNpm(['run', 'dev:web'], { stdio: 'inherit' }); + webProcess = runNpm(['run', '_dev:web'], { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }); await new Promise((resolve) => { webProcess.on('close', resolve); }); @@ -409,7 +618,18 @@ async function main() { log('(Electron will start its own backend server)', 'yellow'); console.log(''); - electronProcess = runNpm(['run', 'dev:electron'], { stdio: 'inherit' }); + // Pass selected ports through to Vite + Electron backend + // - TEST_PORT controls Vite dev server port (see apps/ui/vite.config.mts) + // - PORT controls backend server port (see apps/server/src/index.ts) + electronProcess = runNpm(['run', 'dev:electron'], { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + }, + }); await new Promise((resolve) => { electronProcess.on('close', resolve); }); diff --git a/libs/dependency-resolver/package.json b/libs/dependency-resolver/package.json index 0ba6f756..4f7c30fd 100644 --- a/libs/dependency-resolver/package.json +++ b/libs/dependency-resolver/package.json @@ -25,12 +25,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/git-utils/package.json b/libs/git-utils/package.json index a34ac9af..ee8fbb79 100644 --- a/libs/git-utils/package.json +++ b/libs/git-utils/package.json @@ -18,13 +18,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0" + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/model-resolver/package.json b/libs/model-resolver/package.json index 742144f7..06a0d252 100644 --- a/libs/model-resolver/package.json +++ b/libs/model-resolver/package.json @@ -18,12 +18,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/platform/package.json b/libs/platform/package.json index 35663d05..21729ef9 100644 --- a/libs/platform/package.json +++ b/libs/platform/package.json @@ -17,13 +17,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0", - "p-limit": "^6.2.0" + "@automaker/types": "1.0.0", + "p-limit": "6.2.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/platform/src/config/ports.ts b/libs/platform/src/config/ports.ts new file mode 100644 index 00000000..1089e966 --- /dev/null +++ b/libs/platform/src/config/ports.ts @@ -0,0 +1,8 @@ +/** + * Centralized port configuration for AutoMaker + * + * Re-exports from @automaker/types for backward compatibility. + * The canonical definition is in @automaker/types to allow browser-safe imports. + */ + +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from '@automaker/types'; diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index eba84101..81ffe224 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -55,3 +55,66 @@ 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'; + +// Port configuration +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.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..95ec503a 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'; @@ -165,17 +165,26 @@ export async function readFile( }, `readFile(${filePath})`); } +/** + * Options for writeFile + */ +export interface WriteFileOptions { + encoding?: BufferEncoding; + mode?: number; + flag?: string; +} + /** * Wrapper around fs.writeFile that validates path first */ export async function writeFile( filePath: string, data: string | Buffer, - encoding?: BufferEncoding + optionsOrEncoding?: BufferEncoding | WriteFileOptions ): Promise { const validatedPath = validatePath(filePath); return executeWithRetry( - () => fs.writeFile(validatedPath, data, encoding), + () => fs.writeFile(validatedPath, data, optionsOrEncoding), `writeFile(${filePath})` ); } @@ -305,3 +314,316 @@ 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 keyPrefix = `${key}=`; + let found = false; + + const newLines = lines.map((line) => { + if (line.trim().startsWith(keyPrefix)) { + 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 keyPrefix = `${key}=`; + const newLines = lines.filter((line) => !line.trim().startsWith(keyPrefix)); + + // 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; +} diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts new file mode 100644 index 00000000..2824d623 --- /dev/null +++ b/libs/platform/src/system-paths.ts @@ -0,0 +1,814 @@ +/** + * 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 + * Includes both full paths and short names to match $SHELL or PATH entries + */ +export function getShellPaths(): string[] { + if (process.platform === 'win32') { + return [ + // Full paths (most specific first) + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + // COMSPEC environment variable (typically cmd.exe) + process.env.COMSPEC || 'C:\\Windows\\System32\\cmd.exe', + // Short names (for PATH resolution) + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', + ]; + } + + // POSIX (macOS, Linux) + return [ + // Full paths + '/bin/zsh', + '/bin/bash', + '/bin/sh', + '/usr/bin/zsh', + '/usr/bin/bash', + '/usr/bin/sh', + '/usr/local/bin/zsh', + '/usr/local/bin/bash', + '/opt/homebrew/bin/zsh', + '/opt/homebrew/bin/bash', + // Short names (for PATH resolution or $SHELL matching) + 'zsh', + 'bash', + 'sh', + ]; +} + +// ============================================================================= +// 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/prompts/package.json b/libs/prompts/package.json index e5954174..0012859f 100644 --- a/libs/prompts/package.json +++ b/libs/prompts/package.json @@ -18,12 +18,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index 09e4f644..c0ae7e0b 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -16,6 +16,7 @@ import type { ResolvedBacklogPlanPrompts, ResolvedEnhancementPrompts, } from '@automaker/types'; +import { STATIC_PORT, SERVER_PORT } from '@automaker/types'; /** * ======================================================================== @@ -208,6 +209,9 @@ This feature depends on: {{dependencies}} **Verification:** {{verificationInstructions}} {{/if}} + +**CRITICAL - Port Protection:** +NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session. `; export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation @@ -299,6 +303,9 @@ You have access to several tools: 4. Ask questions when requirements are unclear 5. Guide users toward good software design principles +**CRITICAL - Port Protection:** +NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session. + Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`; /** diff --git a/libs/types/package.json b/libs/types/package.json index acd0ba75..3a5c2a83 100644 --- a/libs/types/package.json +++ b/libs/types/package.json @@ -15,8 +15,11 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "@types/node": "22.19.3", + "typescript": "5.9.3" } } diff --git a/libs/types/src/error.ts b/libs/types/src/error.ts index 714c8f01..6d34d851 100644 --- a/libs/types/src/error.ts +++ b/libs/types/src/error.ts @@ -7,6 +7,7 @@ export type ErrorType = | 'abort' | 'execution' | 'rate_limit' + | 'quota_exhausted' | 'unknown'; /** @@ -19,6 +20,7 @@ export interface ErrorInfo { isAuth: boolean; isCancellation: boolean; isRateLimit: boolean; + isQuotaExhausted: boolean; // Session/weekly usage limit reached retryAfter?: number; // Seconds to wait before retrying (for rate limit errors) originalError: unknown; } diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 30a903e1..be714877 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -140,3 +140,6 @@ export type { PipelineStatus, FeatureStatusWithPipeline, } from './pipeline.js'; + +// Port configuration +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; diff --git a/libs/types/src/ports.ts b/libs/types/src/ports.ts new file mode 100644 index 00000000..451ecdd7 --- /dev/null +++ b/libs/types/src/ports.ts @@ -0,0 +1,15 @@ +/** + * Centralized port configuration for AutoMaker + * + * These ports are reserved for the Automaker application and should never be + * killed or terminated by AI agents during feature implementation. + */ + +/** Port for the static/UI server (Vite dev server) */ +export const STATIC_PORT = 3007; + +/** Port for the backend API server (Express + WebSocket) */ +export const SERVER_PORT = 3008; + +/** Array of all reserved Automaker ports */ +export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 7e56021d..309703ce 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -351,8 +351,10 @@ export interface GlobalSettings { // Claude Agent SDK Settings /** Auto-load CLAUDE.md files using SDK's settingSources option */ autoLoadClaudeMd?: boolean; - /** Enable sandbox mode for bash commands (default: true, disable if issues occur) */ + /** Enable sandbox mode for bash commands (default: false, enable for additional security) */ enableSandboxMode?: boolean; + /** Skip showing the sandbox risk warning dialog */ + skipSandboxWarning?: boolean; // MCP Server Configuration /** List of configured MCP servers for agent use */ @@ -470,6 +472,13 @@ export interface ProjectSettings { * Default values and constants */ +/** Current version of the global settings schema */ +export const SETTINGS_VERSION = 2; +/** Current version of the credentials schema */ +export const CREDENTIALS_VERSION = 1; +/** Current version of the project settings schema */ +export const PROJECT_SETTINGS_VERSION = 1; + /** Default keyboard shortcut bindings */ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { board: 'K', @@ -496,7 +505,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { /** Default global settings used when no settings file exists */ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { - version: 1, + version: SETTINGS_VERSION, theme: 'dark', sidebarOpen: true, chatHistoryOpen: false, @@ -523,7 +532,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { worktreePanelCollapsed: false, lastSelectedSessionByProject: {}, autoLoadClaudeMd: false, - enableSandboxMode: true, + enableSandboxMode: false, + skipSandboxWarning: false, mcpServers: [], // Default to true for autonomous workflow. Security is enforced when adding servers // via the security warning dialog that explains the risks. @@ -533,7 +543,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { /** Default credentials (empty strings - user must provide API keys) */ export const DEFAULT_CREDENTIALS: Credentials = { - version: 1, + version: CREDENTIALS_VERSION, apiKeys: { anthropic: '', google: '', @@ -543,12 +553,5 @@ export const DEFAULT_CREDENTIALS: Credentials = { /** Default project settings (empty - all settings are optional and fall back to global) */ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { - version: 1, + version: PROJECT_SETTINGS_VERSION, }; - -/** Current version of the global settings schema */ -export const SETTINGS_VERSION = 1; -/** Current version of the credentials schema */ -export const CREDENTIALS_VERSION = 1; -/** Current version of the project settings schema */ -export const PROJECT_SETTINGS_VERSION = 1; diff --git a/libs/utils/package.json b/libs/utils/package.json index c7d612e8..118747be 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -17,13 +17,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/platform": "^1.0.0", - "@automaker/types": "^1.0.0" + "@automaker/platform": "1.0.0", + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/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, diff --git a/libs/utils/src/error-handler.ts b/libs/utils/src/error-handler.ts index eddab383..300e359a 100644 --- a/libs/utils/src/error-handler.ts +++ b/libs/utils/src/error-handler.ts @@ -4,6 +4,7 @@ * Provides utilities for: * - Detecting abort/cancellation errors * - Detecting authentication errors + * - Detecting rate limit and quota exhaustion errors * - Classifying errors by type * - Generating user-friendly error messages */ @@ -52,7 +53,7 @@ export function isAuthenticationError(errorMessage: string): boolean { } /** - * Check if an error is a rate limit error + * Check if an error is a rate limit error (429 Too Many Requests) * * @param error - The error to check * @returns True if the error is a rate limit error @@ -62,6 +63,60 @@ export function isRateLimitError(error: unknown): boolean { return message.includes('429') || message.includes('rate_limit'); } +/** + * Check if an error indicates quota/usage exhaustion + * This includes session limits, weekly limits, credit/billing issues, and overloaded errors + * + * @param error - The error to check + * @returns True if the error indicates quota exhaustion + */ +export function isQuotaExhaustedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error || ''); + const lowerMessage = message.toLowerCase(); + + // Check for overloaded/capacity errors + if ( + lowerMessage.includes('overloaded') || + lowerMessage.includes('overloaded_error') || + lowerMessage.includes('capacity') + ) { + return true; + } + + // Check for usage/quota limit patterns + if ( + lowerMessage.includes('limit reached') || + lowerMessage.includes('usage limit') || + lowerMessage.includes('quota exceeded') || + lowerMessage.includes('quota_exceeded') || + lowerMessage.includes('session limit') || + lowerMessage.includes('weekly limit') || + lowerMessage.includes('monthly limit') + ) { + return true; + } + + // Check for billing/credit issues + if ( + lowerMessage.includes('credit balance') || + lowerMessage.includes('insufficient credits') || + lowerMessage.includes('insufficient balance') || + lowerMessage.includes('no credits') || + lowerMessage.includes('out of credits') || + lowerMessage.includes('billing') || + lowerMessage.includes('payment required') + ) { + return true; + } + + // Check for upgrade prompts (often indicates limit reached) + if (lowerMessage.includes('/upgrade') || lowerMessage.includes('extra-usage')) { + return true; + } + + return false; +} + /** * Extract retry-after duration from rate limit error * @@ -98,11 +153,15 @@ export function classifyError(error: unknown): ErrorInfo { const isAuth = isAuthenticationError(message); const isCancellation = isCancellationError(message); const isRateLimit = isRateLimitError(error); + const isQuotaExhausted = isQuotaExhaustedError(error); const retryAfter = isRateLimit ? (extractRetryAfter(error) ?? 60) : undefined; let type: ErrorType; if (isAuth) { type = 'authentication'; + } else if (isQuotaExhausted) { + // Quota exhaustion takes priority over rate limit since it's more specific + type = 'quota_exhausted'; } else if (isRateLimit) { type = 'rate_limit'; } else if (isAbort) { @@ -122,6 +181,7 @@ export function classifyError(error: unknown): ErrorInfo { isAuth, isCancellation, isRateLimit, + isQuotaExhausted, retryAfter, originalError: error, }; @@ -144,6 +204,10 @@ export function getUserFriendlyErrorMessage(error: unknown): string { return 'Authentication failed. Please check your API key.'; } + if (info.isQuotaExhausted) { + return 'Usage limit reached. Auto Mode has been paused. Please wait for your quota to reset or upgrade your plan.'; + } + if (info.isRateLimit) { const retryMsg = info.retryAfter ? ` Please wait ${info.retryAfter} seconds before retrying.` diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index f7d688c6..772f8c89 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -9,6 +9,7 @@ export { isCancellationError, isAuthenticationError, isRateLimitError, + isQuotaExhaustedError, extractRetryAfter, classifyError, getUserFriendlyErrorMessage, diff --git a/libs/utils/tests/error-handler.test.ts b/libs/utils/tests/error-handler.test.ts index 816cfdbf..0dffd669 100644 --- a/libs/utils/tests/error-handler.test.ts +++ b/libs/utils/tests/error-handler.test.ts @@ -4,6 +4,7 @@ import { isCancellationError, isAuthenticationError, isRateLimitError, + isQuotaExhaustedError, extractRetryAfter, classifyError, getUserFriendlyErrorMessage, @@ -129,6 +130,55 @@ describe('error-handler.ts', () => { }); }); + describe('isQuotaExhaustedError', () => { + it('should return true for overloaded errors', () => { + expect(isQuotaExhaustedError(new Error('overloaded_error: service is busy'))).toBe(true); + expect(isQuotaExhaustedError(new Error('Server is overloaded'))).toBe(true); + expect(isQuotaExhaustedError(new Error('At capacity'))).toBe(true); + }); + + it('should return true for usage limit errors', () => { + expect(isQuotaExhaustedError(new Error('limit reached'))).toBe(true); + expect(isQuotaExhaustedError(new Error('Usage limit exceeded'))).toBe(true); + expect(isQuotaExhaustedError(new Error('quota exceeded'))).toBe(true); + expect(isQuotaExhaustedError(new Error('quota_exceeded'))).toBe(true); + expect(isQuotaExhaustedError(new Error('session limit reached'))).toBe(true); + expect(isQuotaExhaustedError(new Error('weekly limit hit'))).toBe(true); + expect(isQuotaExhaustedError(new Error('monthly limit reached'))).toBe(true); + }); + + it('should return true for billing/credit errors', () => { + expect(isQuotaExhaustedError(new Error('credit balance is too low'))).toBe(true); + expect(isQuotaExhaustedError(new Error('insufficient credits'))).toBe(true); + expect(isQuotaExhaustedError(new Error('insufficient balance'))).toBe(true); + expect(isQuotaExhaustedError(new Error('no credits remaining'))).toBe(true); + expect(isQuotaExhaustedError(new Error('out of credits'))).toBe(true); + expect(isQuotaExhaustedError(new Error('billing issue detected'))).toBe(true); + expect(isQuotaExhaustedError(new Error('payment required'))).toBe(true); + }); + + it('should return true for upgrade prompts', () => { + expect(isQuotaExhaustedError(new Error('Please /upgrade your plan'))).toBe(true); + expect(isQuotaExhaustedError(new Error('extra-usage not enabled'))).toBe(true); + }); + + it('should return false for regular errors', () => { + expect(isQuotaExhaustedError(new Error('Something went wrong'))).toBe(false); + expect(isQuotaExhaustedError(new Error('Network error'))).toBe(false); + expect(isQuotaExhaustedError(new Error(''))).toBe(false); + }); + + it('should return false for null/undefined', () => { + expect(isQuotaExhaustedError(null)).toBe(false); + expect(isQuotaExhaustedError(undefined)).toBe(false); + }); + + it('should handle string errors', () => { + expect(isQuotaExhaustedError('overloaded')).toBe(true); + expect(isQuotaExhaustedError('regular error')).toBe(false); + }); + }); + describe('extractRetryAfter', () => { it('should extract retry-after from error message', () => { const error = new Error('Rate limit exceeded. retry-after: 60'); @@ -170,10 +220,37 @@ describe('error-handler.ts', () => { expect(result.isAbort).toBe(false); expect(result.isCancellation).toBe(false); expect(result.isRateLimit).toBe(false); + expect(result.isQuotaExhausted).toBe(false); expect(result.message).toBe('Authentication failed'); expect(result.originalError).toBe(error); }); + it('should classify quota exhausted errors', () => { + const error = new Error('overloaded_error: service is busy'); + const result = classifyError(error); + + expect(result.type).toBe('quota_exhausted'); + expect(result.isQuotaExhausted).toBe(true); + expect(result.isRateLimit).toBe(false); + expect(result.isAuth).toBe(false); + }); + + it('should classify credit balance errors as quota exhausted', () => { + const error = new Error('credit balance is too low'); + const result = classifyError(error); + + expect(result.type).toBe('quota_exhausted'); + expect(result.isQuotaExhausted).toBe(true); + }); + + it('should classify usage limit errors as quota exhausted', () => { + const error = new Error('usage limit reached'); + const result = classifyError(error); + + expect(result.type).toBe('quota_exhausted'); + expect(result.isQuotaExhausted).toBe(true); + }); + it('should classify rate limit errors', () => { const error = new Error('Error: 429 rate_limit_error'); const result = classifyError(error); @@ -320,6 +397,14 @@ describe('error-handler.ts', () => { expect(message).toBe('Authentication failed. Please check your API key.'); }); + it('should return friendly message for quota exhausted errors', () => { + const error = new Error('overloaded_error'); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toContain('Usage limit reached'); + expect(message).toContain('Auto Mode has been paused'); + }); + it('should return friendly message for rate limit errors', () => { const error = new Error('429 rate_limit_error'); const message = getUserFriendlyErrorMessage(error); diff --git a/package-lock.json b/package-lock.json index 81d55416..cf5f0279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,51 +13,57 @@ "libs/*" ], "dependencies": { - "cross-spawn": "^7.0.6", - "rehype-sanitize": "^6.0.0", - "tree-kill": "^1.2.2" + "cross-spawn": "7.0.6", + "rehype-sanitize": "6.0.0", + "tree-kill": "1.2.2" }, "devDependencies": { - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4" + "husky": "9.1.7", + "lint-staged": "16.2.7", + "prettier": "3.7.4" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "apps/server": { "name": "@automaker/server", - "version": "0.1.0", + "version": "0.7.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.72", - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/git-utils": "^1.0.0", - "@automaker/model-resolver": "^1.0.0", - "@automaker/platform": "^1.0.0", - "@automaker/prompts": "^1.0.0", - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.25.1", - "cookie-parser": "^1.4.7", - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.2.1", - "morgan": "^1.10.1", + "@anthropic-ai/claude-agent-sdk": "0.1.76", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/git-utils": "1.0.0", + "@automaker/model-resolver": "1.0.0", + "@automaker/platform": "1.0.0", + "@automaker/prompts": "1.0.0", + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0", + "@modelcontextprotocol/sdk": "1.25.1", + "cookie-parser": "1.4.7", + "cors": "2.8.5", + "dotenv": "17.2.3", + "express": "5.2.1", + "morgan": "1.10.1", "node-pty": "1.1.0-beta41", - "ws": "^8.18.3" + "ws": "8.18.3" }, "devDependencies": { - "@types/cookie": "^0.6.0", - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/morgan": "^1.9.10", - "@types/node": "^22", - "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "tsx": "^4.21.0", - "typescript": "^5", - "vitest": "^4.0.16" + "@types/cookie": "0.6.0", + "@types/cookie-parser": "1.4.10", + "@types/cors": "2.8.19", + "@types/express": "5.0.6", + "@types/morgan": "1.9.10", + "@types/node": "22.19.3", + "@types/ws": "8.18.1", + "@vitest/coverage-v8": "4.0.16", + "@vitest/ui": "4.0.16", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "apps/server/node_modules/@types/node": { @@ -72,91 +78,104 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.1.0", + "version": "0.7.1", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/types": "^1.0.0", - "@codemirror/lang-xml": "^6.1.0", - "@codemirror/theme-one-dark": "^6.1.3", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@lezer/highlight": "^1.2.3", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-router": "^1.141.6", - "@uiw/react-codemirror": "^4.25.4", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.10.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "dagre": "^0.8.5", - "dotenv": "^17.2.3", - "geist": "^1.5.1", - "lucide-react": "^0.562.0", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/types": "1.0.0", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/theme-one-dark": "6.1.3", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@lezer/highlight": "1.2.3", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-query": "5.90.12", + "@tanstack/react-router": "1.141.6", + "@uiw/react-codemirror": "4.25.4", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "@xyflow/react": "12.10.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "dagre": "0.8.5", + "dotenv": "17.2.3", + "geist": "1.5.1", + "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^3.0.6", - "rehype-raw": "^7.0.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "usehooks-ts": "^3.1.1", - "zustand": "^5.0.9" + "react-markdown": "10.1.0", + "react-resizable-panels": "3.0.6", + "rehype-raw": "7.0.0", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "usehooks-ts": "3.1.1", + "zustand": "5.0.9" }, "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@eslint/js": "^9.0.0", - "@playwright/test": "^1.57.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.141.7", - "@types/dagre": "^0.7.53", - "@types/node": "^22", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.50.0", - "@typescript-eslint/parser": "^8.50.0", - "@vitejs/plugin-react": "^5.1.2", - "cross-env": "^10.1.0", + "@electron/rebuild": "4.0.2", + "@eslint/js": "9.0.0", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "4.1.18", + "@tanstack/router-plugin": "1.141.7", + "@types/dagre": "0.7.53", + "@types/node": "22.19.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@vitejs/plugin-react": "5.1.2", + "cross-env": "10.1.0", "electron": "39.2.7", - "electron-builder": "^26.0.12", - "eslint": "^9.39.2", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", + "electron-builder": "26.0.12", + "eslint": "9.39.2", + "tailwindcss": "4.1.18", + "tw-animate-css": "1.4.0", "typescript": "5.9.3", - "vite": "^7.3.0", - "vite-plugin-electron": "^0.29.0", - "vite-plugin-electron-renderer": "^0.14.6" + "vite": "7.3.0", + "vite-plugin-electron": "0.29.0", + "vite-plugin-electron-renderer": "0.14.6" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "^1.29.2", - "lightningcss-darwin-x64": "^1.29.2", - "lightningcss-linux-arm-gnueabihf": "^1.29.2", - "lightningcss-linux-arm64-gnu": "^1.29.2", - "lightningcss-linux-arm64-musl": "^1.29.2", - "lightningcss-linux-x64-gnu": "^1.29.2", - "lightningcss-linux-x64-musl": "^1.29.2", - "lightningcss-win32-arm64-msvc": "^1.29.2", - "lightningcss-win32-x64-msvc": "^1.29.2" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "apps/ui/node_modules/@eslint/js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.0.0.tgz", + "integrity": "sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "apps/ui/node_modules/@types/node": { @@ -169,6 +188,186 @@ "undici-types": "~6.21.0" } }, + "apps/ui/node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "apps/ui/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "apps/ui/node_modules/react-resizable-panels": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", @@ -184,12 +383,15 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/dependency-resolver/node_modules/@types/node": { @@ -207,13 +409,16 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0" + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/git-utils/node_modules/@types/node": { @@ -231,12 +436,15 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/model-resolver/node_modules/@types/node": { @@ -254,13 +462,16 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/types": "^1.0.0", - "p-limit": "^6.2.0" + "@automaker/types": "1.0.0", + "p-limit": "6.2.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/platform/node_modules/@types/node": { @@ -305,12 +516,15 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/prompts/node_modules/@types/node": { @@ -328,8 +542,11 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "@types/node": "22.19.3", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/types/node_modules/@types/node": { @@ -347,13 +564,16 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@automaker/platform": "^1.0.0", - "@automaker/types": "^1.0.0" + "@automaker/platform": "1.0.0", + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" + }, + "engines": { + "node": ">=22.0.0 <23.0.0" } }, "libs/utils/node_modules/@types/node": { @@ -367,9 +587,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.72", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.72.tgz", - "integrity": "sha512-fS/aTDfpafNA49K3Kn2QCQYpFiz6RckIxDFeBO0xw9ciudkao2M3uqjaa7K4eHMOhrXePfypCij4uTt8D4tyHQ==", + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.76.tgz", + "integrity": "sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" @@ -11315,11 +11535,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11335,11 +11557,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11376,11 +11600,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11396,11 +11622,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11416,11 +11644,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11436,11 +11666,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11456,11 +11688,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11476,11 +11710,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11496,11 +11732,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13937,9 +14175,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index fb5d89b6..bb9c7efa 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "automaker", "version": "1.0.0", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "workspaces": [ "apps/*", "libs/*" @@ -42,7 +45,7 @@ "test:server:coverage": "npm run test:cov --workspace=apps/server", "test:packages": "npm run test -w @automaker/types -w @automaker/utils -w @automaker/prompts -w @automaker/platform -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils --if-present", "test:all": "npm run test:packages && npm run test:server", - "lint:lockfile": "! grep -q 'git+ssh://' package-lock.json || (echo 'Error: package-lock.json contains git+ssh:// URLs. Run: git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"' && exit 1)", + "lint:lockfile": "node scripts/lint-lockfile.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky && npm run build:packages" @@ -53,13 +56,13 @@ ] }, "dependencies": { - "cross-spawn": "^7.0.6", - "rehype-sanitize": "^6.0.0", - "tree-kill": "^1.2.2" + "cross-spawn": "7.0.6", + "rehype-sanitize": "6.0.0", + "tree-kill": "1.2.2" }, "devDependencies": { - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4" + "husky": "9.1.7", + "lint-staged": "16.2.7", + "prettier": "3.7.4" } } diff --git a/scripts/lint-lockfile.mjs b/scripts/lint-lockfile.mjs new file mode 100644 index 00000000..b33c9780 --- /dev/null +++ b/scripts/lint-lockfile.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +/** + * Script to check for git+ssh:// URLs in package-lock.json + * This ensures compatibility with CI/CD environments that don't support SSH. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const lockfilePath = join(process.cwd(), 'package-lock.json'); + +try { + const content = readFileSync(lockfilePath, 'utf8'); + + // Check for git+ssh:// URLs + if (content.includes('git+ssh://')) { + console.error('Error: package-lock.json contains git+ssh:// URLs.'); + console.error('Run: git config --global url."https://github.com/".insteadOf "git@github.com:"'); + console.error('Or run: npm run fix:lockfile'); + process.exit(1); + } + + console.log('✓ No git+ssh:// URLs found in package-lock.json'); + process.exit(0); +} catch (error) { + if (error.code === 'ENOENT') { + console.error('Error: package-lock.json not found'); + process.exit(1); + } + console.error('Error checking package-lock.json:', error.message); + process.exit(1); +}