Compare commits

..

30 Commits

Author SHA1 Message Date
webdevcody
2bbc8113c0 chore: update lockfile linting process
- Replaced the inline linting command for package-lock.json with a dedicated script (lint-lockfile.mjs) to check for git+ssh:// URLs, ensuring compatibility with CI/CD environments.
- The new script provides clear error messages and instructions if such URLs are found, enhancing the development workflow.
2026-01-02 00:29:04 -05:00
webdevcody
7e03af2dc6 chore: release v0.7.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 00:00:41 -05:00
Web Dev Cody
ab9ef0d560 Merge pull request #340 from AutoMaker-Org/fix-web-mode-auth
feat: implement authentication state management and routing logic
2026-01-01 17:13:20 -05:00
webdevcody
844be657c8 feat: add skipSandboxWarning to settings and sync function
- Introduced skipSandboxWarning property in GlobalSettings interface to manage user preference for sandbox risk warnings.
- Updated syncSettingsToServer function to include skipSandboxWarning in the settings synchronization process.
- Set default value for skipSandboxWarning to false in DEFAULT_GLOBAL_SETTINGS.
2026-01-01 17:08:15 -05:00
webdevcody
90c89ef338 Merge branch 'fix-web-mode-auth' of github.com:AutoMaker-Org/automaker into fix-web-mode-auth 2026-01-01 16:49:41 -05:00
webdevcody
fb46c0c9ea feat: enhance sandbox risk dialog and settings management
- Updated the SandboxRiskDialog to include a checkbox for users to opt-out of future warnings, passing the state to the onConfirm callback.
- Modified SettingsView to manage the skipSandboxWarning state, allowing users to reset the warning preference.
- Enhanced DangerZoneSection to display a message when the sandbox warning is disabled and provide an option to reset this setting.
- Updated RootLayoutContent to respect the user's choice regarding the sandbox warning, auto-confirming if the user opts to skip it.
- Added skipSandboxWarning state management to the app store for persistent user preferences.
2026-01-01 16:49:35 -05:00
Kacper
81bd57cf6a feat: add runNpmAndWait function for improved npm command handling
- Introduced a new function, runNpmAndWait, to execute npm commands and wait for their completion, enhancing error handling.
- Updated the main function to build shared packages before starting the backend server, ensuring necessary dependencies are ready.
- Adjusted server and web process commands to use a consistent naming convention.
2026-01-01 22:39:12 +01:00
webdevcody
59d47928a7 feat: implement authentication state management and routing logic
- Added a new auth store using Zustand to manage authentication state, including `authChecked` and `isAuthenticated`.
- Updated `LoginView` to set authentication state upon successful login and navigate based on setup completion.
- Enhanced `RootLayoutContent` to enforce routing rules based on authentication status, redirecting users to login or setup as necessary.
- Improved error handling and loading states during authentication checks.
2026-01-01 16:25:31 -05:00
Web Dev Cody
bd432b1da3 Merge pull request #304 from firstfloris/fix/sandbox-cloud-storage-compatibility
fix: auto-disable sandbox mode for cloud storage paths
2026-01-01 02:48:38 -05:00
webdevcody
b51aed849c fix: clarify sandbox mode behavior in sdk-options
- Updated the checkSandboxCompatibility function to explicitly handle the case when enableSandboxMode is set to false, ensuring clearer logic for sandbox mode activation.
- Adjusted unit tests to reflect the new behavior, confirming that sandbox mode defaults to enabled when not specified and correctly disables for cloud storage paths.
- Enhanced test descriptions for better clarity on expected outcomes in various scenarios.
2026-01-01 02:39:38 -05:00
Web Dev Cody
90e62b8add Merge pull request #337 from AutoMaker-Org/addressing-pr-issues
feat: improve error handling in HttpApiClient
2026-01-01 02:31:59 -05:00
webdevcody
67c6c9a9e7 feat: enhance cloud storage path detection in sdk-options
- Introduced macOS-specific cloud storage patterns and home-anchored folder detection to improve accuracy in identifying cloud storage paths.
- Updated the isCloudStoragePath function to utilize these new patterns, ensuring better handling of cloud storage locations.
- Added comprehensive unit tests to validate detection logic for various cloud storage scenarios, including false positive prevention.
2026-01-01 02:31:02 -05:00
webdevcody
2d66e38fa7 Merge branch 'main' into fix/sandbox-cloud-storage-compatibility 2026-01-01 02:23:10 -05:00
webdevcody
50aac1c218 feat: improve error handling in HttpApiClient
- Added error handling for HTTP responses in the HttpApiClient class.
- Enhanced error messages to include status text and parsed error data, improving debugging and user feedback.
2026-01-01 02:17:12 -05:00
Web Dev Cody
8c8a4875ca Merge pull request #329 from andydataguy/fix/windows-mcp-orphaned-processes
fix(windows): properly terminate MCP server process trees
2026-01-01 02:12:26 -05:00
webdevcody
eec36268fe Merge branch 'main' into fix/windows-mcp-orphaned-processes 2026-01-01 02:09:54 -05:00
WebDevCody
f6efbd1b26 docs: update release process in documentation
- Added steps for committing version bumps and creating git tags in the release process.
- Clarified the verification steps to include checking the visibility of tags on the remote repository.
2026-01-01 01:40:25 -05:00
WebDevCody
019793e047 chore: release v0.7.2 2026-01-01 01:40:04 -05:00
Web Dev Cody
a8a3711246 Merge pull request #336 from AutoMaker-Org/fix-things
refactor: use environment variables for git configuration in test rep…
2026-01-01 01:23:41 -05:00
WebDevCody
b867ca1407 refactor: update window close behavior for macOS and other platforms
- Modified the application to keep the app and servers running when all windows are closed on macOS, aligning with standard macOS behavior.
- On other platforms, ensured that the server processes are stopped and the app quits when all windows are closed, preventing potential port conflicts.
2026-01-01 01:20:34 -05:00
WebDevCody
75143c0792 refactor: clean up whitespace and improve prompt formatting in port management
- Removed unnecessary whitespace in the init.mjs file for better readability.
- Enhanced the formatting of user prompts to improve clarity during port conflict resolution.
2026-01-01 00:46:14 -05:00
WebDevCody
f32f3e82b2 feat: enhance port management and server initialization process
- Added a new function to check if a port is in use without terminating processes, improving user experience during server startup.
- Updated the health check function to accept a dynamic port parameter, allowing for flexible server configurations.
- Implemented user prompts for handling port conflicts, enabling users to kill processes, choose different ports, or cancel the operation.
- Enhanced CORS configuration to support localhost and IPv6 addresses, ensuring compatibility across different development environments.
- Refactored the main function to utilize dynamic port assignments for both the web and server applications, improving overall flexibility.
2026-01-01 00:42:42 -05:00
WebDevCody
abe272ef4d fix: remove TypeScript type annotations from bumpVersion function
- Updated the bumpVersion function to use plain JavaScript by removing TypeScript type annotations, improving compatibility with non-TypeScript environments.
- Cleaned up whitespace in the bump-version.mjs file for better readability.
2025-12-31 23:33:51 -05:00
WebDevCody
6d4ab9cc13 feat: implement version-based migrations for global settings
- Added versioning to global settings, enabling automatic migrations for breaking changes.
- Updated default global settings to reflect the new versioning schema.
- Implemented logic to disable sandbox mode for existing users during migration from version 1 to 2.
- Enhanced error handling for saving migrated settings, ensuring data integrity during updates.
2025-12-31 23:30:44 -05:00
WebDevCody
98381441b9 feat: add GitHub issue fix command and release command
- Introduced a new command for fetching and validating GitHub issues, allowing users to address issues directly from the command line.
- Added a release command to bump the version of the application and build the Electron app, ensuring version consistency across UI and server packages.
- Updated package.json files for both UI and server to version 0.7.1, reflecting the latest changes.
- Implemented version utility in the server to read the version from package.json, enhancing version management across the application.
2025-12-31 23:24:01 -05:00
WebDevCody
eae60ab6b9 feat: update README logo to SVG format
- Replaced the existing PNG logo with a new SVG version for improved scalability and quality.
- Added the SVG logo file to the project, enhancing visual consistency across different display resolutions.
2025-12-31 22:06:54 -05:00
WebDevCody
1d7b64cea8 refactor: use environment variables for git configuration in test repositories
- Updated test repository creation functions to utilize environment variables for git author and committer information, preventing modifications to the user's global git configuration.
- This change enhances test isolation and ensures consistent behavior across different environments.
2025-12-31 22:02:45 -05:00
Test User
6337e266c5 drag top bar 2025-12-31 21:58:22 -05:00
Anand (Andy) Houston
e818922b0d fix(windows): properly terminate MCP server process trees
On Windows, MCP server processes spawned via 'cmd /c npx' weren't being
properly terminated after testing, causing orphaned processes that would
spam logs with "FastMCP warning: server is not responding to ping".

Root cause: client.close() kills only the parent cmd.exe, orphaning child
node.exe processes. taskkill /t needs the parent PID to traverse the tree.

