mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge main into feat/cursor-cli-integration
Carefully merged latest changes from main branch into the Cursor CLI integration branch. This merge brings in important improvements and fixes while preserving all Cursor-related functionality. Key changes from main: - Sandbox mode security improvements and cloud storage compatibility - Version-based settings migrations (v2 schema) - Port configuration centralization - System paths utilities for CLI detection - Enhanced error handling in HttpApiClient - Windows MCP process cleanup fixes - New validation and build commands - GitHub issue templates and release process improvements Resolved conflicts in: - apps/server/src/routes/context/routes/describe-image.ts (Combined Cursor provider routing with secure-fs imports) - apps/server/src/services/auto-mode-service.ts (Merged failure tracking with raw output logging) - apps/server/tests/unit/services/terminal-service.test.ts (Updated to async tests with systemPathExists mocking) - libs/platform/src/index.ts (Combined WSL utilities with system-paths exports) - libs/types/src/settings.ts (Merged DEFAULT_PHASE_MODELS with SETTINGS_VERSION constants) All Cursor CLI integration features remain intact including: - CursorProvider and CliProvider base class - Phase-based model configuration - Provider registry and factory patterns - WSL support for Windows - Model override UI components - Cursor-specific settings and configurations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
74
.claude/commands/gh-issue.md
Normal file
74
.claude/commands/gh-issue.md
Normal file
@@ -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 <ISSUE_NUMBER> --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
|
||||
77
.claude/commands/release.md
Normal file
77
.claude/commands/release.md
Normal file
@@ -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 <type>
|
||||
```
|
||||
- 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<version>"
|
||||
```
|
||||
|
||||
5. **Create and push the git tag**
|
||||
- Create an annotated tag for the release:
|
||||
```bash
|
||||
git tag -a v<version> -m "Release v<version>"
|
||||
```
|
||||
- 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)
|
||||
49
.claude/commands/validate-build.md
Normal file
49
.claude/commands/validate-build.md
Normal file
@@ -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.).
|
||||
36
.claude/commands/validate-tests.md
Normal file
36
.claude/commands/validate-tests.md
Normal file
@@ -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).
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -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
|
||||
@@ -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.
|
||||
34
README.md
34
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="apps/ui/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||
<img src="apps/ui/public/readme_logo.svg" alt="Automaker Logo" height="80" />
|
||||
</p>
|
||||
|
||||
> **[!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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
33
apps/server/src/lib/version.ts
Normal file
33
apps/server/src/lib/version.ts
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
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 }),
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.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';
|
||||
@@ -60,13 +60,13 @@ function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unk
|
||||
*/
|
||||
function findActualFilePath(requestedPath: string): string | null {
|
||||
// First, try the exact path
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
if (secureFs.existsSync(requestedPath)) {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
// Try with Unicode normalization
|
||||
const normalizedPath = requestedPath.normalize('NFC');
|
||||
if (fs.existsSync(normalizedPath)) {
|
||||
if (secureFs.existsSync(normalizedPath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -75,12 +75,12 @@ function findActualFilePath(requestedPath: string): string | null {
|
||||
const dir = path.dirname(requestedPath);
|
||||
const baseName = path.basename(requestedPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
if (!secureFs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
const files = secureFs.readdirSync(dir);
|
||||
|
||||
// Normalize the requested basename for comparison
|
||||
// Replace various space-like characters with regular space for comparison
|
||||
@@ -284,9 +284,9 @@ export function createDescribeImageHandler(
|
||||
}
|
||||
|
||||
// Log path + stats (this is often where issues start: missing file, perms, size)
|
||||
let stat: fs.Stats | null = null;
|
||||
let stat: ReturnType<typeof secureFs.statSync> | null = null;
|
||||
try {
|
||||
stat = fs.statSync(actualPath);
|
||||
stat = secureFs.statSync(actualPath);
|
||||
logger.info(
|
||||
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
20
apps/server/src/routes/health/routes/environment.ts
Normal file
20
apps/server/src/routes/health/routes/environment.ts
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
|
||||
/**
|
||||
* Helper to persist API keys to .env file
|
||||
* Uses centralized secureFs.writeEnvKey for path validation
|
||||
*/
|
||||
export async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<GhStatus> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ export function createSessionsListHandler() {
|
||||
}
|
||||
|
||||
export function createSessionsCreateHandler() {
|
||||
return (req: Request, res: Response): void => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
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,
|
||||
|
||||
@@ -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<boolean> {
|
||||
export async function ensureInitialCommit(
|
||||
repoPath: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
||||
return false;
|
||||
@@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -199,6 +199,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<string, RunningFeature>();
|
||||
@@ -209,12 +213,89 @@ export class AutoModeService {
|
||||
private config: AutoModeConfig | null = null;
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
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
|
||||
*/
|
||||
@@ -223,6 +304,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 = {
|
||||
@@ -518,6 +602,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;
|
||||
}
|
||||
|
||||
@@ -951,6 +1058,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,
|
||||
@@ -968,6 +1078,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);
|
||||
@@ -1976,7 +2099,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`
|
||||
@@ -2055,6 +2180,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
// Log raw stream event for debugging
|
||||
appendRawEvent(msg);
|
||||
|
||||
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') {
|
||||
@@ -2528,6 +2654,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,
|
||||
|
||||
@@ -192,9 +192,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);
|
||||
|
||||
@@ -206,13 +205,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}`
|
||||
@@ -223,8 +222,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) => {
|
||||
|
||||
@@ -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<MCPTestResult> {
|
||||
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<void> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,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<GlobalSettings> {
|
||||
@@ -136,7 +138,7 @@ export class SettingsService {
|
||||
const migratedPhaseModels = this.migratePhaseModels(settings);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
@@ -145,6 +147,32 @@ export class SettingsService {
|
||||
},
|
||||
phaseModels: migratedPhaseModels,
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string> {
|
||||
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<TerminalSession | null> {
|
||||
// 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<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !automakerEnvVars.includes(key)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'automaker-terminal',
|
||||
|
||||
@@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,21 +275,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
// Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux)
|
||||
it.skipIf(process.platform === 'win32')('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'],
|
||||
@@ -242,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),
|
||||
@@ -259,67 +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');
|
||||
});
|
||||
|
||||
// Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux)
|
||||
it.skipIf(process.platform === 'win32')('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];
|
||||
@@ -333,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');
|
||||
@@ -374,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);
|
||||
@@ -393,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');
|
||||
@@ -425,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();
|
||||
});
|
||||
@@ -436,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);
|
||||
});
|
||||
@@ -471,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');
|
||||
});
|
||||
@@ -492,20 +553,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAllSessions', () => {
|
||||
// Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux)
|
||||
it.skipIf(process.platform === 'win32')('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');
|
||||
});
|
||||
@@ -538,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();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
38
apps/ui/public/readme_logo.svg
Normal file
38
apps/ui/public/readme_logo.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1456" height="330" viewBox="0 0 1456 330" role="img" aria-label="Automaker Logo">
|
||||
<defs>
|
||||
<!-- Brand Gradient -->
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6B5BFF" />
|
||||
<stop offset="100%" stop-color="#2EC7FF" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Shadow filter -->
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Optional subtle background to ensure contrast on all GitHub themes -->
|
||||
<rect width="1456" height="330" rx="20" fill="#09090b" />
|
||||
|
||||
<!-- Logo Icon (Left Side) -->
|
||||
<g transform="translate(80, 65)">
|
||||
<!-- Rounded square background -->
|
||||
<rect width="200" height="200" rx="50" fill="url(#bg)" />
|
||||
|
||||
<!-- Icon paths -->
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)" transform="translate(100, 100)">
|
||||
<!-- Left bracket < -->
|
||||
<path d="M-30 -30 L-55 0 L-30 30" />
|
||||
<!-- Slash / -->
|
||||
<path d="M15 -45 L-15 45" />
|
||||
<!-- Right bracket > -->
|
||||
<path d="M30 -30 L55 0 L30 30" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Text Section -->
|
||||
<text x="320" y="215" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="160" font-weight="800" letter-spacing="-4" fill="#FFFFFF">
|
||||
automaker<tspan fill="#6B5BFF">.</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
92
apps/ui/scripts/bump-version.mjs
Executable file
92
apps/ui/scripts/bump-version.mjs
Executable file
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-destructive/10 p-4">
|
||||
<ShieldX className="w-12 h-12 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Access Denied</h1>
|
||||
<p className="text-muted-foreground">
|
||||
You declined to accept the risks of running Automaker outside a sandbox environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Automaker in a containerized sandbox environment:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReload}
|
||||
className="gap-2"
|
||||
data-testid="sandbox-retry"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload & Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Sandbox Risk Confirmation Dialog
|
||||
*
|
||||
* Shows when the app is running outside a containerized environment.
|
||||
* Users must acknowledge the risks before proceeding.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SandboxRiskDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-lg"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
Sandbox Environment Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="text-muted-foreground">
|
||||
<strong>Warning:</strong> 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.
|
||||
</p>
|
||||
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>Agents can read, modify, or delete files on your system</li>
|
||||
<li>Agents can execute arbitrary commands and install software</li>
|
||||
<li>Agents can access environment variables and credentials</li>
|
||||
<li>Unintended side effects from agent actions may affect your system</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||
Deny & Exit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="px-4"
|
||||
data-testid="sandbox-confirm"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ interface AutomakerLogoProps {
|
||||
}
|
||||
|
||||
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -17,7 +19,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{!sidebarOpen ? (
|
||||
<div className="relative flex items-center justify-center rounded-lg">
|
||||
<div className="relative flex flex-col items-center justify-center rounded-lg gap-0.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
@@ -61,54 +63,62 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
<div className={cn('flex flex-col', 'hidden lg:flex')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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)}`;
|
||||
},
|
||||
|
||||
@@ -206,6 +206,7 @@ export function BoardView() {
|
||||
checkContextExists,
|
||||
features: hookFeatures,
|
||||
isLoading,
|
||||
featuresWithContext,
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export function CardActions({
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : hasContext && onResume ? (
|
||||
) : onResume ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -158,21 +158,6 @@ export function CardActions({
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : onVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-feature-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
{onViewOutput && !feature.skipTests && (
|
||||
<Button
|
||||
|
||||
@@ -105,9 +105,21 @@ export function AgentOutputModal({
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
console.log(
|
||||
'[AgentOutputModal] Received event:',
|
||||
event.type,
|
||||
'featureId:',
|
||||
'featureId' in event ? event.featureId : 'none',
|
||||
'modalFeatureId:',
|
||||
featureId
|
||||
);
|
||||
|
||||
// Filter events for this specific feature only (skip events without featureId)
|
||||
if ('featureId' in event && event.featureId !== featureId) {
|
||||
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -435,21 +435,33 @@ export function useBoardActions({
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
console.log('[Board] handleResumeFeature called for feature:', feature.id);
|
||||
if (!currentProject) {
|
||||
console.error('[Board] No current project');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
console.error('Auto mode API not available');
|
||||
console.error('[Board] Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Board] Calling resumeFeature API...', {
|
||||
projectPath: currentProject.path,
|
||||
featureId: feature.id,
|
||||
useWorktrees,
|
||||
});
|
||||
|
||||
const result = await api.autoMode.resumeFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
console.log('[Board] resumeFeature result:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[Board] Feature resume started successfully');
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
|
||||
interface UseBoardBackgroundProps {
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -23,7 +24,7 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
|
||||
|
||||
return {
|
||||
backgroundImage: `url(${
|
||||
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'
|
||||
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
|
||||
}/api/fs/image?path=${encodeURIComponent(
|
||||
backgroundSettings.imagePath
|
||||
)}&projectPath=${encodeURIComponent(currentProject.path)}${
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UseBoardEffectsProps {
|
||||
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||
features: any[];
|
||||
isLoading: boolean;
|
||||
featuresWithContext: Set<string>;
|
||||
setFeaturesWithContext: (set: Set<string>) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +26,14 @@ export function useBoardEffects({
|
||||
checkContextExists,
|
||||
features,
|
||||
isLoading,
|
||||
featuresWithContext,
|
||||
setFeaturesWithContext,
|
||||
}: UseBoardEffectsProps) {
|
||||
// Keep a ref to the current featuresWithContext for use in event handlers
|
||||
const featuresWithContextRef = useRef(featuresWithContext);
|
||||
useEffect(() => {
|
||||
featuresWithContextRef.current = featuresWithContext;
|
||||
}, [featuresWithContext]);
|
||||
// Make current project available globally for modal
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
@@ -146,4 +153,30 @@ export function useBoardEffects({
|
||||
checkAllContexts();
|
||||
}
|
||||
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
|
||||
|
||||
// Re-check context when a feature stops, completes, or errors
|
||||
// This ensures hasContext is updated even if the features array doesn't change
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent(async (event) => {
|
||||
// When a feature stops (error/abort) or completes, re-check its context
|
||||
if (
|
||||
(event.type === 'auto_mode_error' || event.type === 'auto_mode_feature_complete') &&
|
||||
event.featureId
|
||||
) {
|
||||
const hasContext = await checkContextExists(event.featureId);
|
||||
if (hasContext) {
|
||||
const newSet = new Set(featuresWithContextRef.current);
|
||||
newSet.add(event.featureId);
|
||||
setFeaturesWithContext(newSet);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [checkContextExists, setFeaturesWithContext]);
|
||||
}
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
quit?: () => Promise<void>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
openDirectory: () => Promise<DialogResult>;
|
||||
openFile: (options?: object) => Promise<DialogResult>;
|
||||
@@ -710,7 +711,7 @@ export const checkServerAvailable = async (): Promise<boolean> => {
|
||||
|
||||
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),
|
||||
|
||||
@@ -32,8 +32,31 @@ 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<void> => {
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getServerUrl) {
|
||||
try {
|
||||
cachedServerUrl = await window.electronAPI.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 +64,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<void> | null = null;
|
||||
|
||||
// Cached session token for authentication (Web mode - explicit header auth)
|
||||
let cachedSessionToken: string | null = null;
|
||||
@@ -52,6 +81,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<void> => {
|
||||
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;
|
||||
|
||||
@@ -73,30 +113,46 @@ export const isElectronMode = (): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<void> => {
|
||||
// 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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -269,6 +325,32 @@ export const verifySession = async (): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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'
|
||||
@@ -296,7 +378,17 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrl();
|
||||
this.connectWebSocket();
|
||||
// Wait for API key initialization before connecting WebSocket
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
waitForApiKeyInit()
|
||||
.then(() => {
|
||||
this.connectWebSocket();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
||||
// Still attempt WebSocket connection - it may work with cookie auth
|
||||
this.connectWebSocket();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,8 +481,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 +561,103 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}
|
||||
|
||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// 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<T>(endpoint: string): Promise<T> {
|
||||
// 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<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// 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<T>(endpoint: string): Promise<T> {
|
||||
// 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 +731,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: [] };
|
||||
}
|
||||
|
||||
@@ -1707,3 +1873,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);
|
||||
});
|
||||
|
||||
@@ -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, execSync, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<typeof setTimeout> | 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<void> {
|
||||
const staticPath = path.join(__dirname, '../dist');
|
||||
@@ -253,20 +321,24 @@ async function startStaticServer(): Promise<void> {
|
||||
} 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<void> {
|
||||
});
|
||||
|
||||
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<void> {
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
* Uses centralized methods for path validation.
|
||||
*/
|
||||
async function startServer(): Promise<void> {
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
@@ -318,8 +391,20 @@ async function startServer(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
? 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<void> {
|
||||
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<void> {
|
||||
}),
|
||||
};
|
||||
|
||||
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<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise<void>((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,7 +736,29 @@ 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();
|
||||
}
|
||||
});
|
||||
@@ -631,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;
|
||||
});
|
||||
|
||||
@@ -720,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
|
||||
@@ -736,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();
|
||||
});
|
||||
|
||||
@@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Window management
|
||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||
|
||||
// App control
|
||||
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
|
||||
});
|
||||
|
||||
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||
|
||||
@@ -8,10 +8,22 @@ import {
|
||||
} from '@/contexts/file-browser-context';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client';
|
||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||
import { isMac } from '@/lib/utils';
|
||||
import {
|
||||
initApiKey,
|
||||
isElectronMode,
|
||||
verifySession,
|
||||
checkSandboxEnvironment,
|
||||
} from '@/lib/http-api-client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||
|
||||
// Session storage key for sandbox risk acknowledgment
|
||||
const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged';
|
||||
const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied';
|
||||
|
||||
function RootLayoutContent() {
|
||||
const location = useLocation();
|
||||
@@ -27,6 +39,20 @@ function RootLayoutContent() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Sandbox environment check state
|
||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => {
|
||||
// Check if user previously denied in this session
|
||||
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) {
|
||||
return 'denied';
|
||||
}
|
||||
// Check if user previously acknowledged in this session
|
||||
if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) {
|
||||
return 'confirmed';
|
||||
}
|
||||
return 'pending';
|
||||
});
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
@@ -73,6 +99,63 @@ function RootLayoutContent() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Check sandbox environment on mount
|
||||
useEffect(() => {
|
||||
// Skip if already decided
|
||||
if (sandboxStatus !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSandbox = async () => {
|
||||
try {
|
||||
const result = await checkSandboxEnvironment();
|
||||
|
||||
if (result.isContainerized) {
|
||||
// Running in a container, no warning needed
|
||||
setSandboxStatus('containerized');
|
||||
} else {
|
||||
// Not containerized, show warning dialog
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to check environment:', error);
|
||||
// On error, assume not containerized and show warning
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
};
|
||||
|
||||
checkSandbox();
|
||||
}, [sandboxStatus]);
|
||||
|
||||
// Handle sandbox risk confirmation
|
||||
const handleSandboxConfirm = useCallback(() => {
|
||||
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
|
||||
setSandboxStatus('confirmed');
|
||||
}, []);
|
||||
|
||||
// Handle sandbox risk denial
|
||||
const handleSandboxDeny = useCallback(async () => {
|
||||
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
|
||||
|
||||
if (isElectron()) {
|
||||
// In Electron mode, quit the application
|
||||
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
|
||||
try {
|
||||
const electronAPI = window.electronAPI;
|
||||
if (electronAPI?.quit) {
|
||||
await electronAPI.quit();
|
||||
} else {
|
||||
console.error('[Sandbox] quit() not available on electronAPI');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to quit app:', error);
|
||||
}
|
||||
} else {
|
||||
// In web mode, show rejection screen
|
||||
setSandboxStatus('denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize authentication
|
||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||
// - Web mode: Uses HTTP-only session cookie
|
||||
@@ -201,11 +284,31 @@ function RootLayoutContent() {
|
||||
const isSetupRoute = location.pathname === '/setup';
|
||||
const isLoginRoute = location.pathname === '/login';
|
||||
|
||||
// Show rejection screen if user denied sandbox risk (web mode only)
|
||||
if (sandboxStatus === 'denied' && !isElectron()) {
|
||||
return <SandboxRejectionScreen />;
|
||||
}
|
||||
|
||||
// Show loading while checking sandbox environment
|
||||
if (sandboxStatus === 'pending') {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<div className="text-muted-foreground">Checking environment...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login page (full screen, no sidebar)
|
||||
if (isLoginRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
{/* Show sandbox dialog on top of login page if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -228,12 +331,25 @@ function RootLayoutContent() {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
{/* Show sandbox dialog on top of setup page if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
{/* Full-width titlebar drag region for Electron window dragging */}
|
||||
{isElectron() && (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<Sidebar />
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
@@ -249,6 +365,13 @@ function RootLayoutContent() {
|
||||
}`}
|
||||
/>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
|
||||
{/* Show sandbox dialog if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1010,7 +1010,7 @@ const initialState: AppState = {
|
||||
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
|
||||
cursorDefaultModel: 'auto', // Default to auto selection
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -197,6 +197,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
}),
|
||||
{
|
||||
name: 'automaker-setup',
|
||||
version: 1, // Add version field for proper hydration (matches app-store pattern)
|
||||
partialize: (state) => ({
|
||||
isFirstRun: state.isFirstRun,
|
||||
setupComplete: state.setupComplete,
|
||||
|
||||
1
apps/ui/src/types/electron.d.ts
vendored
1
apps/ui/src/types/electron.d.ts
vendored
@@ -465,6 +465,7 @@ export interface AutoModeAPI {
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
quit?: () => Promise<void>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Dialog APIs
|
||||
|
||||
3
apps/ui/src/vite-env.d.ts
vendored
3
apps/ui/src/vite-env.d.ts
vendored
@@ -9,3 +9,6 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
// Global constants defined in vite.config.mts
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -80,13 +80,21 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
|
||||
|
||||
// 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<void> {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
|
||||
226
init.mjs
226
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,10 +253,16 @@ 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);
|
||||
@@ -309,10 +366,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
|
||||
@@ -345,7 +526,7 @@ async function main() {
|
||||
log('Launching Web Application...', 'blue');
|
||||
|
||||
// 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'))) {
|
||||
@@ -356,6 +537,10 @@ async function main() {
|
||||
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
|
||||
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 +560,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 +578,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 +600,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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
8
libs/platform/src/config/ports.ts
Normal file
8
libs/platform/src/config/ports.ts
Normal file
@@ -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';
|
||||
@@ -70,3 +70,66 @@ export {
|
||||
type WslCliResult,
|
||||
type WslOptions,
|
||||
} from './wsl.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';
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<Record<string, string>> {
|
||||
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<string, string> {
|
||||
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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
814
libs/platform/src/system-paths.ts
Normal file
814
libs/platform/src/system-paths.ts
Normal file
@@ -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<boolean> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<fsSync.Stats> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
return findFirstExistingPath(getGitHubCliPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude CLI is installed and return its path
|
||||
*/
|
||||
export async function findClaudeCliPath(): Promise<string | null> {
|
||||
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<ClaudeAuthIndicators> {
|
||||
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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -161,3 +161,6 @@ export type {
|
||||
PipelineStatus,
|
||||
FeatureStatusWithPipeline,
|
||||
} from './pipeline.js';
|
||||
|
||||
// Port configuration
|
||||
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js';
|
||||
|
||||
15
libs/types/src/ports.ts
Normal file
15
libs/types/src/ports.ts
Normal file
@@ -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;
|
||||
@@ -435,7 +435,7 @@ 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;
|
||||
|
||||
// MCP Server Configuration
|
||||
@@ -571,6 +571,13 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
|
||||
projectAnalysisModel: 'sonnet',
|
||||
};
|
||||
|
||||
/** 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',
|
||||
@@ -597,7 +604,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,
|
||||
@@ -627,7 +634,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
worktreePanelCollapsed: false,
|
||||
lastSelectedSessionByProject: {},
|
||||
autoLoadClaudeMd: false,
|
||||
enableSandboxMode: true,
|
||||
enableSandboxMode: false,
|
||||
mcpServers: [],
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
@@ -637,7 +644,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: '',
|
||||
@@ -647,12 +654,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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
readdir: (path: string) => Promise<string[]>;
|
||||
readFile: (path: string, encoding?: BufferEncoding) => Promise<string | Buffer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
readdir: (path: string) => Promise<string[]>;
|
||||
readFile: (path: string, encoding: string) => Promise<string>;
|
||||
};
|
||||
fsModule?: ContextFsModule;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +70,12 @@ function getContextDir(projectPath: string): string {
|
||||
*/
|
||||
async function loadContextMetadata(
|
||||
contextDir: string,
|
||||
fsModule: typeof fs
|
||||
fsModule: ContextFsModule
|
||||
): Promise<ContextMetadata> {
|
||||
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<ContextFilesResult> {
|
||||
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<Array<{ name: string; path: string; description?: string }>> {
|
||||
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,
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
isCancellationError,
|
||||
isAuthenticationError,
|
||||
isRateLimitError,
|
||||
isQuotaExhaustedError,
|
||||
extractRetryAfter,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
|
||||
@@ -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);
|
||||
|
||||
518
package-lock.json
generated
518
package-lock.json
generated
@@ -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"
|
||||
@@ -11327,11 +11547,13 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11347,11 +11569,13 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11389,11 +11613,13 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11409,11 +11635,13 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11429,11 +11657,13 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11449,11 +11679,13 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11469,11 +11701,13 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11489,11 +11723,13 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11509,11 +11745,13 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
||||
15
package.json
15
package.json
@@ -2,6 +2,9 @@
|
||||
"name": "automaker",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"libs/*"
|
||||
@@ -53,13 +56,13 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"tree-kill": "^1.2.2"
|
||||
"cross-spawn": "7.0.6",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"tree-kill": "1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.7.4"
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.7",
|
||||
"prettier": "3.7.4"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user