Fix: Run taskkill BEFORE client.close() so the parent PID still exists
when we kill the process tree.

- Add execSync import for taskkill execution
- Add IS_WINDOWS constant for platform check
- Create cleanupConnection() method with proper termination order
- Add comprehensive documentation in docs/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:04:23 +08:00
firstfloris
495af733da fix: auto-disable sandbox mode for cloud storage paths
The Claude CLI sandbox feature is incompatible with cloud storage
virtual filesystems (Dropbox, Google Drive, iCloud, OneDrive).
When a project is in a cloud storage location, sandbox mode is now
automatically disabled with a warning log to prevent process crashes.

Added:
- isCloudStoragePath() to detect cloud storage locations
- checkSandboxCompatibility() for graceful degradation
- 15 new tests for cloud storage detection and sandbox behavior
2025-12-28 20:45:44 +01:00
57 changed files with 2530 additions and 466 deletions

View 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

View 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)

117
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

View File

@@ -1,5 +1,5 @@
<p align="center"> <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> </p>
> **[!TIP]** > **[!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. 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 ## Community & Support
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows. Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
@@ -624,6 +608,22 @@ data/
└── {sessionId}.json └── {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 ## Learn More
### Documentation ### Documentation

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/server", "name": "@automaker/server",
"version": "0.1.0", "version": "0.7.3",
"description": "Backend server for Automaker - provides API for both web and Electron modes", "description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team", "author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
@@ -24,7 +24,7 @@
"test:unit": "vitest run tests/unit" "test:unit": "vitest run tests/unit"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.72", "@anthropic-ai/claude-agent-sdk": "0.1.76",
"@automaker/dependency-resolver": "1.0.0", "@automaker/dependency-resolver": "1.0.0",
"@automaker/git-utils": "1.0.0", "@automaker/git-utils": "1.0.0",
"@automaker/model-resolver": "1.0.0", "@automaker/model-resolver": "1.0.0",

View File

@@ -133,7 +133,11 @@ app.use(
} }
// For local development, allow localhost origins // 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); callback(null, origin);
return; return;
} }

View File

@@ -16,6 +16,7 @@
*/ */
import type { Options } from '@anthropic-ai/claude-agent-sdk'; import type { Options } from '@anthropic-ai/claude-agent-sdk';
import os from 'os';
import path from 'path'; import path from 'path';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; 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 * Tool presets for different use cases
*/ */
@@ -381,7 +504,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification * - Full tool access for code modification
* - Standard turns for interactive sessions * - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default * - 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 * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createChatOptions(config: CreateSdkOptionsConfig): Options { export function createChatOptions(config: CreateSdkOptionsConfig): Options {
@@ -397,6 +520,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Build MCP-related options // Build MCP-related options
const mcpOptions = buildMcpOptions(config); const mcpOptions = buildMcpOptions(config);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel), model: getModelForUseCase('chat', effectiveModel),
@@ -406,7 +532,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
// Apply MCP bypass options if configured // Apply MCP bypass options if configured
...mcpOptions.bypassOptions, ...mcpOptions.bypassOptions,
...(config.enableSandboxMode && { ...(sandboxCheck.enabled && {
sandbox: { sandbox: {
enabled: true, enabled: true,
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
@@ -425,7 +551,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation * - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation * - Extended turns for thorough feature implementation
* - Uses default model (can be overridden) * - 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 * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
@@ -438,6 +564,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Build MCP-related options // Build MCP-related options
const mcpOptions = buildMcpOptions(config); const mcpOptions = buildMcpOptions(config);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('auto', config.model), model: getModelForUseCase('auto', config.model),
@@ -447,7 +576,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
// Apply MCP bypass options if configured // Apply MCP bypass options if configured
...mcpOptions.bypassOptions, ...mcpOptions.bypassOptions,
...(config.enableSandboxMode && { ...(sandboxCheck.enabled && {
sandbox: { sandbox: {
enabled: true, enabled: true,
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,

View File

@@ -74,7 +74,7 @@ export async function getEnableSandboxModeSetting(
try { try {
const globalSettings = await settingsService.getGlobalSettings(); const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? true; const result = globalSettings.enableSandboxMode ?? false;
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`); logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result; return result;
} catch (error) { } catch (error) {

View 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';
}
}

View File

@@ -4,13 +4,14 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getAuthStatus } from '../../../lib/auth.js'; import { getAuthStatus } from '../../../lib/auth.js';
import { getVersion } from '../../../lib/version.js';
export function createDetailedHandler() { export function createDetailedHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0', version: getVersion(),
uptime: process.uptime(), uptime: process.uptime(),
memory: process.memoryUsage(), memory: process.memoryUsage(),
dataDir: process.env.DATA_DIR || './data', dataDir: process.env.DATA_DIR || './data',

View File

@@ -3,13 +3,14 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getVersion } from '../../../lib/version.js';
export function createIndexHandler() { export function createIndexHandler() {
return (_req: Request, res: Response): void => { return (_req: Request, res: Response): void => {
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0', version: getVersion(),
}); });
}; };
} }

View File

@@ -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. * 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. * 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 { try {
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath }); await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
return false; return false;
@@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
try { try {
await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, { await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, {
cwd: repoPath, cwd: repoPath,
env: { ...process.env, ...env },
}); });
logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`); logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`);
return true; return true;

View File

@@ -100,7 +100,14 @@ export function createCreateHandler() {
} }
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed // 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) // First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);

View File

@@ -190,6 +190,10 @@ interface AutoModeConfig {
projectPath: string; 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 { export class AutoModeService {
private events: EventEmitter; private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>(); private runningFeatures = new Map<string, RunningFeature>();
@@ -200,12 +204,89 @@ export class AutoModeService {
private config: AutoModeConfig | null = null; private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>(); private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null; 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) { constructor(events: EventEmitter, settingsService?: SettingsService) {
this.events = events; this.events = events;
this.settingsService = settingsService ?? null; 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 * Start the auto mode loop - continuously picks and executes pending features
*/ */
@@ -214,6 +295,9 @@ export class AutoModeService {
throw new Error('Auto mode is already running'); throw new Error('Auto mode is already running');
} }
// Reset failure tracking when user manually starts auto mode
this.resetFailureTracking();
this.autoLoopRunning = true; this.autoLoopRunning = true;
this.autoLoopAbortController = new AbortController(); this.autoLoopAbortController = new AbortController();
this.config = { this.config = {
@@ -502,6 +586,9 @@ export class AutoModeService {
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus); await this.updateFeatureStatus(projectPath, featureId, finalStatus);
// Record success to reset consecutive failure tracking
this.recordSuccess();
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
passes: true, passes: true,
@@ -529,6 +616,21 @@ export class AutoModeService {
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, 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 { } finally {
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`); console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
@@ -689,6 +791,11 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.cancelPlanApproval(featureId); this.cancelPlanApproval(featureId);
running.abortController.abort(); 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; return true;
} }
@@ -926,6 +1033,9 @@ Address the follow-up instructions above. Review the previous work and make the
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus); await this.updateFeatureStatus(projectPath, featureId, finalStatus);
// Record success to reset consecutive failure tracking
this.recordSuccess();
this.emitAutoModeEvent('auto_mode_feature_complete', { this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
passes: true, passes: true,
@@ -941,6 +1051,19 @@ Address the follow-up instructions above. Review the previous work and make the
errorType: errorInfo.type, errorType: errorInfo.type,
projectPath, 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 { } finally {
this.runningFeatures.delete(featureId); this.runningFeatures.delete(featureId);
@@ -1940,7 +2063,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
}; };
// Execute via provider // Execute via provider
console.log(`[AutoMode] Starting stream for feature ${featureId}...`);
const stream = provider.executeQuery(executeOptions); 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 // Initialize with previous content if this is a follow-up, with a separator
let responseText = previousContent let responseText = previousContent
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
@@ -1978,6 +2103,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
}; };
streamLoop: for await (const msg of stream) { streamLoop: for await (const msg of stream) {
console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || '');
if (msg.type === 'assistant' && msg.message?.content) { if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === 'text') { if (block.type === 'text') {
@@ -2433,6 +2559,9 @@ Implement all the changes described in the plan above.`;
// Only emit progress for non-marker text (marker was already handled above) // Only emit progress for non-marker text (marker was already handled above)
if (!specDetected) { if (!specDetected) {
console.log(
`[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}`
);
this.emitAutoModeEvent('auto_mode_progress', { this.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
content: block.text, content: block.text,

View File

@@ -9,10 +9,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.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 { MCPServerConfig, MCPToolInfo } from '@automaker/types';
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
const execAsync = promisify(exec);
const DEFAULT_TIMEOUT = 10000; // 10 seconds const DEFAULT_TIMEOUT = 10000; // 10 seconds
const IS_WINDOWS = process.platform === 'win32';
export interface MCPTestResult { export interface MCPTestResult {
success: boolean; success: boolean;
@@ -41,6 +45,11 @@ export class MCPTestService {
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> { async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
const startTime = Date.now(); const startTime = Date.now();
let client: Client | null = null; let client: Client | null = null;
let transport:
| StdioClientTransport
| SSEClientTransport
| StreamableHTTPClientTransport
| null = null;
try { try {
client = new Client({ client = new Client({
@@ -49,7 +58,7 @@ export class MCPTestService {
}); });
// Create transport based on server type // Create transport based on server type
const transport = await this.createTransport(serverConfig); transport = await this.createTransport(serverConfig);
// Connect with timeout // Connect with timeout
await Promise.race([ await Promise.race([
@@ -98,13 +107,47 @@ export class MCPTestService {
connectionTime, connectionTime,
}; };
} finally { } finally {
// Clean up client connection // Clean up client connection and ensure process termination
if (client) { await this.cleanupConnection(client, transport);
try { }
await client.close(); }
} catch {
// Ignore cleanup errors /**
} * 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
} }
} }
} }

View File

@@ -124,6 +124,8 @@ export class SettingsService {
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward * Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
* compatibility during schema migrations. * compatibility during schema migrations.
* *
* Also applies version-based migrations for breaking changes.
*
* @returns Promise resolving to complete GlobalSettings object * @returns Promise resolving to complete GlobalSettings object
*/ */
async getGlobalSettings(): Promise<GlobalSettings> { async getGlobalSettings(): Promise<GlobalSettings> {
@@ -131,7 +133,7 @@ export class SettingsService {
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS); const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
// Apply any missing defaults (for backwards compatibility) // Apply any missing defaults (for backwards compatibility)
return { let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS, ...DEFAULT_GLOBAL_SETTINGS,
...settings, ...settings,
keyboardShortcuts: { keyboardShortcuts: {
@@ -139,6 +141,32 @@ export class SettingsService {
...settings.keyboardShortcuts, ...settings.keyboardShortcuts,
}, },
}; };
// Version-based migrations
const storedVersion = settings.version || 1;
let needsSave = false;
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
// Sandbox mode can cause issues on some systems, so we're disabling it by default
if (storedVersion < 2) {
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
result.enableSandboxMode = false;
result.version = SETTINGS_VERSION;
needsSave = true;
}
// Save migrated settings if needed
if (needsSave) {
try {
await ensureDataDir(this.dataDir);
await atomicWriteJson(settingsPath, result);
logger.info('Settings migration complete');
} catch (error) {
logger.error('Failed to save migrated settings:', error);
}
}
return result;
} }
/** /**

View File

@@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise<TestRepo> {
// Initialize git repo // Initialize git repo
await execAsync('git init', { cwd: tmpDir }); 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 // Create initial commit
await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n'); await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n');
await execAsync('git add .', { cwd: tmpDir }); await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly // Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir }); await execAsync('git branch -M main', { cwd: tmpDir });

View File

@@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() { async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
await execAsync('git init', { cwd: repoPath }); await execAsync('git init', { cwd: repoPath });
await execAsync('git config user.email "test@example.com"', { // Don't set git config - use environment variables in commit operations instead
cwd: repoPath, // to avoid affecting user's git config
});
await execAsync('git config user.name "Test User"', { cwd: repoPath });
// Intentionally skip creating an initial commit // Intentionally skip creating an initial commit
} }

View File

@@ -1,15 +1,161 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
describe('sdk-options.ts', () => { describe('sdk-options.ts', () => {
let originalEnv: NodeJS.ProcessEnv; let originalEnv: NodeJS.ProcessEnv;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
originalEnv = { ...process.env }; originalEnv = { ...process.env };
vi.resetModules(); vi.resetModules();
// Spy on os.homedir and set default return value
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test');
}); });
afterEach(() => { afterEach(() => {
process.env = originalEnv; 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', () => { describe('TOOL_PRESETS', () => {
@@ -224,13 +370,27 @@ describe('sdk-options.ts', () => {
expect(options.sandbox).toBeUndefined(); 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 { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({ const options = createChatOptions({
cwd: '/test/path', 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(); expect(options.sandbox).toBeUndefined();
}); });
}); });
@@ -285,13 +445,48 @@ describe('sdk-options.ts', () => {
expect(options.sandbox).toBeUndefined(); 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 { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ const options = createAutoModeOptions({
cwd: '/test/path', 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(); expect(options.sandbox).toBeUndefined();
}); });
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/ui", "name": "@automaker/ui",
"version": "0.1.0", "version": "0.7.3",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker", "homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": { "repository": {

View 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

View 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);
}

View File

@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; 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 { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -62,7 +62,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
// Update preview image when background settings change // Update preview image when background settings change
useEffect(() => { useEffect(() => {
if (currentProject && backgroundSettings.imagePath) { 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 // Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`; const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(

View File

@@ -16,10 +16,12 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
interface SandboxRiskDialogProps { interface SandboxRiskDialogProps {
open: boolean; open: boolean;
onConfirm: () => void; onConfirm: (skipInFuture: boolean) => void;
onDeny: () => void; onDeny: () => void;
} }
@@ -27,6 +29,13 @@ const DOCKER_COMMAND = 'npm run dev:docker';
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) { export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [skipInFuture, setSkipInFuture] = useState(false);
const handleConfirm = () => {
onConfirm(skipInFuture);
// Reset checkbox state after confirmation
setSkipInFuture(false);
};
const handleCopy = async () => { const handleCopy = async () => {
try { try {
@@ -93,18 +102,34 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="gap-2 sm:gap-2 pt-4"> <DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny"> <div className="flex items-center space-x-2 self-start">
Deny &amp; Exit <Checkbox
</Button> id="skip-sandbox-warning"
<Button checked={skipInFuture}
variant="destructive" onCheckedChange={(checked) => setSkipInFuture(checked === true)}
onClick={onConfirm} data-testid="sandbox-skip-checkbox"
className="px-4" />
data-testid="sandbox-confirm" <Label
> htmlFor="skip-sandbox-warning"
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks className="text-sm text-muted-foreground cursor-pointer"
</Button> >
Do not show this warning again
</Label>
</div>
<div className="flex gap-2 sm:gap-2 w-full sm:justify-end">
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
Deny &amp; Exit
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
className="px-4"
data-testid="sandbox-confirm"
>
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -7,6 +7,8 @@ interface AutomakerLogoProps {
} }
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
return ( return (
<div <div
className={cn( className={cn(
@@ -17,7 +19,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
data-testid="logo-button" data-testid="logo-button"
> >
{!sidebarOpen ? ( {!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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256" viewBox="0 0 256 256"
@@ -61,54 +63,62 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
<path d="M164 92 L204 128 L164 164" /> <path d="M164 92 L204 128 L164 164" />
</g> </g>
</svg> </svg>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
v{appVersion}
</span>
</div> </div>
) : ( ) : (
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}> <div className={cn('flex flex-col', 'hidden lg:flex')}>
<svg <div className="flex items-center gap-1">
xmlns="http://www.w3.org/2000/svg" <svg
viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"
role="img" viewBox="0 0 256 256"
aria-label="automaker" role="img"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out" 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)"
> >
<path d="M92 92 L52 128 L92 164" /> <defs>
<path d="M144 72 L116 184" /> <linearGradient
<path d="M164 92 L204 128 L164 164" /> id="bg-expanded"
</g> x1="0"
</svg> y1="0"
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]"> x2="256"
automaker<span className="text-brand-500">.</span> 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> </span>
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { ImageIcon, X, Loader2, FileText } from 'lucide-react'; import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getServerUrlSync } from '@/lib/http-api-client';
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store'; import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
import { import {
sanitizeFilename, sanitizeFilename,
@@ -93,7 +94,7 @@ export function DescriptionImageDropZone({
// Construct server URL for loading saved images // Construct server URL for loading saved images
const getImageServerUrl = useCallback( const getImageServerUrl = useCallback(
(imagePath: string): string => { (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 || ''; const projectPath = currentProject?.path || '';
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
}, },

View File

@@ -206,6 +206,7 @@ export function BoardView() {
checkContextExists, checkContextExists,
features: hookFeatures, features: hookFeatures,
isLoading, isLoading,
featuresWithContext,
setFeaturesWithContext, setFeaturesWithContext,
}); });

View File

@@ -143,7 +143,7 @@ export function CardActions({
<CheckCircle2 className="w-3 h-3 mr-1" /> <CheckCircle2 className="w-3 h-3 mr-1" />
Verify Verify
</Button> </Button>
) : hasContext && onResume ? ( ) : onResume ? (
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
@@ -158,21 +158,6 @@ export function CardActions({
<RotateCcw className="w-3 h-3 mr-1" /> <RotateCcw className="w-3 h-3 mr-1" />
Resume Resume
</Button> </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} ) : null}
{onViewOutput && !feature.skipTests && ( {onViewOutput && !feature.skipTests && (
<Button <Button

View File

@@ -105,9 +105,21 @@ export function AgentOutputModal({
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode) return; if (!api?.autoMode) return;
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
const unsubscribe = api.autoMode.onEvent((event) => { 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) // Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) { if ('featureId' in event && event.featureId !== featureId) {
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
return; return;
} }

View File

@@ -435,21 +435,33 @@ export function useBoardActions({
const handleResumeFeature = useCallback( const handleResumeFeature = useCallback(
async (feature: Feature) => { 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 { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode) { if (!api?.autoMode) {
console.error('Auto mode API not available'); console.error('[Board] Auto mode API not available');
return; return;
} }
console.log('[Board] Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
});
const result = await api.autoMode.resumeFeature( const result = await api.autoMode.resumeFeature(
currentProject.path, currentProject.path,
feature.id, feature.id,
useWorktrees useWorktrees
); );
console.log('[Board] resumeFeature result:', result);
if (result.success) { if (result.success) {
console.log('[Board] Feature resume started successfully'); console.log('[Board] Feature resume started successfully');
} else { } else {

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getServerUrlSync } from '@/lib/http-api-client';
interface UseBoardBackgroundProps { interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null; currentProject: { path: string; id: string } | null;
@@ -23,7 +24,7 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return { return {
backgroundImage: `url(${ backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008' import.meta.env.VITE_SERVER_URL || getServerUrlSync()
}/api/fs/image?path=${encodeURIComponent( }/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${ )}&projectPath=${encodeURIComponent(currentProject.path)}${

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
@@ -12,6 +12,7 @@ interface UseBoardEffectsProps {
checkContextExists: (featureId: string) => Promise<boolean>; checkContextExists: (featureId: string) => Promise<boolean>;
features: any[]; features: any[];
isLoading: boolean; isLoading: boolean;
featuresWithContext: Set<string>;
setFeaturesWithContext: (set: Set<string>) => void; setFeaturesWithContext: (set: Set<string>) => void;
} }
@@ -25,8 +26,14 @@ export function useBoardEffects({
checkContextExists, checkContextExists,
features, features,
isLoading, isLoading,
featuresWithContext,
setFeaturesWithContext, setFeaturesWithContext,
}: UseBoardEffectsProps) { }: 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 // Make current project available globally for modal
useEffect(() => { useEffect(() => {
if (currentProject) { if (currentProject) {
@@ -146,4 +153,30 @@ export function useBoardEffects({
checkAllContexts(); checkAllContexts();
} }
}, [features, isLoading, checkContextExists, setFeaturesWithContext]); }, [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]);
} }

View File

@@ -11,9 +11,13 @@ import { login } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react'; import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useSetupStore } from '@/store/setup-store';
export function LoginView() { export function LoginView() {
const navigate = useNavigate(); const navigate = useNavigate();
const setAuthState = useAuthStore((s) => s.setAuthState);
const setupComplete = useSetupStore((s) => s.setupComplete);
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -26,8 +30,11 @@ export function LoginView() {
try { try {
const result = await login(apiKey.trim()); const result = await login(apiKey.trim());
if (result.success) { if (result.success) {
// Redirect to home/board on success // Mark as authenticated for this session (cookie-based auth)
navigate({ to: '/' }); setAuthState({ isAuthenticated: true, authChecked: true });
// After auth, determine if setup is needed or go to app
navigate({ to: setupComplete ? '/' : '/setup' });
} else { } else {
setError(result.error || 'Invalid API key'); setError(result.error || 'Invalid API key');
} }
@@ -73,7 +80,7 @@ export function LoginView() {
{error && ( {error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive"> <div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span> <span>{error}</span>
</div> </div>
)} )}

View File

@@ -55,6 +55,8 @@ export function SettingsView() {
setAutoLoadClaudeMd, setAutoLoadClaudeMd,
enableSandboxMode, enableSandboxMode,
setEnableSandboxMode, setEnableSandboxMode,
skipSandboxWarning,
setSkipSandboxWarning,
promptCustomization, promptCustomization,
setPromptCustomization, setPromptCustomization,
} = useAppStore(); } = useAppStore();
@@ -184,6 +186,8 @@ export function SettingsView() {
<DangerZoneSection <DangerZoneSection
project={settingsProject} project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)} onDeleteClick={() => setShowDeleteDialog(true)}
skipSandboxWarning={skipSandboxWarning}
onResetSandboxWarning={() => setSkipSandboxWarning(false)}
/> />
); );
default: default:

View File

@@ -1,16 +1,21 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle } from 'lucide-react'; import { Trash2, Folder, AlertTriangle, Shield, RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Project } from '../shared/types'; import type { Project } from '../shared/types';
interface DangerZoneSectionProps { interface DangerZoneSectionProps {
project: Project | null; project: Project | null;
onDeleteClick: () => void; onDeleteClick: () => void;
skipSandboxWarning: boolean;
onResetSandboxWarning: () => void;
} }
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) { export function DangerZoneSection({
if (!project) return null; project,
onDeleteClick,
skipSandboxWarning,
onResetSandboxWarning,
}: DangerZoneSectionProps) {
return ( return (
<div <div
className={cn( className={cn(
@@ -28,35 +33,75 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2> <h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12"> <p className="text-sm text-muted-foreground/80 ml-12">
Permanently remove this project from Automaker. Destructive actions and reset options.
</p> </p>
</div> </div>
<div className="p-6"> <div className="p-6 space-y-4">
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10"> {/* Sandbox Warning Reset */}
<div className="flex items-center gap-3.5 min-w-0"> {skipSandboxWarning && (
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0"> <div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<Folder className="w-5 h-5 text-brand-500" /> <div className="flex items-center gap-3.5 min-w-0">
</div> <div className="w-11 h-11 rounded-xl bg-gradient-to-br from-destructive/15 to-destructive/10 border border-destructive/20 flex items-center justify-center shrink-0">
<div className="min-w-0"> <Shield className="w-5 h-5 text-destructive" />
<p className="font-medium text-foreground truncate">{project.name}</p> </div>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p> <div className="min-w-0">
<p className="font-medium text-foreground">Sandbox Warning Disabled</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
The sandbox environment warning is hidden on startup
</p>
</div>
</div> </div>
<Button
variant="outline"
onClick={onResetSandboxWarning}
data-testid="reset-sandbox-warning-button"
className={cn(
'shrink-0 gap-2',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
</div> </div>
<Button )}
variant="destructive"
onClick={onDeleteClick} {/* Project Delete */}
data-testid="delete-project-button" {project && (
className={cn( <div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
'shrink-0', <div className="flex items-center gap-3.5 min-w-0">
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25', <div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
'transition-all duration-200 ease-out', <Folder className="w-5 h-5 text-brand-500" />
'hover:scale-[1.02] active:scale-[0.98]' </div>
)} <div className="min-w-0">
> <p className="font-medium text-foreground truncate">{project.name}</p>
<Trash2 className="w-4 h-4 mr-2" /> <p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
Delete Project </div>
</Button> </div>
</div> <Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</Button>
</div>
)}
{/* Empty state when nothing to show */}
{!skipSandboxWarning && !project && (
<p className="text-sm text-muted-foreground/60 text-center py-4">
No danger zone actions available.
</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -13,6 +13,7 @@ import {
SquarePlus, SquarePlus,
Settings, Settings,
} from 'lucide-react'; } from 'lucide-react';
import { getServerUrlSync } from '@/lib/http-api-client';
import { import {
useAppStore, useAppStore,
type TerminalPanelContent, type TerminalPanelContent,
@@ -272,7 +273,7 @@ export function TerminalView() {
// Get the default run script from terminal settings // Get the default run script from terminal settings
const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript); 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 // Helper to collect all session IDs from all tabs
const collectAllSessionIds = useCallback((): string[] => { const collectAllSessionIds = useCallback((): string[] => {

View File

@@ -40,7 +40,7 @@ import {
} from '@/config/terminal-themes'; } from '@/config/terminal-themes';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; 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 // Font size constraints
const MIN_FONT_SIZE = 8; const MIN_FONT_SIZE = 8;
@@ -483,7 +483,7 @@ export function TerminalPanel({
[closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal] [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'); const wsUrl = serverUrl.replace(/^http/, 'ws');
// Fetch a short-lived WebSocket token for secure authentication // Fetch a short-lived WebSocket token for secure authentication

View File

@@ -226,6 +226,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
validationModel: state.validationModel, validationModel: state.validationModel,
autoLoadClaudeMd: state.autoLoadClaudeMd, autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode, enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts, keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles, aiProfiles: state.aiProfiles,
mcpServers: state.mcpServers, mcpServers: state.mcpServers,

View File

@@ -9,16 +9,10 @@
* Use this instead of raw fetch() for all authenticated API calls. * 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 // Server URL - uses shared cached URL from http-api-client
const getServerUrl = (): string => { const getServerUrl = (): string => getServerUrlSync();
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return 'http://localhost:3008';
};
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

View File

@@ -95,7 +95,7 @@ import type {
} from '@/types/electron'; } from '@/types/electron';
// Import HTTP API client (ES module) // 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 // Feature type - Import from app-store
import type { Feature } from '@/store/app-store'; import type { Feature } from '@/store/app-store';
@@ -695,7 +695,7 @@ export const checkServerAvailable = async (): Promise<boolean> => {
serverCheckPromise = (async () => { serverCheckPromise = (async () => {
try { 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`, { const response = await fetch(`${serverUrl}/api/health`, {
method: 'GET', method: 'GET',
signal: AbortSignal.timeout(2000), signal: AbortSignal.timeout(2000),

View File

@@ -32,8 +32,34 @@ import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; 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> => {
// window.electronAPI is typed as ElectronAPI, but some Electron-only helpers
// (like getServerUrl) are not part of the shared interface. Narrow via `any`.
const electron = typeof window !== 'undefined' ? (window.electronAPI as any) : null;
if (electron?.getServerUrl) {
try {
cachedServerUrl = await electron.getServerUrl();
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
} catch (error) {
console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
}
}
};
// Server URL - uses cached value from IPC or environment variable
const getServerUrl = (): string => { const getServerUrl = (): string => {
// Use cached URL from Electron IPC if available
if (cachedServerUrl) {
return cachedServerUrl;
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL; const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl; if (envUrl) return envUrl;
@@ -41,6 +67,11 @@ const getServerUrl = (): string => {
return 'http://localhost:3008'; 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) // Cached API key for authentication (Electron mode only)
let cachedApiKey: string | null = null; let cachedApiKey: string | null = null;
let apiKeyInitialized = false; let apiKeyInitialized = false;
@@ -81,11 +112,17 @@ export const clearSessionToken = (): void => {
* Check if we're running in Electron mode * Check if we're running in Electron mode
*/ */
export const isElectronMode = (): boolean => { export const isElectronMode = (): boolean => {
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey; if (typeof window === 'undefined') return false;
// Prefer a stable runtime marker from preload.
// In some dev/electron setups, method availability can be temporarily undefined
// during early startup, but `isElectron` remains reliable.
const api = window.electronAPI as any;
return api?.isElectron === true || !!api?.getApiKey;
}; };
/** /**
* Initialize API key for Electron mode authentication. * Initialize API key and server URL for Electron mode authentication.
* In web mode, authentication uses HTTP-only cookies instead. * In web mode, authentication uses HTTP-only cookies instead.
* *
* This should be called early in app initialization. * This should be called early in app initialization.
@@ -100,6 +137,9 @@ export const initApiKey = async (): Promise<void> => {
// Create and store the promise so concurrent calls wait for the same initialization // Create and store the promise so concurrent calls wait for the same initialization
apiKeyInitPromise = (async () => { apiKeyInitPromise = (async () => {
try { try {
// Initialize server URL from Electron IPC first (needed for API requests)
await initServerUrl();
// Only Electron mode uses API key header auth // Only Electron mode uses API key header auth
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
try { try {
@@ -276,7 +316,9 @@ export const verifySession = async (): Promise<boolean> => {
// Try to clear the cookie via logout (fire and forget) // Try to clear the cookie via logout (fire and forget)
fetch(`${getServerUrl()}/api/auth/logout`, { fetch(`${getServerUrl()}/api/auth/logout`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: '{}',
}).catch(() => {}); }).catch(() => {});
return false; return false;
} }
@@ -325,7 +367,8 @@ type EventType =
| 'auto-mode:event' | 'auto-mode:event'
| 'suggestions:event' | 'suggestions:event'
| 'spec-regeneration:event' | 'spec-regeneration:event'
| 'issue-validation:event'; | 'issue-validation:event'
| 'backlog-plan:event';
type EventCallback = (payload: unknown) => void; type EventCallback = (payload: unknown) => void;
@@ -347,17 +390,20 @@ export class HttpApiClient implements ElectronAPI {
constructor() { constructor() {
this.serverUrl = getServerUrl(); this.serverUrl = getServerUrl();
// Wait for API key initialization before connecting WebSocket // Electron mode: connect WebSocket immediately once API key is ready.
// This prevents 401 errors on startup in Electron mode // Web mode: defer WebSocket connection until a consumer subscribes to events,
waitForApiKeyInit() // to avoid noisy 401s on first-load/login/setup routes.
.then(() => { if (isElectronMode()) {
this.connectWebSocket(); waitForApiKeyInit()
}) .then(() => {
.catch((error) => { this.connectWebSocket();
console.error('[HttpApiClient] API key initialization failed:', error); })
// Still attempt WebSocket connection - it may work with cookie auth .catch((error) => {
this.connectWebSocket(); console.error('[HttpApiClient] API key initialization failed:', error);
}); // Still attempt WebSocket connection - it may work with cookie auth
this.connectWebSocket();
});
}
} }
/** /**
@@ -405,9 +451,24 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true; this.isConnecting = true;
// In Electron mode, use API key directly // Electron mode must authenticate with the injected API key.
const apiKey = getApiKey(); // If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow).
if (apiKey) { if (isElectronMode()) {
const apiKey = getApiKey();
if (!apiKey) {
console.warn(
'[HttpApiClient] Electron mode: API key not ready, delaying WebSocket connect'
);
this.isConnecting = false;
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connectWebSocket();
}, 250);
}
return;
}
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`); this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
return; return;
@@ -450,8 +511,17 @@ export class HttpApiClient implements ElectronAPI {
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); 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); const callbacks = this.eventCallbacks.get(data.type);
if (callbacks) { if (callbacks) {
console.log('[HttpApiClient] Dispatching to', callbacks.size, 'callbacks');
callbacks.forEach((cb) => cb(data.payload)); callbacks.forEach((cb) => cb(data.payload));
} }
} catch (error) { } catch (error) {
@@ -529,6 +599,20 @@ export class HttpApiClient implements ElectronAPI {
credentials: 'include', // Include cookies for session auth credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined, 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(); return response.json();
} }
@@ -539,6 +623,20 @@ export class HttpApiClient implements ElectronAPI {
headers: this.getHeaders(), headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth 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(); return response.json();
} }
@@ -551,6 +649,20 @@ export class HttpApiClient implements ElectronAPI {
credentials: 'include', // Include cookies for session auth credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined, 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(); return response.json();
} }
@@ -562,6 +674,20 @@ export class HttpApiClient implements ElectronAPI {
headers: this.getHeaders(), headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth 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(); return response.json();
} }

View File

@@ -11,6 +11,7 @@ import path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process'; import { spawn, execSync, ChildProcess } from 'child_process';
import crypto from 'crypto'; import crypto from 'crypto';
import http, { Server } from 'http'; import http, { Server } from 'http';
import net from 'net';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { import {
findNodeExecutable, findNodeExecutable,
@@ -51,8 +52,51 @@ if (isDev) {
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let serverProcess: ChildProcess | null = null; let serverProcess: ChildProcess | null = null;
let staticServer: Server | 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 // Window sizing constants for kanban layout
@@ -326,8 +370,8 @@ async function startStaticServer(): Promise<void> {
}); });
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
staticServer!.listen(STATIC_PORT, () => { staticServer!.listen(staticPort, () => {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`); console.log(`[Electron] Static server running at http://localhost:${staticPort}`);
resolve(); resolve();
}); });
staticServer!.on('error', reject); staticServer!.on('error', reject);
@@ -432,7 +476,7 @@ async function startServer(): Promise<void> {
const env = { const env = {
...process.env, ...process.env,
PATH: enhancedPath, PATH: enhancedPath,
PORT: SERVER_PORT.toString(), PORT: serverPort.toString(),
DATA_DIR: app.getPath('userData'), DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules, NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection // Pass API key to server for CSRF protection
@@ -444,6 +488,8 @@ async function startServer(): Promise<void> {
}), }),
}; };
console.log(`[Electron] Server will use port ${serverPort}`);
console.log('[Electron] Starting backend server...'); console.log('[Electron] Starting backend server...');
console.log('[Electron] Server path:', serverPath); console.log('[Electron] Server path:', serverPath);
console.log('[Electron] Server root (cwd):', serverRoot); console.log('[Electron] Server root (cwd):', serverRoot);
@@ -483,7 +529,7 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
await new Promise<void>((resolve, reject) => { 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) { if (res.statusCode === 200) {
resolve(); resolve();
} else { } else {
@@ -548,9 +594,9 @@ function createWindow(): void {
mainWindow.loadURL(VITE_DEV_SERVER_URL); mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else if (isDev) { } else if (isDev) {
// Fallback for dev without Vite server URL // Fallback for dev without Vite server URL
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); mainWindow.loadURL(`http://localhost:${staticPort}`);
} else { } else {
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`); mainWindow.loadURL(`http://localhost:${staticPort}`);
} }
if (isDev && process.env.OPEN_DEVTOOLS === 'true') { if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
@@ -642,6 +688,21 @@ app.whenReady().then(async () => {
ensureApiKey(); ensureApiKey();
try { 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 // Start static file server in production
if (app.isPackaged) { if (app.isPackaged) {
await startStaticServer(); await startStaticServer();
@@ -675,7 +736,29 @@ app.whenReady().then(async () => {
}); });
app.on('window-all-closed', () => { 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 (process.platform !== 'darwin') {
if (serverProcess && serverProcess.pid) {
console.log('[Electron] All windows closed, stopping server...');
if (process.platform === 'win32') {
try {
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
console.error('[Electron] Failed to kill server process:', (error as Error).message);
}
} else {
serverProcess.kill('SIGTERM');
}
serverProcess = null;
}
if (staticServer) {
console.log('[Electron] Stopping static server...');
staticServer.close();
staticServer = null;
}
app.quit(); app.quit();
} }
}); });
@@ -822,7 +905,7 @@ ipcMain.handle('ping', async () => {
// Get server URL for HTTP client // Get server URL for HTTP client
ipcMain.handle('server:getUrl', async () => { ipcMain.handle('server:getUrl', async () => {
return `http://localhost:${SERVER_PORT}`; return `http://localhost:${serverPort}`;
}); });
// Get API key for authentication // Get API key for authentication

View File

@@ -1,5 +1,5 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue } from 'react'; import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { import {
FileBrowserProvider, FileBrowserProvider,
@@ -8,25 +8,30 @@ import {
} from '@/contexts/file-browser-context'; } from '@/contexts/file-browser-context';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { getElectronAPI, isElectron } from '@/lib/electron'; import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils';
import { import {
initApiKey, initApiKey,
isElectronMode, isElectronMode,
verifySession, verifySession,
checkSandboxEnvironment, checkSandboxEnvironment,
getServerUrlSync,
} from '@/lib/http-api-client'; } from '@/lib/http-api-client';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options'; import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; 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() { function RootLayoutContent() {
const location = useLocation(); const location = useLocation();
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore(); const {
setIpcConnected,
currentProject,
getEffectiveTheme,
skipSandboxWarning,
setSkipSandboxWarning,
} = useAppStore();
const { setupComplete } = useSetupStore(); const { setupComplete } = useSetupStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
@@ -34,23 +39,18 @@ function RootLayoutContent() {
const [setupHydrated, setSetupHydrated] = useState( const [setupHydrated, setSetupHydrated] = useState(
() => useSetupStore.persist?.hasHydrated?.() ?? false () => useSetupStore.persist?.hasHydrated?.() ?? false
); );
const [authChecked, setAuthChecked] = useState(false); const authChecked = useAuthStore((s) => s.authChecked);
const [isAuthenticated, setIsAuthenticated] = useState(false); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { openFileBrowser } = useFileBrowser(); const { openFileBrowser } = useFileBrowser();
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
// Sandbox environment check state // Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => { // Always start from pending on a fresh page load so the user sees the prompt
// Check if user previously denied in this session // each time the app is launched/refreshed (unless running in a container).
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) { const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>('pending');
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 // Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
@@ -112,6 +112,9 @@ function RootLayoutContent() {
if (result.isContainerized) { if (result.isContainerized) {
// Running in a container, no warning needed // Running in a container, no warning needed
setSandboxStatus('containerized'); setSandboxStatus('containerized');
} else if (skipSandboxWarning) {
// User opted to skip the warning, auto-confirm
setSandboxStatus('confirmed');
} else { } else {
// Not containerized, show warning dialog // Not containerized, show warning dialog
setSandboxStatus('needs-confirmation'); setSandboxStatus('needs-confirmation');
@@ -119,23 +122,30 @@ function RootLayoutContent() {
} catch (error) { } catch (error) {
console.error('[Sandbox] Failed to check environment:', error); console.error('[Sandbox] Failed to check environment:', error);
// On error, assume not containerized and show warning // On error, assume not containerized and show warning
setSandboxStatus('needs-confirmation'); if (skipSandboxWarning) {
setSandboxStatus('confirmed');
} else {
setSandboxStatus('needs-confirmation');
}
} }
}; };
checkSandbox(); checkSandbox();
}, [sandboxStatus]); }, [sandboxStatus, skipSandboxWarning]);
// Handle sandbox risk confirmation // Handle sandbox risk confirmation
const handleSandboxConfirm = useCallback(() => { const handleSandboxConfirm = useCallback(
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true'); (skipInFuture: boolean) => {
setSandboxStatus('confirmed'); if (skipInFuture) {
}, []); setSkipSandboxWarning(true);
}
setSandboxStatus('confirmed');
},
[setSkipSandboxWarning]
);
// Handle sandbox risk denial // Handle sandbox risk denial
const handleSandboxDeny = useCallback(async () => { const handleSandboxDeny = useCallback(async () => {
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
if (isElectron()) { if (isElectron()) {
// In Electron mode, quit the application // In Electron mode, quit the application
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client // Use window.electronAPI directly since getElectronAPI() returns the HTTP client
@@ -155,19 +165,28 @@ function RootLayoutContent() {
} }
}, []); }, []);
// Ref to prevent concurrent auth checks from running
const authCheckRunning = useRef(false);
// Initialize authentication // Initialize authentication
// - Electron mode: Uses API key from IPC (header-based auth) // - Electron mode: Uses API key from IPC (header-based auth)
// - Web mode: Uses HTTP-only session cookie // - Web mode: Uses HTTP-only session cookie
useEffect(() => { useEffect(() => {
// Prevent concurrent auth checks
if (authCheckRunning.current) {
return;
}
const initAuth = async () => { const initAuth = async () => {
authCheckRunning.current = true;
try { try {
// Initialize API key for Electron mode // Initialize API key for Electron mode
await initApiKey(); await initApiKey();
// In Electron mode, we're always authenticated via header // In Electron mode, we're always authenticated via header
if (isElectronMode()) { if (isElectronMode()) {
setIsAuthenticated(true); useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
setAuthChecked(true);
return; return;
} }
@@ -176,31 +195,23 @@ function RootLayoutContent() {
const isValid = await verifySession(); const isValid = await verifySession();
if (isValid) { if (isValid) {
setIsAuthenticated(true); useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
setAuthChecked(true);
return; return;
} }
// Session is invalid or expired - redirect to login // Session is invalid or expired - treat as not authenticated
console.log('Session invalid or expired - redirecting to login'); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
setIsAuthenticated(false);
setAuthChecked(true);
if (location.pathname !== '/login') {
navigate({ to: '/login' });
}
} catch (error) { } catch (error) {
console.error('Failed to initialize auth:', error); console.error('Failed to initialize auth:', error);
setAuthChecked(true); // On error, treat as not authenticated
// On error, redirect to login to be safe useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
if (location.pathname !== '/login') { } finally {
navigate({ to: '/login' }); authCheckRunning.current = false;
}
} }
}; };
initAuth(); initAuth();
}, [location.pathname, navigate]); }, []); // Runs once per load; auth state drives routing rules
// Wait for setup store hydration before enforcing routing rules // Wait for setup store hydration before enforcing routing rules
useEffect(() => { useEffect(() => {
@@ -220,16 +231,34 @@ function RootLayoutContent() {
}; };
}, []); }, []);
// Redirect first-run users (or anyone who reopened the wizard) to /setup // Routing rules (web mode):
// - If not authenticated: force /login (even /setup is protected)
// - If authenticated but setup incomplete: force /setup
useEffect(() => { useEffect(() => {
if (!setupHydrated) return; if (!setupHydrated) return;
// Wait for auth check to complete before enforcing any redirects
if (!isElectronMode() && !authChecked) return;
// Unauthenticated -> force /login
if (!isElectronMode() && !isAuthenticated) {
if (location.pathname !== '/login') {
navigate({ to: '/login' });
}
return;
}
// Authenticated -> determine whether setup is required
if (!setupComplete && location.pathname !== '/setup') { if (!setupComplete && location.pathname !== '/setup') {
navigate({ to: '/setup' }); navigate({ to: '/setup' });
} else if (setupComplete && location.pathname === '/setup') { return;
}
// Setup complete but user is still on /setup -> go to app
if (setupComplete && location.pathname === '/setup') {
navigate({ to: '/' }); navigate({ to: '/' });
} }
}, [setupComplete, setupHydrated, location.pathname, navigate]); }, [authChecked, isAuthenticated, setupComplete, setupHydrated, location.pathname, navigate]);
useEffect(() => { useEffect(() => {
setGlobalFileBrowser(openFileBrowser); setGlobalFileBrowser(openFileBrowser);
@@ -239,9 +268,19 @@ function RootLayoutContent() {
useEffect(() => { useEffect(() => {
const testConnection = async () => { const testConnection = async () => {
try { try {
const api = getElectronAPI(); if (isElectron()) {
const result = await api.ping(); const api = getElectronAPI();
setIpcConnected(result === 'pong'); const result = await api.ping();
setIpcConnected(result === 'pong');
return;
}
// Web mode: check backend availability without instantiating the full HTTP client
const response = await fetch(`${getServerUrlSync()}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
setIpcConnected(response.ok);
} catch (error) { } catch (error) {
console.error('IPC connection failed:', error); console.error('IPC connection failed:', error);
setIpcConnected(false); setIpcConnected(false);
@@ -279,10 +318,6 @@ function RootLayoutContent() {
} }
}, [deferredTheme]); }, [deferredTheme]);
// Login and setup views are full-screen without sidebar
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
// Show rejection screen if user denied sandbox risk (web mode only) // Show rejection screen if user denied sandbox risk (web mode only)
if (sandboxStatus === 'denied' && !isElectron()) { if (sandboxStatus === 'denied' && !isElectron()) {
return <SandboxRejectionScreen />; return <SandboxRejectionScreen />;
@@ -322,10 +357,16 @@ function RootLayoutContent() {
} }
// Redirect to login if not authenticated (web mode) // Redirect to login if not authenticated (web mode)
// Show loading state while navigation to login is in progress
if (!isElectronMode() && !isAuthenticated) { if (!isElectronMode() && !isAuthenticated) {
return null; // Will redirect via useEffect return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<div className="text-muted-foreground">Redirecting to login...</div>
</main>
);
} }
// Show setup page (full screen, no sidebar) - authenticated only
if (isSetupRoute) { if (isSetupRoute) {
return ( return (
<main className="h-screen overflow-hidden" data-testid="app-container"> <main className="h-screen overflow-hidden" data-testid="app-container">
@@ -342,6 +383,13 @@ function RootLayoutContent() {
return ( return (
<main className="flex h-screen overflow-hidden" data-testid="app-container"> <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 /> <Sidebar />
<div <div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300" className="flex-1 flex flex-col overflow-hidden transition-all duration-300"

View File

@@ -487,6 +487,7 @@ export interface AppState {
// Claude Agent SDK Settings // Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems) enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
// MCP Servers // MCP Servers
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
@@ -775,6 +776,7 @@ export interface AppActions {
// Claude Agent SDK Settings actions // Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>; setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setEnableSandboxMode: (enabled: boolean) => Promise<void>; setEnableSandboxMode: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>; setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>; setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
@@ -975,7 +977,8 @@ const initialState: AppState = {
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
validationModel: 'opus', // Default to opus for GitHub issue validation validationModel: 'opus', // Default to opus for GitHub issue validation
autoLoadClaudeMd: false, // Default to disabled (user must opt-in) autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur) enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default mcpServers: [], // No MCP servers configured by default
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
@@ -1623,6 +1626,12 @@ export const useAppStore = create<AppState & AppActions>()(
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer(); await syncSettingsToServer();
}, },
setSkipSandboxWarning: async (skip) => {
set({ skipSandboxWarning: skip });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setMcpAutoApproveTools: async (enabled) => { setMcpAutoApproveTools: async (enabled) => {
set({ mcpAutoApproveTools: enabled }); set({ mcpAutoApproveTools: enabled });
// Sync to server settings file // Sync to server settings file
@@ -2921,6 +2930,7 @@ export const useAppStore = create<AppState & AppActions>()(
validationModel: state.validationModel, validationModel: state.validationModel,
autoLoadClaudeMd: state.autoLoadClaudeMd, autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode, enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,
// MCP settings // MCP settings
mcpServers: state.mcpServers, mcpServers: state.mcpServers,
mcpAutoApproveTools: state.mcpAutoApproveTools, mcpAutoApproveTools: state.mcpAutoApproveTools,

View File

@@ -0,0 +1,29 @@
import { create } from 'zustand';
interface AuthState {
/** Whether we've attempted to determine auth status for this page load */
authChecked: boolean;
/** Whether the user is currently authenticated (web mode: valid session cookie) */
isAuthenticated: boolean;
}
interface AuthActions {
setAuthState: (state: Partial<AuthState>) => void;
resetAuth: () => void;
}
const initialState: AuthState = {
authChecked: false,
isAuthenticated: false,
};
/**
* Web authentication state.
*
* Intentionally NOT persisted: source of truth is the server session cookie.
*/
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
...initialState,
setAuthState: (state) => set(state),
resetAuth: () => set(initialState),
}));

View File

@@ -9,3 +9,6 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
} }
// Global constants defined in vite.config.mts
declare const __APP_VERSION__: string;

View File

@@ -80,13 +80,21 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
// Initialize git repo // Initialize git repo
await execAsync('git init', { cwd: tmpDir }); 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 // Create initial commit
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n'); fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n');
await execAsync('git add .', { cwd: tmpDir }); await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly // Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir }); await execAsync('git branch -M main', { cwd: tmpDir });
@@ -248,9 +256,18 @@ export async function commitFile(
content: string, content: string,
message: string message: string
): Promise<void> { ): 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); fs.writeFileSync(path.join(repoPath, filePath), content);
await execAsync(`git add "${filePath}"`, { cwd: repoPath }); await execAsync(`git add "${filePath}"`, { cwd: repoPath, env: gitEnv });
await execAsync(`git commit -m "${message}"`, { cwd: repoPath }); await execAsync(`git commit -m "${message}"`, { cwd: repoPath, env: gitEnv });
} }
/** /**

View File

@@ -1,4 +1,5 @@
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
@@ -8,6 +9,10 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.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 }) => { export default defineConfig(({ command }) => {
// Only skip electron plugin during dev server in CI (no display available for Electron) // 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 // Always include it during build - we need dist-electron/main.js for electron-builder
@@ -65,5 +70,8 @@ export default defineConfig(({ command }) => {
build: { build: {
outDir: 'dist', outDir: 'dist',
}, },
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
}; };
}); });

201
init.mjs
View File

@@ -170,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 * Kill processes on a port and wait for it to be freed
*/ */
@@ -211,9 +219,9 @@ function sleep(ms) {
/** /**
* Check if the server health endpoint is responding * Check if the server health endpoint is responding
*/ */
function checkHealth() { function checkHealth(port = 3008) {
return new Promise((resolve) => { 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); resolve(res.statusCode === 200);
}); });
req.on('error', () => resolve(false)); req.on('error', () => resolve(false));
@@ -245,15 +253,35 @@ function prompt(question) {
* Run npm command using cross-spawn for Windows compatibility * Run npm command using cross-spawn for Windows compatibility
*/ */
function runNpm(args, options = {}) { function runNpm(args, options = {}) {
const { env, ...restOptions } = options;
const spawnOptions = { const spawnOptions = {
stdio: 'inherit', stdio: 'inherit',
cwd: __dirname, cwd: __dirname,
...options, ...restOptions,
// Ensure environment variables are properly merged with process.env
env: {
...process.env,
...(env || {}),
},
}; };
// cross-spawn handles Windows .cmd files automatically // cross-spawn handles Windows .cmd files automatically
return crossSpawn('npm', args, spawnOptions); return crossSpawn('npm', args, spawnOptions);
} }
/**
* Run an npm command and wait for completion
*/
function runNpmAndWait(args, options = {}) {
const child = runNpm(args, options);
return new Promise((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`));
});
child.on('error', (err) => reject(err));
});
}
/** /**
* Run npx command using cross-spawn for Windows compatibility * Run npx command using cross-spawn for Windows compatibility
*/ */
@@ -352,10 +380,134 @@ async function main() {
log('Playwright installation skipped', 'yellow'); 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'); 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(''); console.log('');
// Show menu // Show menu
@@ -387,8 +539,12 @@ async function main() {
console.log(''); console.log('');
log('Launching Web Application...', 'blue'); log('Launching Web Application...', 'blue');
// Build shared packages once (dev:server and dev:web both do this at the root level)
log('Building shared packages...', 'blue');
await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' });
// Start the backend server // Start the backend server
log('Starting backend server on port 3008...', 'blue'); log(`Starting backend server on port ${serverPort}...`, 'blue');
// Create logs directory // Create logs directory
if (!fs.existsSync(path.join(__dirname, 'logs'))) { if (!fs.existsSync(path.join(__dirname, 'logs'))) {
@@ -397,8 +553,12 @@ async function main() {
// Start server in background, showing output in console AND logging to file // Start server in background, showing output in console AND logging to file
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
serverProcess = runNpm(['run', 'dev:server'], { serverProcess = runNpm(['run', '_dev:server'], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: {
PORT: String(serverPort),
CORS_ORIGIN: corsOriginEnv,
},
}); });
// Pipe to both log file and console so user can see API key // Pipe to both log file and console so user can see API key
@@ -418,7 +578,7 @@ async function main() {
let serverReady = false; let serverReady = false;
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
if (await checkHealth()) { if (await checkHealth(serverPort)) {
serverReady = true; serverReady = true;
break; break;
} }
@@ -436,11 +596,17 @@ async function main() {
} }
log('✓ Server is ready!', 'green'); 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(''); console.log('');
// Start web app // 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) => { await new Promise((resolve) => {
webProcess.on('close', resolve); webProcess.on('close', resolve);
}); });
@@ -452,7 +618,18 @@ async function main() {
log('(Electron will start its own backend server)', 'yellow'); log('(Electron will start its own backend server)', 'yellow');
console.log(''); 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) => { await new Promise((resolve) => {
electronProcess.on('close', resolve); electronProcess.on('close', resolve);
}); });

View File

@@ -7,6 +7,7 @@ export type ErrorType =
| 'abort' | 'abort'
| 'execution' | 'execution'
| 'rate_limit' | 'rate_limit'
| 'quota_exhausted'
| 'unknown'; | 'unknown';
/** /**
@@ -19,6 +20,7 @@ export interface ErrorInfo {
isAuth: boolean; isAuth: boolean;
isCancellation: boolean; isCancellation: boolean;
isRateLimit: boolean; isRateLimit: boolean;
isQuotaExhausted: boolean; // Session/weekly usage limit reached
retryAfter?: number; // Seconds to wait before retrying (for rate limit errors) retryAfter?: number; // Seconds to wait before retrying (for rate limit errors)
originalError: unknown; originalError: unknown;
} }

View File

@@ -351,8 +351,10 @@ export interface GlobalSettings {
// Claude Agent SDK Settings // Claude Agent SDK Settings
/** Auto-load CLAUDE.md files using SDK's settingSources option */ /** Auto-load CLAUDE.md files using SDK's settingSources option */
autoLoadClaudeMd?: boolean; 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; enableSandboxMode?: boolean;
/** Skip showing the sandbox risk warning dialog */
skipSandboxWarning?: boolean;
// MCP Server Configuration // MCP Server Configuration
/** List of configured MCP servers for agent use */ /** List of configured MCP servers for agent use */
@@ -470,6 +472,13 @@ export interface ProjectSettings {
* Default values and constants * Default values and constants
*/ */
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 2;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
export const PROJECT_SETTINGS_VERSION = 1;
/** Default keyboard shortcut bindings */ /** Default keyboard shortcut bindings */
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
board: 'K', board: 'K',
@@ -496,7 +505,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
/** Default global settings used when no settings file exists */ /** Default global settings used when no settings file exists */
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
version: 1, version: SETTINGS_VERSION,
theme: 'dark', theme: 'dark',
sidebarOpen: true, sidebarOpen: true,
chatHistoryOpen: false, chatHistoryOpen: false,
@@ -523,7 +532,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
worktreePanelCollapsed: false, worktreePanelCollapsed: false,
lastSelectedSessionByProject: {}, lastSelectedSessionByProject: {},
autoLoadClaudeMd: false, autoLoadClaudeMd: false,
enableSandboxMode: true, enableSandboxMode: false,
skipSandboxWarning: false,
mcpServers: [], mcpServers: [],
// Default to true for autonomous workflow. Security is enforced when adding servers // Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks. // via the security warning dialog that explains the risks.
@@ -533,7 +543,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
/** Default credentials (empty strings - user must provide API keys) */ /** Default credentials (empty strings - user must provide API keys) */
export const DEFAULT_CREDENTIALS: Credentials = { export const DEFAULT_CREDENTIALS: Credentials = {
version: 1, version: CREDENTIALS_VERSION,
apiKeys: { apiKeys: {
anthropic: '', anthropic: '',
google: '', google: '',
@@ -543,12 +553,5 @@ export const DEFAULT_CREDENTIALS: Credentials = {
/** Default project settings (empty - all settings are optional and fall back to global) */ /** Default project settings (empty - all settings are optional and fall back to global) */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { 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;

View File

@@ -4,6 +4,7 @@
* Provides utilities for: * Provides utilities for:
* - Detecting abort/cancellation errors * - Detecting abort/cancellation errors
* - Detecting authentication errors * - Detecting authentication errors
* - Detecting rate limit and quota exhaustion errors
* - Classifying errors by type * - Classifying errors by type
* - Generating user-friendly error messages * - 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 * @param error - The error to check
* @returns True if the error is a rate limit error * @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'); 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 * Extract retry-after duration from rate limit error
* *
@@ -98,11 +153,15 @@ export function classifyError(error: unknown): ErrorInfo {
const isAuth = isAuthenticationError(message); const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message); const isCancellation = isCancellationError(message);
const isRateLimit = isRateLimitError(error); const isRateLimit = isRateLimitError(error);
const isQuotaExhausted = isQuotaExhaustedError(error);
const retryAfter = isRateLimit ? (extractRetryAfter(error) ?? 60) : undefined; const retryAfter = isRateLimit ? (extractRetryAfter(error) ?? 60) : undefined;
let type: ErrorType; let type: ErrorType;
if (isAuth) { if (isAuth) {
type = 'authentication'; type = 'authentication';
} else if (isQuotaExhausted) {
// Quota exhaustion takes priority over rate limit since it's more specific
type = 'quota_exhausted';
} else if (isRateLimit) { } else if (isRateLimit) {
type = 'rate_limit'; type = 'rate_limit';
} else if (isAbort) { } else if (isAbort) {
@@ -122,6 +181,7 @@ export function classifyError(error: unknown): ErrorInfo {
isAuth, isAuth,
isCancellation, isCancellation,
isRateLimit, isRateLimit,
isQuotaExhausted,
retryAfter, retryAfter,
originalError: error, originalError: error,
}; };
@@ -144,6 +204,10 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
return 'Authentication failed. Please check your API key.'; 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) { if (info.isRateLimit) {
const retryMsg = info.retryAfter const retryMsg = info.retryAfter
? ` Please wait ${info.retryAfter} seconds before retrying.` ? ` Please wait ${info.retryAfter} seconds before retrying.`

View File

@@ -9,6 +9,7 @@ export {
isCancellationError, isCancellationError,
isAuthenticationError, isAuthenticationError,
isRateLimitError, isRateLimitError,
isQuotaExhaustedError,
extractRetryAfter, extractRetryAfter,
classifyError, classifyError,
getUserFriendlyErrorMessage, getUserFriendlyErrorMessage,

View File

@@ -4,6 +4,7 @@ import {
isCancellationError, isCancellationError,
isAuthenticationError, isAuthenticationError,
isRateLimitError, isRateLimitError,
isQuotaExhaustedError,
extractRetryAfter, extractRetryAfter,
classifyError, classifyError,
getUserFriendlyErrorMessage, 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', () => { describe('extractRetryAfter', () => {
it('should extract retry-after from error message', () => { it('should extract retry-after from error message', () => {
const error = new Error('Rate limit exceeded. retry-after: 60'); 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.isAbort).toBe(false);
expect(result.isCancellation).toBe(false); expect(result.isCancellation).toBe(false);
expect(result.isRateLimit).toBe(false); expect(result.isRateLimit).toBe(false);
expect(result.isQuotaExhausted).toBe(false);
expect(result.message).toBe('Authentication failed'); expect(result.message).toBe('Authentication failed');
expect(result.originalError).toBe(error); 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', () => { it('should classify rate limit errors', () => {
const error = new Error('Error: 429 rate_limit_error'); const error = new Error('Error: 429 rate_limit_error');
const result = classifyError(error); const result = classifyError(error);
@@ -320,6 +397,14 @@ describe('error-handler.ts', () => {
expect(message).toBe('Authentication failed. Please check your API key.'); 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', () => { it('should return friendly message for rate limit errors', () => {
const error = new Error('429 rate_limit_error'); const error = new Error('429 rate_limit_error');
const message = getUserFriendlyErrorMessage(error); const message = getUserFriendlyErrorMessage(error);

609
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@
"test:server:coverage": "npm run test:cov --workspace=apps/server", "test:server:coverage": "npm run test:cov --workspace=apps/server",
"test:packages": "npm run test -w @automaker/types -w @automaker/utils -w @automaker/prompts -w @automaker/platform -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils --if-present", "test:packages": "npm run test -w @automaker/types -w @automaker/utils -w @automaker/prompts -w @automaker/platform -w @automaker/model-resolver -w @automaker/dependency-resolver -w @automaker/git-utils --if-present",
"test:all": "npm run test:packages && npm run test:server", "test:all": "npm run test:packages && npm run test:server",
"lint:lockfile": "! grep -q 'git+ssh://' package-lock.json || (echo 'Error: package-lock.json contains git+ssh:// URLs. Run: git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"' && exit 1)", "lint:lockfile": "node scripts/lint-lockfile.mjs",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"prepare": "husky && npm run build:packages" "prepare": "husky && npm run build:packages"

33
scripts/lint-lockfile.mjs Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Script to check for git+ssh:// URLs in package-lock.json
* This ensures compatibility with CI/CD environments that don't support SSH.
*/
import { readFileSync } from 'fs';
import { join } from 'path';
const lockfilePath = join(process.cwd(), 'package-lock.json');
try {
const content = readFileSync(lockfilePath, 'utf8');
// Check for git+ssh:// URLs
if (content.includes('git+ssh://')) {
console.error('Error: package-lock.json contains git+ssh:// URLs.');
console.error('Run: git config --global url."https://github.com/".insteadOf "git@github.com:"');
console.error('Or run: npm run fix:lockfile');
process.exit(1);
}
console.log('✓ No git+ssh:// URLs found in package-lock.json');
process.exit(0);
} catch (error) {
if (error.code === 'ENOENT') {
console.error('Error: package-lock.json not found');
process.exit(1);
}
console.error('Error checking package-lock.json:', error.message);
process.exit(1);
}