mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
60 Commits
feature/mc
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5ae9ad262 | ||
|
|
5a0ad75059 | ||
|
|
cf62dbbf7a | ||
|
|
a4d1a1497a | ||
|
|
b798260491 | ||
|
|
1fcaa52f72 | ||
|
|
46caae05d2 | ||
|
|
59a6a23f9b | ||
|
|
88bb5b923f | ||
|
|
504d9aa9d7 | ||
|
|
ab0cd95d9a | ||
|
|
4c65855140 | ||
|
|
adfc353b2d | ||
|
|
d5aea8355b | ||
|
|
e498f39153 | ||
|
|
d66259b411 | ||
|
|
e556521c8d | ||
|
|
e448d6d4e5 | ||
|
|
65a09b2d38 | ||
|
|
469ee5ff85 | ||
|
|
04e6ed30a2 | ||
|
|
ec3d78922e | ||
|
|
bc0ef47323 | ||
|
|
579246dc26 | ||
|
|
d68de99c15 | ||
|
|
57b7f92e61 | ||
|
|
dd822c41c5 | ||
|
|
7016985bf2 | ||
|
|
67a6c10edc | ||
|
|
0317dadcaf | ||
|
|
625fddb71e | ||
|
|
63b0ccd035 | ||
|
|
19aa86c027 | ||
|
|
76ad6667f1 | ||
|
|
25c9259b50 | ||
|
|
69a847fe8c | ||
|
|
6f2402e16d | ||
|
|
bacd4f385d | ||
|
|
cc42b79fbc | ||
|
|
eaeb503ee7 | ||
|
|
d028932dc8 | ||
|
|
6bdac230df | ||
|
|
43728e451e | ||
|
|
b93b59951b | ||
|
|
b5a8ed229c | ||
|
|
97ae4b6362 | ||
|
|
5a1e53ca7c | ||
|
|
876d383936 | ||
|
|
96196f906f | ||
|
|
0ee9313441 | ||
|
|
496ace8a8e | ||
|
|
0a21c11a35 | ||
|
|
a526869f21 | ||
|
|
789b807542 | ||
|
|
35b3d3931e | ||
|
|
bad4393dda | ||
|
|
6012e8312b | ||
|
|
8f458e55e2 | ||
|
|
61881d99e2 | ||
|
|
1321a8bd4d |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -80,4 +80,7 @@ blob-report/
|
||||
*.pem
|
||||
|
||||
docker-compose.override.yml
|
||||
.claude/
|
||||
.claude/docker-compose.override.yml
|
||||
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
134
CHANGELOG_RATE_LIMIT_HANDLING.md
Normal file
134
CHANGELOG_RATE_LIMIT_HANDLING.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Improved Error Handling for Rate Limiting
|
||||
|
||||
## Problem
|
||||
|
||||
When running multiple features concurrently in auto-mode, the Claude API rate limits were being exceeded, resulting in cryptic error messages:
|
||||
|
||||
```
|
||||
Error: Claude Code process exited with code 1
|
||||
```
|
||||
|
||||
This error provided no actionable information to users about:
|
||||
|
||||
- What went wrong (rate limit exceeded)
|
||||
- How long to wait before retrying
|
||||
- How to prevent it in the future
|
||||
|
||||
## Root Cause
|
||||
|
||||
The Claude Agent SDK was terminating with exit code 1 when hitting rate limits (HTTP 429), but the error details were not being properly surfaced to the user. The error handling code only showed the generic exit code message instead of the actual API error.
|
||||
|
||||
## Solution
|
||||
|
||||
Implemented comprehensive rate limit error handling across the stack:
|
||||
|
||||
### 1. Enhanced Error Classification (libs/utils)
|
||||
|
||||
Added new error type and detection functions:
|
||||
|
||||
- **New error type**: `'rate_limit'` added to `ErrorType` union
|
||||
- **`isRateLimitError()`**: Detects 429 and rate_limit errors
|
||||
- **`extractRetryAfter()`**: Extracts retry duration from error messages
|
||||
- **Updated `classifyError()`**: Includes rate limit classification with retry-after metadata
|
||||
- **Updated `getUserFriendlyErrorMessage()`**: Provides clear, actionable messages for rate limit errors
|
||||
|
||||
### 2. Improved Claude Provider Error Handling (apps/server)
|
||||
|
||||
Enhanced `ClaudeProvider.executeQuery()` to:
|
||||
|
||||
- Classify all errors using the enhanced error utilities
|
||||
- Detect rate limit errors specifically
|
||||
- Provide user-friendly error messages with:
|
||||
- Clear explanation of the problem (rate limit exceeded)
|
||||
- Retry-after duration when available
|
||||
- Actionable tip: reduce `maxConcurrency` in auto-mode
|
||||
- Preserve original error details for debugging
|
||||
|
||||
### 3. Comprehensive Test Coverage
|
||||
|
||||
Added 8 new tests covering:
|
||||
|
||||
- Rate limit error detection (429, rate_limit keywords)
|
||||
- Retry-after extraction from various message formats
|
||||
- Error classification with retry metadata
|
||||
- User-friendly message generation
|
||||
- Edge cases (null/undefined, non-rate-limit errors)
|
||||
|
||||
**Total test suite**: 162 tests passing ✅
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### Before
|
||||
|
||||
```
|
||||
[AutoMode] Feature touch-gesture-support failed: Error: Claude Code process exited with code 1
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
```
|
||||
[AutoMode] Feature touch-gesture-support failed: Rate limit exceeded (429). Please wait 60 seconds before retrying.
|
||||
|
||||
Tip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Clear communication**: Users understand exactly what went wrong
|
||||
2. **Actionable guidance**: Users know how long to wait and how to prevent future errors
|
||||
3. **Better debugging**: Original error details preserved for technical investigation
|
||||
4. **Type safety**: New `isRateLimit` and `retryAfter` fields properly typed in `ErrorInfo`
|
||||
5. **Comprehensive testing**: All edge cases covered with automated tests
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `libs/types/src/error.ts` - Added `'rate_limit'` type and `retryAfter` field
|
||||
- `libs/utils/src/error-handler.ts` - Added rate limit detection and extraction logic
|
||||
- `libs/utils/src/index.ts` - Exported new utility functions
|
||||
- `libs/utils/tests/error-handler.test.ts` - Added 8 new test cases
|
||||
- `apps/server/src/providers/claude-provider.ts` - Enhanced error handling with user-friendly messages
|
||||
|
||||
### API Changes
|
||||
|
||||
**ErrorInfo interface** (backwards compatible):
|
||||
|
||||
```typescript
|
||||
interface ErrorInfo {
|
||||
type: ErrorType; // Now includes 'rate_limit'
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
isCancellation: boolean;
|
||||
isRateLimit: boolean; // NEW
|
||||
retryAfter?: number; // NEW (seconds to wait)
|
||||
originalError: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**New utility functions**:
|
||||
|
||||
```typescript
|
||||
isRateLimitError(error: unknown): boolean
|
||||
extractRetryAfter(error: unknown): number | undefined
|
||||
```
|
||||
|
||||
## Future Improvements
|
||||
|
||||
This PR lays the groundwork for future enhancements:
|
||||
|
||||
1. **Automatic retry with exponential backoff**: Use `retryAfter` to implement smart retry logic
|
||||
2. **Global rate limiter**: Track requests to prevent hitting limits proactively
|
||||
3. **Concurrency auto-adjustment**: Dynamically reduce concurrency when rate limits are detected
|
||||
4. **User notifications**: Show toast/banner when rate limits are approaching
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
|
||||
```bash
|
||||
npm run test -w @automaker/utils
|
||||
```
|
||||
|
||||
All 162 tests pass, including 8 new rate limit tests.
|
||||
685
CONTRIBUTING.md
Normal file
685
CONTRIBUTING.md
Normal file
@@ -0,0 +1,685 @@
|
||||
# Contributing to Automaker
|
||||
|
||||
Thank you for your interest in contributing to Automaker! We're excited to have you join our community of developers building the future of autonomous AI development.
|
||||
|
||||
Automaker is an autonomous AI development studio that provides a Kanban-based workflow where AI agents implement features in isolated git worktrees. Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your contributions help make this project better for everyone.
|
||||
|
||||
This guide will help you get started with contributing to Automaker. Please take a moment to read through these guidelines to ensure a smooth contribution process.
|
||||
|
||||
## Contribution License Agreement
|
||||
|
||||
**Important:** By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials to the Automaker project, you agree to assign all right, title, and interest in and to your contributions, including all copyrights, patents, and other intellectual property rights, to the Core Contributors of Automaker. This assignment is irrevocable and includes the right to use, modify, distribute, and monetize your contributions in any manner.
|
||||
|
||||
**You understand and agree that you will have no right to receive any royalties, compensation, or other financial benefits from any revenue, income, or commercial use generated from your contributed code or any derivative works thereof.** All contributions are made without expectation of payment or financial return.
|
||||
|
||||
For complete details on contribution terms and rights assignment, please review [Section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT) of the LICENSE](LICENSE#5-contributions-and-rights-assignment).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contributing to Automaker](#contributing-to-automaker)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Fork and Clone](#fork-and-clone)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Branch Naming Convention](#branch-naming-convention)
|
||||
- [Commit Message Format](#commit-message-format)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [1. Prepare Your Changes](#1-prepare-your-changes)
|
||||
- [2. Run Pre-submission Checks](#2-run-pre-submission-checks)
|
||||
- [3. Push Your Changes](#3-push-your-changes)
|
||||
- [4. Open a Pull Request](#4-open-a-pull-request)
|
||||
- [PR Requirements Checklist](#pr-requirements-checklist)
|
||||
- [Review Process](#review-process)
|
||||
- [What to Expect](#what-to-expect)
|
||||
- [Review Focus Areas](#review-focus-areas)
|
||||
- [Responding to Feedback](#responding-to-feedback)
|
||||
- [Approval Criteria](#approval-criteria)
|
||||
- [Getting Help](#getting-help)
|
||||
- [Code Style Guidelines](#code-style-guidelines)
|
||||
- [Testing Requirements](#testing-requirements)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Test Frameworks](#test-frameworks)
|
||||
- [End-to-End Tests (Playwright)](#end-to-end-tests-playwright)
|
||||
- [Unit Tests (Vitest)](#unit-tests-vitest)
|
||||
- [Writing Tests](#writing-tests)
|
||||
- [When to Write Tests](#when-to-write-tests)
|
||||
- [CI/CD Pipeline](#cicd-pipeline)
|
||||
- [CI Checks](#ci-checks)
|
||||
- [CI Testing Environment](#ci-testing-environment)
|
||||
- [Viewing CI Results](#viewing-ci-results)
|
||||
- [Common CI Failures](#common-ci-failures)
|
||||
- [Coverage Requirements](#coverage-requirements)
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Bug Reports](#bug-reports)
|
||||
- [Before Reporting](#before-reporting)
|
||||
- [Bug Report Template](#bug-report-template)
|
||||
- [Feature Requests](#feature-requests)
|
||||
- [Before Requesting](#before-requesting)
|
||||
- [Feature Request Template](#feature-request-template)
|
||||
- [Security Issues](#security-issues)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before contributing to Automaker, ensure you have the following installed on your system:
|
||||
|
||||
- **Node.js 18+** (tested with Node.js 22)
|
||||
- Download from [nodejs.org](https://nodejs.org/)
|
||||
- Verify installation: `node --version`
|
||||
- **npm** (comes with Node.js)
|
||||
- Verify installation: `npm --version`
|
||||
- **Git** for version control
|
||||
- Verify installation: `git --version`
|
||||
- **Claude Code CLI** or **Anthropic API Key** (for AI agent functionality)
|
||||
- Required to run the AI development features
|
||||
|
||||
**Optional but recommended:**
|
||||
|
||||
- A code editor with TypeScript support (VS Code recommended)
|
||||
- GitHub CLI (`gh`) for easier PR management
|
||||
|
||||
### Fork and Clone
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
- Navigate to [https://github.com/AutoMaker-Org/automaker](https://github.com/AutoMaker-Org/automaker)
|
||||
- Click the "Fork" button in the top-right corner
|
||||
- This creates your own copy of the repository
|
||||
|
||||
2. **Clone your fork locally**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/automaker.git
|
||||
cd automaker
|
||||
```
|
||||
|
||||
3. **Add the upstream remote** to keep your fork in sync
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/AutoMaker-Org/automaker.git
|
||||
```
|
||||
|
||||
4. **Verify remotes**
|
||||
```bash
|
||||
git remote -v
|
||||
# Should show:
|
||||
# origin https://github.com/YOUR_USERNAME/automaker.git (fetch)
|
||||
# origin https://github.com/YOUR_USERNAME/automaker.git (push)
|
||||
# upstream https://github.com/AutoMaker-Org/automaker.git (fetch)
|
||||
# upstream https://github.com/AutoMaker-Org/automaker.git (push)
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build shared packages** (required before running the app)
|
||||
|
||||
```bash
|
||||
npm run build:packages
|
||||
```
|
||||
|
||||
3. **Start the development server**
|
||||
```bash
|
||||
npm run dev # Interactive launcher - choose mode
|
||||
npm run dev:web # Browser mode (web interface)
|
||||
npm run dev:electron # Desktop app mode
|
||||
```
|
||||
|
||||
**Common development commands:**
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------ | -------------------------------- |
|
||||
| `npm run dev` | Interactive development launcher |
|
||||
| `npm run dev:web` | Start in browser mode |
|
||||
| `npm run dev:electron` | Start desktop app |
|
||||
| `npm run build` | Build all packages and apps |
|
||||
| `npm run build:packages` | Build shared packages only |
|
||||
| `npm run lint` | Run ESLint checks |
|
||||
| `npm run format` | Format code with Prettier |
|
||||
| `npm run format:check` | Check formatting without changes |
|
||||
| `npm run test` | Run E2E tests (Playwright) |
|
||||
| `npm run test:server` | Run server unit tests |
|
||||
| `npm run test:packages` | Run package tests |
|
||||
| `npm run test:all` | Run all tests |
|
||||
|
||||
### Project Structure
|
||||
|
||||
Automaker is organized as an npm workspace monorepo:
|
||||
|
||||
```
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend
|
||||
│ └── server/ # Express + WebSocket backend
|
||||
├── libs/
|
||||
│ ├── @automaker/types/ # Shared TypeScript types
|
||||
│ ├── @automaker/utils/ # Utility functions
|
||||
│ ├── @automaker/prompts/ # AI prompt templates
|
||||
│ ├── @automaker/platform/ # Platform abstractions
|
||||
│ ├── @automaker/model-resolver/ # AI model resolution
|
||||
│ ├── @automaker/dependency-resolver/ # Dependency management
|
||||
│ └── @automaker/git-utils/ # Git operations
|
||||
├── docs/ # Documentation
|
||||
└── package.json # Root package configuration
|
||||
```
|
||||
|
||||
**Key conventions:**
|
||||
|
||||
- Always import from `@automaker/*` shared packages, never use relative paths to `libs/`
|
||||
- Frontend code lives in `apps/ui/`
|
||||
- Backend code lives in `apps/server/`
|
||||
- Shared logic should be in the appropriate `libs/` package
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
|
||||
|
||||
### Branch Naming Convention
|
||||
|
||||
We use a consistent branch naming pattern to keep our repository organized:
|
||||
|
||||
```
|
||||
<type>/<description>
|
||||
```
|
||||
|
||||
**Branch types:**
|
||||
|
||||
| Type | Purpose | Example |
|
||||
| ---------- | ------------------------ | --------------------------------- |
|
||||
| `feature` | New functionality | `feature/add-user-authentication` |
|
||||
| `fix` | Bug fixes | `fix/resolve-memory-leak` |
|
||||
| `docs` | Documentation changes | `docs/update-contributing-guide` |
|
||||
| `refactor` | Code restructuring | `refactor/simplify-api-handlers` |
|
||||
| `test` | Adding or updating tests | `test/add-utils-unit-tests` |
|
||||
| `chore` | Maintenance tasks | `chore/update-dependencies` |
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Use lowercase letters and hyphens (no underscores or spaces)
|
||||
- Keep descriptions short but descriptive
|
||||
- Include issue number when applicable: `feature/123-add-login`
|
||||
|
||||
```bash
|
||||
# Create and checkout a new feature branch
|
||||
git checkout -b feature/add-dark-mode
|
||||
|
||||
# Create a fix branch with issue reference
|
||||
git checkout -b fix/456-resolve-login-error
|
||||
```
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
We follow the **Conventional Commits** style for clear, readable commit history:
|
||||
|
||||
```
|
||||
<type>: <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
| Type | Purpose |
|
||||
| ---------- | --------------------------- |
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | Formatting (no code change) |
|
||||
| `refactor` | Code restructuring |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Maintenance tasks |
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Use **imperative mood** ("Add feature" not "Added feature")
|
||||
- Keep first line under **72 characters**
|
||||
- Capitalize the first letter after the type prefix
|
||||
- No period at the end of the subject line
|
||||
- Add a blank line before the body for detailed explanations
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Simple commit
|
||||
git commit -m "feat: Add user authentication flow"
|
||||
|
||||
# Commit with body for more context
|
||||
git commit -m "fix: Resolve memory leak in WebSocket handler
|
||||
|
||||
The connection cleanup was not being called when clients
|
||||
disconnected unexpectedly. Added proper cleanup in the
|
||||
error handler to prevent memory accumulation."
|
||||
|
||||
# Documentation update
|
||||
git commit -m "docs: Update API documentation"
|
||||
|
||||
# Refactoring
|
||||
git commit -m "refactor: Simplify state management logic"
|
||||
```
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
Follow these steps to submit your contribution:
|
||||
|
||||
#### 1. Prepare Your Changes
|
||||
|
||||
Ensure you've synced with the latest upstream changes:
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Rebase your branch on main (if needed)
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
#### 2. Run Pre-submission Checks
|
||||
|
||||
Before opening your PR, verify everything passes locally:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:all
|
||||
|
||||
# Check formatting
|
||||
npm run format:check
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Build to verify no compile errors
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### 3. Push Your Changes
|
||||
|
||||
```bash
|
||||
# Push your branch to your fork
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
#### 4. Open a Pull Request
|
||||
|
||||
1. Go to your fork on GitHub
|
||||
2. Click "Compare & pull request" for your branch
|
||||
3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main`
|
||||
4. Fill out the PR template completely
|
||||
|
||||
#### PR Requirements Checklist
|
||||
|
||||
Your PR should include:
|
||||
|
||||
- [ ] **Clear title** describing the change (use conventional commit format)
|
||||
- [ ] **Description** explaining what changed and why
|
||||
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
|
||||
- [ ] **All CI checks passing** (format, lint, build, tests)
|
||||
- [ ] **No merge conflicts** with main branch
|
||||
- [ ] **Tests included** for new functionality
|
||||
- [ ] **Documentation updated** if adding/changing public APIs
|
||||
|
||||
**Example PR Description:**
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
This PR adds dark mode support to the Automaker UI.
|
||||
|
||||
- Implements theme toggle in settings panel
|
||||
- Adds CSS custom properties for theme colors
|
||||
- Persists theme preference to localStorage
|
||||
|
||||
## Related Issue
|
||||
|
||||
Closes #123
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] Tested toggle functionality in Chrome and Firefox
|
||||
- [x] Verified theme persists across page reloads
|
||||
- [x] Checked accessibility contrast ratios
|
||||
|
||||
## Screenshots
|
||||
|
||||
[Include before/after screenshots for UI changes]
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
All contributions go through code review to maintain quality:
|
||||
|
||||
#### What to Expect
|
||||
|
||||
1. **CI Checks Run First** - Automated checks (format, lint, build, tests) must pass before review
|
||||
2. **Maintainer Review** - The project maintainers will review your PR and decide whether to merge it
|
||||
3. **Feedback & Discussion** - The reviewer may ask questions or request changes
|
||||
4. **Iteration** - Make requested changes and push updates to the same branch
|
||||
5. **Approval & Merge** - Once approved and checks pass, your PR will be merged
|
||||
|
||||
#### Review Focus Areas
|
||||
|
||||
The reviewer checks for:
|
||||
|
||||
- **Correctness** - Does the code work as intended?
|
||||
- **Clean Code** - Does it follow our [code style guidelines](#code-style-guidelines)?
|
||||
- **Test Coverage** - Are new features properly tested?
|
||||
- **Documentation** - Are public APIs documented?
|
||||
- **Breaking Changes** - Are any breaking changes discussed first?
|
||||
|
||||
#### Responding to Feedback
|
||||
|
||||
- Respond to **all** review comments, even if just to acknowledge
|
||||
- Ask questions if feedback is unclear
|
||||
- Push additional commits to address feedback (don't force-push during review)
|
||||
- Mark conversations as resolved once addressed
|
||||
|
||||
#### Approval Criteria
|
||||
|
||||
Your PR is ready to merge when:
|
||||
|
||||
- ✅ All CI checks pass
|
||||
- ✅ The maintainer has approved the changes
|
||||
- ✅ All review comments are addressed
|
||||
- ✅ No unresolved merge conflicts
|
||||
|
||||
#### Getting Help
|
||||
|
||||
If your PR seems stuck:
|
||||
|
||||
- Comment asking for status update (mention @webdevcody if needed)
|
||||
- Reach out on [Discord](https://discord.gg/jjem7aEDKU)
|
||||
- Make sure all checks are passing and you've responded to all feedback
|
||||
|
||||
---
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
Automaker uses automated tooling to enforce code style. Run `npm run format` to format code and `npm run lint` to check for issues. Pre-commit hooks automatically format staged files before committing.
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
Testing helps prevent regressions. Automaker uses **Playwright** for end-to-end testing and **Vitest** for unit tests.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Use these commands to run tests locally:
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------ | ------------------------------------- |
|
||||
| `npm run test` | Run E2E tests (Playwright) |
|
||||
| `npm run test:server` | Run server unit tests (Vitest) |
|
||||
| `npm run test:packages` | Run shared package tests |
|
||||
| `npm run test:all` | Run all tests |
|
||||
| `npm run test:server:coverage` | Run server tests with coverage report |
|
||||
|
||||
**Before submitting a PR**, always run the full test suite:
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
### Test Frameworks
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
|
||||
E2E tests verify the entire application works correctly from a user's perspective.
|
||||
|
||||
- **Framework:** [Playwright](https://playwright.dev/)
|
||||
- **Location:** `e2e/` directory
|
||||
- **Test ports:** UI on port 3007, Server on port 3008
|
||||
|
||||
**Running E2E tests:**
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npm run test
|
||||
|
||||
# Run with headed browser (useful for debugging)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run a specific test file
|
||||
npm test --workspace=@automaker/ui -- tests/example.spec.ts
|
||||
```
|
||||
|
||||
**E2E Test Guidelines:**
|
||||
|
||||
- Write tests from a user's perspective
|
||||
- Use descriptive test names that explain the scenario
|
||||
- Clean up test data after each test
|
||||
- Use appropriate timeouts for async operations
|
||||
- Prefer `locator` over direct selectors for resilience
|
||||
|
||||
#### Unit Tests (Vitest)
|
||||
|
||||
Unit tests verify individual functions and modules work correctly in isolation.
|
||||
|
||||
- **Framework:** [Vitest](https://vitest.dev/)
|
||||
- **Location:** In the `tests/` directory within each package (e.g., `apps/server/tests/`)
|
||||
|
||||
**Running unit tests:**
|
||||
|
||||
```bash
|
||||
# Run all server unit tests
|
||||
npm run test:server
|
||||
|
||||
# Run with coverage report
|
||||
npm run test:server:coverage
|
||||
|
||||
# Run package tests
|
||||
npm run test:packages
|
||||
|
||||
# Run in watch mode during development
|
||||
npx vitest --watch
|
||||
```
|
||||
|
||||
**Unit Test Guidelines:**
|
||||
|
||||
- Keep tests small and focused on one behavior
|
||||
- Use descriptive test names: `it('should return null when user is not found')`
|
||||
- Follow the AAA pattern: Arrange, Act, Assert
|
||||
- Mock external dependencies to isolate the unit under test
|
||||
- Aim for meaningful coverage, not just line coverage
|
||||
|
||||
### Writing Tests
|
||||
|
||||
#### When to Write Tests
|
||||
|
||||
- **New features:** All new features should include tests
|
||||
- **Bug fixes:** Add a test that reproduces the bug before fixing
|
||||
- **Refactoring:** Ensure existing tests pass after refactoring
|
||||
- **Public APIs:** All public APIs must have test coverage
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
Automaker uses **GitHub Actions** for continuous integration. Every pull request triggers automated checks.
|
||||
|
||||
#### CI Checks
|
||||
|
||||
The following checks must pass before your PR can be merged:
|
||||
|
||||
| Check | Description |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| **Format** | Verifies code is formatted with Prettier |
|
||||
| **Build** | Ensures the project compiles without errors |
|
||||
| **Package Tests** | Runs tests for shared `@automaker/*` packages |
|
||||
| **Server Tests** | Runs server unit tests with coverage |
|
||||
|
||||
#### CI Testing Environment
|
||||
|
||||
For CI environments, Automaker supports a mock agent mode:
|
||||
|
||||
```bash
|
||||
# Enable mock agent mode for CI testing
|
||||
AUTOMAKER_MOCK_AGENT=true npm run test
|
||||
```
|
||||
|
||||
This allows tests to run without requiring a real Claude API connection.
|
||||
|
||||
#### Viewing CI Results
|
||||
|
||||
1. Go to your PR on GitHub
|
||||
2. Scroll to the "Checks" section at the bottom
|
||||
3. Click on any failed check to see detailed logs
|
||||
4. Fix issues locally and push updates
|
||||
|
||||
#### Common CI Failures
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| Format check failed | Run `npm run format` locally |
|
||||
| Build failed | Run `npm run build` and fix TypeScript errors |
|
||||
| Tests failed | Run `npm run test:all` locally to reproduce |
|
||||
| Coverage decreased | Add tests for new code paths |
|
||||
|
||||
### Coverage Requirements
|
||||
|
||||
While we don't enforce strict coverage percentages, we expect:
|
||||
|
||||
- **New features:** Should include comprehensive tests
|
||||
- **Bug fixes:** Should include a regression test
|
||||
- **Critical paths:** Must have test coverage (authentication, data persistence, etc.)
|
||||
|
||||
To view coverage reports locally:
|
||||
|
||||
```bash
|
||||
npm run test:server:coverage
|
||||
```
|
||||
|
||||
This generates an HTML report you can open in your browser to see which lines are covered.
|
||||
|
||||
---
|
||||
|
||||
## Issue Reporting
|
||||
|
||||
Found a bug or have an idea for a new feature? We'd love to hear from you! This section explains how to report issues effectively.
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When reporting a bug, please provide as much information as possible to help us understand and reproduce the issue.
|
||||
|
||||
#### Before Reporting
|
||||
|
||||
1. **Search existing issues** - Check if the bug has already been reported
|
||||
2. **Try the latest version** - Make sure you're running the latest version of Automaker
|
||||
3. **Reproduce the issue** - Verify you can consistently reproduce the bug
|
||||
|
||||
#### Bug Report Template
|
||||
|
||||
When creating a bug report, include:
|
||||
|
||||
- **Title:** A clear, descriptive title summarizing the issue
|
||||
- **Environment:**
|
||||
- Operating System and version
|
||||
- Node.js version (`node --version`)
|
||||
- Automaker version or commit hash
|
||||
- **Steps to Reproduce:** Numbered list of steps to reproduce the bug
|
||||
- **Expected Behavior:** What you expected to happen
|
||||
- **Actual Behavior:** What actually happened
|
||||
- **Logs/Screenshots:** Any relevant error messages, console output, or screenshots
|
||||
|
||||
**Example Bug Report:**
|
||||
|
||||
```markdown
|
||||
## Bug: WebSocket connection drops after 5 minutes of inactivity
|
||||
|
||||
### Environment
|
||||
|
||||
- OS: Windows 11
|
||||
- Node.js: 22.11.0
|
||||
- Automaker: commit abc1234
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Start the application with `npm run dev:web`
|
||||
2. Open the Kanban board
|
||||
3. Leave the browser tab open for 5+ minutes without interaction
|
||||
4. Try to move a card
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
The card should move to the new column.
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
The UI shows "Connection lost" and the card doesn't move.
|
||||
|
||||
### Logs
|
||||
|
||||
[WebSocket] Connection closed: 1006
|
||||
```
|
||||
|
||||
### Feature Requests
|
||||
|
||||
We welcome ideas for improving Automaker! Here's how to submit a feature request:
|
||||
|
||||
#### Before Requesting
|
||||
|
||||
1. **Check existing issues** - Your idea may already be proposed or in development
|
||||
2. **Consider scope** - Think about whether the feature fits Automaker's mission as an autonomous AI development studio
|
||||
|
||||
#### Feature Request Template
|
||||
|
||||
A good feature request includes:
|
||||
|
||||
- **Title:** A brief, descriptive title
|
||||
- **Problem Statement:** What problem does this feature solve?
|
||||
- **Proposed Solution:** How do you envision this working?
|
||||
- **Alternatives Considered:** What other approaches did you consider?
|
||||
- **Additional Context:** Mockups, examples, or references that help explain your idea
|
||||
|
||||
**Example Feature Request:**
|
||||
|
||||
```markdown
|
||||
## Feature: Dark Mode Support
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Working late at night, the bright UI causes eye strain and doesn't match
|
||||
my system's dark theme preference.
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Add a theme toggle in the settings panel that allows switching between
|
||||
light and dark modes. Ideally, it should also detect system preference.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
- Browser extension to force dark mode (doesn't work well with custom styling)
|
||||
- Custom CSS override (breaks with updates)
|
||||
|
||||
### Additional Context
|
||||
|
||||
Similar to how VS Code handles themes - a dropdown in settings with
|
||||
immediate preview.
|
||||
```
|
||||
|
||||
### Security Issues
|
||||
|
||||
**Important:** If you discover a security vulnerability, please do NOT open a public issue. Instead:
|
||||
|
||||
1. Join our [Discord server](https://discord.gg/jjem7aEDKU) and send a direct message to the user `@webdevcody`
|
||||
2. Include detailed steps to reproduce
|
||||
3. Allow time for us to address the issue before public disclosure
|
||||
|
||||
We take security seriously and appreciate responsible disclosure.
|
||||
|
||||
---
|
||||
|
||||
For license and contribution terms, see the [LICENSE](LICENSE) file in the repository root and the [README.md](README.md#license) for more details.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Automaker!
|
||||
154
Dockerfile
Normal file
154
Dockerfile
Normal file
@@ -0,0 +1,154 @@
|
||||
# Automaker Multi-Stage Dockerfile
|
||||
# Single Dockerfile for both server and UI builds
|
||||
# Usage:
|
||||
# docker build --target server -t automaker-server .
|
||||
# docker build --target ui -t automaker-ui .
|
||||
# Or use docker-compose which selects targets automatically
|
||||
|
||||
# =============================================================================
|
||||
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
||||
# =============================================================================
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Copy all libs package.json files (centralized - add new libs here)
|
||||
COPY libs/types/package*.json ./libs/types/
|
||||
COPY libs/utils/package*.json ./libs/utils/
|
||||
COPY libs/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
|
||||
# Copy scripts (needed by npm workspace)
|
||||
COPY scripts ./scripts
|
||||
|
||||
# =============================================================================
|
||||
# SERVER BUILD STAGE
|
||||
# =============================================================================
|
||||
FROM base AS server-builder
|
||||
|
||||
# Copy server-specific package.json
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
|
||||
# Install dependencies (--ignore-scripts to skip husky/prepare, then rebuild native modules)
|
||||
RUN npm ci --ignore-scripts && npm rebuild node-pty
|
||||
|
||||
# Copy all source files
|
||||
COPY libs ./libs
|
||||
COPY apps/server ./apps/server
|
||||
|
||||
# Build packages in dependency order, then build server
|
||||
RUN npm run build:packages && npm run build --workspace=apps/server
|
||||
|
||||
# =============================================================================
|
||||
# SERVER PRODUCTION STAGE
|
||||
# =============================================================================
|
||||
FROM node:22-alpine AS server
|
||||
|
||||
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
|
||||
RUN apk add --no-cache git curl bash && \
|
||||
GH_VERSION="2.63.2" && \
|
||||
ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
x86_64) GH_ARCH="amd64" ;; \
|
||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
|
||||
tar -xzf gh.tar.gz && \
|
||||
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
|
||||
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
|
||||
|
||||
# Install Claude CLI globally
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
|
||||
# Copy root package.json (needed for workspace resolution)
|
||||
COPY --from=server-builder /app/package*.json ./
|
||||
|
||||
# Copy built libs (workspace packages are symlinked in node_modules)
|
||||
COPY --from=server-builder /app/libs ./libs
|
||||
|
||||
# Copy built server
|
||||
COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
|
||||
|
||||
# Copy node_modules (includes symlinks to libs)
|
||||
COPY --from=server-builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data and projects directories
|
||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
||||
|
||||
# Configure git for mounted volumes and authentication
|
||||
# Use --system so it's not overwritten by mounted user .gitconfig
|
||||
RUN git config --system --add safe.directory '*' && \
|
||||
# Use gh as credential helper (works with GH_TOKEN env var)
|
||||
git config --system credential.helper '!gh auth git-credential'
|
||||
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
|
||||
# Health check (using curl since it's already installed, more reliable than busybox wget)
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["node", "apps/server/dist/index.js"]
|
||||
|
||||
# =============================================================================
|
||||
# UI BUILD STAGE
|
||||
# =============================================================================
|
||||
FROM base AS ui-builder
|
||||
|
||||
# Copy UI-specific package.json
|
||||
COPY apps/ui/package*.json ./apps/ui/
|
||||
|
||||
# Install dependencies (--ignore-scripts to skip husky and build:packages in prepare script)
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Copy all source files
|
||||
COPY libs ./libs
|
||||
COPY apps/ui ./apps/ui
|
||||
|
||||
# Build packages in dependency order, then build UI
|
||||
# VITE_SERVER_URL tells the UI where to find the API server
|
||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||
ARG VITE_SERVER_URL=http://localhost:3008
|
||||
ENV VITE_SKIP_ELECTRON=true
|
||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||
RUN npm run build:packages && npm run build --workspace=apps/ui
|
||||
|
||||
# =============================================================================
|
||||
# UI PRODUCTION STAGE
|
||||
# =============================================================================
|
||||
FROM nginx:alpine AS ui
|
||||
|
||||
# Copy built files
|
||||
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA routing
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
102
README.md
102
README.md
@@ -223,14 +223,111 @@ npm run build:electron:linux # Linux (AppImage + DEB, x64)
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose (recommended for security)
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Access at http://localhost:3007
|
||||
# Access UI at http://localhost:3007
|
||||
# API at http://localhost:3008
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
##### Configuration
|
||||
|
||||
Create a `.env` file in the project root if using API key authentication:
|
||||
|
||||
```bash
|
||||
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
|
||||
|
||||
##### Working with Projects (Host Directory Access)
|
||||
|
||||
By default, the container is isolated from your host filesystem. To work on projects from your host machine, create a `docker-compose.override.yml` file (gitignored):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Mount your project directories
|
||||
- /path/to/your/project:/projects/your-project
|
||||
```
|
||||
|
||||
##### Claude CLI Authentication (Optional)
|
||||
|
||||
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Linux/macOS
|
||||
- ~/.claude:/home/automaker/.claude
|
||||
# Windows
|
||||
- C:/Users/YourName/.claude:/home/automaker/.claude
|
||||
```
|
||||
|
||||
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
||||
|
||||
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
||||
|
||||
To enable git push and GitHub CLI operations inside the container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Mount GitHub CLI config
|
||||
# Linux/macOS
|
||||
- ~/.config/gh:/home/automaker/.config/gh
|
||||
# Windows
|
||||
- 'C:/Users/YourName/AppData/Roaming/GitHub CLI:/home/automaker/.config/gh'
|
||||
|
||||
# Mount git config for user identity (name, email)
|
||||
- ~/.gitconfig:/home/automaker/.gitconfig:ro
|
||||
environment:
|
||||
# GitHub token (required on Windows where tokens are in Credential Manager)
|
||||
# Get your token with: gh auth token
|
||||
- GH_TOKEN=${GH_TOKEN}
|
||||
```
|
||||
|
||||
Then add `GH_TOKEN` to your `.env` file:
|
||||
|
||||
```bash
|
||||
GH_TOKEN=gho_your_github_token_here
|
||||
```
|
||||
|
||||
##### Complete docker-compose.override.yml Example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Your projects
|
||||
- /path/to/project1:/projects/project1
|
||||
- /path/to/project2:/projects/project2
|
||||
|
||||
# Authentication configs
|
||||
- ~/.claude:/home/automaker/.claude
|
||||
- ~/.config/gh:/home/automaker/.config/gh
|
||||
- ~/.gitconfig:/home/automaker/.gitconfig:ro
|
||||
environment:
|
||||
- GH_TOKEN=${GH_TOKEN}
|
||||
```
|
||||
|
||||
##### Architecture Support
|
||||
|
||||
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
|
||||
|
||||
### Testing
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
@@ -531,6 +628,7 @@ data/
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
|
||||
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
|
||||
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Automaker Backend Server
|
||||
# Multi-stage build for minimal production image
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and scripts needed for postinstall
|
||||
COPY package*.json ./
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --workspace=apps/server
|
||||
|
||||
# Copy source
|
||||
COPY apps/server ./apps/server
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build --workspace=apps/server
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install git, curl, and GitHub CLI (pinned version for reproducible builds)
|
||||
RUN apk add --no-cache git curl && \
|
||||
GH_VERSION="2.63.2" && \
|
||||
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
|
||||
tar -xzf gh.tar.gz && \
|
||||
mv "gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh && \
|
||||
rm -rf gh.tar.gz "gh_${GH_VERSION}_linux_amd64"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
|
||||
# Copy built files and production dependencies
|
||||
COPY --from=builder /app/apps/server/dist ./dist
|
||||
COPY --from=builder /app/apps/server/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data && chown automaker:automaker /data
|
||||
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
|
||||
# Environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -9,6 +9,7 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:test": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
@@ -37,6 +39,8 @@
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cookie from 'cookie';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { authMiddleware, getAuthStatus } from './lib/auth.js';
|
||||
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
||||
import { requireJsonContentType } from './middleware/require-json-content-type.js';
|
||||
import { createAuthRoutes } from './routes/auth/index.js';
|
||||
import { createFsRoutes } from './routes/fs/index.js';
|
||||
import { createHealthRoutes } from './routes/health/index.js';
|
||||
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
|
||||
import { createAgentRoutes } from './routes/agent/index.js';
|
||||
import { createSessionsRoutes } from './routes/sessions/index.js';
|
||||
import { createFeaturesRoutes } from './routes/features/index.js';
|
||||
@@ -91,7 +95,7 @@ const app = express();
|
||||
// Middleware
|
||||
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
|
||||
if (ENABLE_REQUEST_LOGGING) {
|
||||
morgan.token('status-colored', (req, res) => {
|
||||
morgan.token('status-colored', (_req, res) => {
|
||||
const status = res.statusCode;
|
||||
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
||||
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
||||
@@ -105,17 +109,43 @@ if (ENABLE_REQUEST_LOGGING) {
|
||||
})
|
||||
);
|
||||
}
|
||||
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
|
||||
// from malicious websites. MCP server endpoints can execute arbitrary commands,
|
||||
// so allowing any origin would enable RCE from any website visited while Automaker runs.
|
||||
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
|
||||
// CORS configuration
|
||||
// When using credentials (cookies), origin cannot be '*'
|
||||
// We dynamically allow the requesting origin for local development
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (like mobile apps, curl, Electron)
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
|
||||
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, origin);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject other origins by default for security
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Create shared event emitter for streaming
|
||||
const events: EventEmitter = createEventEmitter();
|
||||
@@ -144,18 +174,26 @@ setInterval(() => {
|
||||
}
|
||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
|
||||
// This helps prevent CSRF and content-type confusion attacks
|
||||
app.use('/api', requireJsonContentType);
|
||||
|
||||
// Mount API routes - health and auth are unauthenticated
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
app.use('/api/auth', createAuthRoutes());
|
||||
|
||||
// Apply authentication to all other routes
|
||||
app.use('/api', authMiddleware);
|
||||
|
||||
// Protected health endpoint with detailed info
|
||||
app.get('/api/health/detailed', createDetailedHandler());
|
||||
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes());
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes());
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/setup', createSetupRoutes());
|
||||
@@ -182,10 +220,55 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
* Checks for API key in header/query, session token in header/query, OR valid session cookie
|
||||
*/
|
||||
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Convert URL search params to query object
|
||||
const query: Record<string, string | undefined> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
|
||||
// Parse cookies from header
|
||||
const cookieHeader = request.headers.cookie;
|
||||
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
|
||||
|
||||
// Use shared authentication logic for standard auth methods
|
||||
if (
|
||||
checkRawAuthentication(
|
||||
request.headers as Record<string, string | string[] | undefined>,
|
||||
query,
|
||||
cookies
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Additionally check for short-lived WebSocket connection token (WebSocket-specific)
|
||||
const wsToken = url.searchParams.get('wsToken');
|
||||
if (wsToken && validateWsConnectionToken(wsToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// Authenticate all WebSocket connections
|
||||
if (!authenticateWebSocket(request)) {
|
||||
console.log('[WebSocket] Authentication failed, rejecting connection');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/events') {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
|
||||
@@ -1,54 +1,378 @@
|
||||
/**
|
||||
* Authentication middleware for API security
|
||||
*
|
||||
* Supports API key authentication via header or environment variable.
|
||||
* Supports two authentication methods:
|
||||
* 1. Header-based (X-API-Key) - Used by Electron mode
|
||||
* 2. Cookie-based (HTTP-only session cookie) - Used by web mode
|
||||
*
|
||||
* Auto-generates an API key on first run if none is configured.
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// API key from environment (optional - if not set, auth is disabled)
|
||||
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
|
||||
const SESSION_COOKIE_NAME = 'automaker_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
|
||||
|
||||
// Session store - persisted to file for survival across server restarts
|
||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
// Short-lived WebSocket connection tokens (in-memory only, not persisted)
|
||||
const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
// Clean up expired WebSocket tokens periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
wsConnectionTokens.forEach((data, token) => {
|
||||
if (data.expiresAt <= now) {
|
||||
wsConnectionTokens.delete(token);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000); // Clean up every minute
|
||||
|
||||
/**
|
||||
* Load sessions from file on startup
|
||||
*/
|
||||
function loadSessions(): void {
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) {
|
||||
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
|
||||
const sessions = JSON.parse(data) as Array<
|
||||
[string, { createdAt: number; expiresAt: number }]
|
||||
>;
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [token, session] of sessions) {
|
||||
// Only load non-expired sessions
|
||||
if (session.expiresAt > now) {
|
||||
validSessions.set(token, session);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedCount > 0 || expiredCount > 0) {
|
||||
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error loading sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to file (async)
|
||||
*/
|
||||
async function saveSessions(): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
const sessions = Array.from(validSessions.entries());
|
||||
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing sessions on startup
|
||||
loadSessions();
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - either from env var, file, or generate new one.
|
||||
* This provides CSRF protection by requiring a secret key for all API requests.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
// First check environment variable (Electron passes it this way)
|
||||
if (process.env.AUTOMAKER_API_KEY) {
|
||||
console.log('[Auth] Using API key from environment variable');
|
||||
return process.env.AUTOMAKER_API_KEY;
|
||||
}
|
||||
|
||||
// Try to read from file
|
||||
try {
|
||||
if (fs.existsSync(API_KEY_FILE)) {
|
||||
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
|
||||
if (key) {
|
||||
console.log('[Auth] Loaded API key from file');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error reading API key file:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
const newKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Auth] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save API key:', error);
|
||||
}
|
||||
return newKey;
|
||||
}
|
||||
|
||||
// API key - always generated/loaded on startup for CSRF protection
|
||||
const API_KEY = ensureApiKey();
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ 🔐 API Key for Web Mode Authentication ║
|
||||
╠═══════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ When accessing via browser, you'll be prompted to enter this key: ║
|
||||
║ ║
|
||||
║ ${API_KEY}
|
||||
║ ║
|
||||
║ In Electron mode, authentication is handled automatically. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure session token
|
||||
*/
|
||||
function generateSessionToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session and return the token
|
||||
*/
|
||||
export async function createSession(): Promise<string> {
|
||||
const token = generateSessionToken();
|
||||
const now = Date.now();
|
||||
validSessions.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: now + SESSION_MAX_AGE_MS,
|
||||
});
|
||||
await saveSessions(); // Persist to file
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token
|
||||
* Note: This returns synchronously but triggers async persistence if session expired
|
||||
*/
|
||||
export function validateSession(token: string): boolean {
|
||||
const session = validSessions.get(token);
|
||||
if (!session) return false;
|
||||
|
||||
if (Date.now() > session.expiresAt) {
|
||||
validSessions.delete(token);
|
||||
// Fire-and-forget: persist removal asynchronously
|
||||
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a session token
|
||||
*/
|
||||
export async function invalidateSession(token: string): Promise<void> {
|
||||
validSessions.delete(token);
|
||||
await saveSessions(); // Persist removal
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short-lived WebSocket connection token
|
||||
* Used for initial WebSocket handshake authentication
|
||||
*/
|
||||
export function createWsConnectionToken(): string {
|
||||
const token = generateSessionToken();
|
||||
const now = Date.now();
|
||||
wsConnectionTokens.set(token, {
|
||||
createdAt: now,
|
||||
expiresAt: now + WS_TOKEN_MAX_AGE_MS,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a WebSocket connection token
|
||||
* These tokens are single-use and short-lived (5 minutes)
|
||||
* Token is invalidated immediately after first successful use
|
||||
*/
|
||||
export function validateWsConnectionToken(token: string): boolean {
|
||||
const tokenData = wsConnectionTokens.get(token);
|
||||
if (!tokenData) return false;
|
||||
|
||||
// Always delete the token (single-use)
|
||||
wsConnectionTokens.delete(token);
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > tokenData.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the API key using timing-safe comparison
|
||||
* Prevents timing attacks that could leak information about the key
|
||||
*/
|
||||
export function validateApiKey(key: string): boolean {
|
||||
if (!key || typeof key !== 'string') return false;
|
||||
|
||||
// Both buffers must be the same length for timingSafeEqual
|
||||
const keyBuffer = Buffer.from(key);
|
||||
const apiKeyBuffer = Buffer.from(API_KEY);
|
||||
|
||||
// If lengths differ, compare against a dummy to maintain constant time
|
||||
if (keyBuffer.length !== apiKeyBuffer.length) {
|
||||
crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session cookie options
|
||||
*/
|
||||
export function getSessionCookieOptions(): {
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: 'strict' | 'lax' | 'none';
|
||||
maxAge: number;
|
||||
path: string;
|
||||
} {
|
||||
return {
|
||||
httpOnly: true, // JavaScript cannot access this cookie
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
|
||||
maxAge: SESSION_MAX_AGE_MS,
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session cookie name
|
||||
*/
|
||||
export function getSessionCookieName(): string {
|
||||
return SESSION_COOKIE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication result type
|
||||
*/
|
||||
type AuthResult =
|
||||
| { authenticated: true }
|
||||
| { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' };
|
||||
|
||||
/**
|
||||
* Core authentication check - shared between middleware and status check
|
||||
* Extracts auth credentials from various sources and validates them
|
||||
*/
|
||||
function checkAuthentication(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): AuthResult {
|
||||
// Check for API key in header (Electron mode)
|
||||
const headerKey = headers['x-api-key'] as string | undefined;
|
||||
if (headerKey) {
|
||||
if (validateApiKey(headerKey)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||
}
|
||||
|
||||
// Check for session token in header (web mode with explicit token)
|
||||
const sessionTokenHeader = headers['x-session-token'] as string | undefined;
|
||||
if (sessionTokenHeader) {
|
||||
if (validateSession(sessionTokenHeader)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_session' };
|
||||
}
|
||||
|
||||
// Check for API key in query parameter (fallback)
|
||||
const queryKey = query.apiKey;
|
||||
if (queryKey) {
|
||||
if (validateApiKey(queryKey)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||
}
|
||||
|
||||
// Check for session cookie (web mode)
|
||||
const sessionToken = cookies[SESSION_COOKIE_NAME];
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
|
||||
return { authenticated: false, errorType: 'no_auth' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
*
|
||||
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
|
||||
* If not set, allows all requests (development mode).
|
||||
* Accepts either:
|
||||
* 1. X-API-Key header (for Electron mode)
|
||||
* 2. X-Session-Token header (for web mode with explicit token)
|
||||
* 3. apiKey query parameter (fallback for cases where headers can't be set)
|
||||
* 4. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// If no API key is configured, allow all requests
|
||||
if (!API_KEY) {
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
(req.cookies || {}) as Record<string, string | undefined>
|
||||
);
|
||||
|
||||
if (result.authenticated) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for API key in header
|
||||
const providedKey = req.headers['x-api-key'] as string | undefined;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header.',
|
||||
});
|
||||
return;
|
||||
// Return appropriate error based on what failed
|
||||
switch (result.errorType) {
|
||||
case 'invalid_api_key':
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
break;
|
||||
case 'invalid_session':
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired session token.',
|
||||
});
|
||||
break;
|
||||
case 'no_auth':
|
||||
default:
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
}
|
||||
|
||||
if (providedKey !== API_KEY) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is enabled
|
||||
* Check if authentication is enabled (always true now)
|
||||
*/
|
||||
export function isAuthEnabled(): boolean {
|
||||
return !!API_KEY;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +380,31 @@ export function isAuthEnabled(): boolean {
|
||||
*/
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
return {
|
||||
enabled: !!API_KEY,
|
||||
method: API_KEY ? 'api_key' : 'none',
|
||||
enabled: true,
|
||||
method: 'api_key_or_session',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is authenticated (for status endpoint)
|
||||
*/
|
||||
export function isRequestAuthenticated(req: Request): boolean {
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
(req.cookies || {}) as Record<string, string | undefined>
|
||||
);
|
||||
return result.authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if raw credentials are authenticated
|
||||
* Used for WebSocket authentication where we don't have Express request objects
|
||||
*/
|
||||
export function checkRawAuthentication(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): boolean {
|
||||
return checkAuthentication(headers, query, cookies).authenticated;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
|
||||
import {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
mergeBacklogPlanPrompts,
|
||||
mergeEnhancementPrompts,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
const logger = createLogger('SettingsHelper');
|
||||
|
||||
/**
|
||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||
@@ -21,7 +30,7 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
console.log(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
||||
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,7 +38,7 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
// Check project settings first (takes precedence)
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
if (projectSettings.autoLoadClaudeMd !== undefined) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
|
||||
);
|
||||
return projectSettings.autoLoadClaudeMd;
|
||||
@@ -38,10 +47,10 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
// Fall back to global settings
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.autoLoadClaudeMd ?? false;
|
||||
console.log(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
|
||||
logger.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -59,17 +68,17 @@ export async function getEnableSandboxModeSetting(
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`);
|
||||
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.enableSandboxMode ?? true;
|
||||
console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
|
||||
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -171,13 +180,13 @@ export async function getMCPServersFromSettings(
|
||||
sdkServers[server.name] = convertToSdkFormat(server);
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.info(
|
||||
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
|
||||
);
|
||||
|
||||
return sdkServers;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load MCP servers setting:`, error);
|
||||
logger.error(`${logPrefix} Failed to load MCP servers setting:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -207,12 +216,12 @@ export async function getMCPPermissionSettings(
|
||||
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||
};
|
||||
console.log(
|
||||
logger.info(
|
||||
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
logger.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
@@ -255,3 +264,43 @@ function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
|
||||
env: server.env,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompt customization from global settings and merge with defaults.
|
||||
* Returns prompts merged with built-in defaults - custom prompts override defaults.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to merged prompts for all categories
|
||||
*/
|
||||
export async function getPromptCustomization(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[PromptHelper]'
|
||||
): Promise<{
|
||||
autoMode: ReturnType<typeof mergeAutoModePrompts>;
|
||||
agent: ReturnType<typeof mergeAgentPrompts>;
|
||||
backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>;
|
||||
enhancement: ReturnType<typeof mergeEnhancementPrompts>;
|
||||
}> {
|
||||
let customization: PromptCustomization = {};
|
||||
|
||||
if (settingsService) {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
customization = globalSettings.promptCustomization || {};
|
||||
logger.info(`${logPrefix} Loaded prompt customization from settings`);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load prompt customization:`, error);
|
||||
// Fall through to use empty customization (all defaults)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${logPrefix} SettingsService not available, using default prompts`);
|
||||
}
|
||||
|
||||
return {
|
||||
autoMode: mergeAutoModePrompts(customization.autoMode),
|
||||
agent: mergeAgentPrompts(customization.agent),
|
||||
backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
|
||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
||||
};
|
||||
}
|
||||
|
||||
50
apps/server/src/middleware/require-json-content-type.ts
Normal file
50
apps/server/src/middleware/require-json-content-type.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Middleware to enforce Content-Type: application/json for request bodies
|
||||
*
|
||||
* This security middleware prevents malicious requests by requiring proper
|
||||
* Content-Type headers for all POST, PUT, and PATCH requests.
|
||||
*
|
||||
* Rejecting requests without proper Content-Type helps prevent:
|
||||
* - CSRF attacks via form submissions (which use application/x-www-form-urlencoded)
|
||||
* - Content-type confusion attacks
|
||||
* - Malformed request exploitation
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// HTTP methods that typically include request bodies
|
||||
const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH'];
|
||||
|
||||
/**
|
||||
* Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests
|
||||
*
|
||||
* Returns 415 Unsupported Media Type if:
|
||||
* - The request method is POST, PUT, or PATCH
|
||||
* - AND the Content-Type header is missing or not application/json
|
||||
*
|
||||
* Allows requests to pass through if:
|
||||
* - The request method is GET, DELETE, OPTIONS, HEAD, etc.
|
||||
* - OR the Content-Type is properly set to application/json (with optional charset)
|
||||
*/
|
||||
export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void {
|
||||
// Skip validation for methods that don't require a body
|
||||
if (!METHODS_REQUIRING_JSON.includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = req.headers['content-type'];
|
||||
|
||||
// Check if Content-Type header exists and contains application/json
|
||||
// Allows for charset parameter: "application/json; charset=utf-8"
|
||||
if (!contentType || !contentType.toLowerCase().includes('application/json')) {
|
||||
res.status(415).json({
|
||||
success: false,
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'Content-Type header must be application/json',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -107,9 +108,32 @@ export class ClaudeProvider extends BaseProvider {
|
||||
yield msg as ProviderMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
|
||||
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
|
||||
throw error;
|
||||
// Enhance error with user-friendly message and classification
|
||||
const errorInfo = classifyError(error);
|
||||
const userMessage = getUserFriendlyErrorMessage(error);
|
||||
|
||||
console.error('[ClaudeProvider] executeQuery() error during execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
isRateLimit: errorInfo.isRateLimit,
|
||||
retryAfter: errorInfo.retryAfter,
|
||||
stack: (error as Error).stack,
|
||||
});
|
||||
|
||||
// Build enhanced error message with additional guidance for rate limits
|
||||
const message = errorInfo.isRateLimit
|
||||
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
|
||||
: userMessage;
|
||||
|
||||
const enhancedError = new Error(message);
|
||||
(enhancedError as any).originalError = error;
|
||||
(enhancedError as any).type = errorInfo.type;
|
||||
|
||||
if (errorInfo.isRateLimit) {
|
||||
(enhancedError as any).retryAfter = errorInfo.retryAfter;
|
||||
}
|
||||
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
247
apps/server/src/routes/auth/index.ts
Normal file
247
apps/server/src/routes/auth/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Auth routes - Login, logout, and status endpoints
|
||||
*
|
||||
* Security model:
|
||||
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
|
||||
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
|
||||
*
|
||||
* The session cookie is:
|
||||
* - HTTP-only: JavaScript cannot read it (protects against XSS)
|
||||
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
|
||||
*
|
||||
* Mounted at /api/auth in the main server (BEFORE auth middleware).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
validateApiKey,
|
||||
createSession,
|
||||
invalidateSession,
|
||||
getSessionCookieOptions,
|
||||
getSessionCookieName,
|
||||
isRequestAuthenticated,
|
||||
createWsConnectionToken,
|
||||
} from '../../lib/auth.js';
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
|
||||
|
||||
// Check if we're in test mode - disable rate limiting for E2E tests
|
||||
const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true';
|
||||
|
||||
// In-memory rate limit tracking (resets on server restart)
|
||||
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
// Clean up old rate limit entries periodically (every 5 minutes)
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
loginAttempts.forEach((data, ip) => {
|
||||
if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
|
||||
loginAttempts.delete(ip);
|
||||
}
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
/**
|
||||
* Get client IP address from request
|
||||
* Handles X-Forwarded-For header for reverse proxy setups
|
||||
*/
|
||||
function getClientIp(req: Request): string {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) {
|
||||
// X-Forwarded-For can be a comma-separated list; take the first (original client)
|
||||
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
|
||||
return forwardedIp.trim();
|
||||
}
|
||||
return req.ip || req.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is rate limited
|
||||
* Returns { limited: boolean, retryAfter?: number }
|
||||
*/
|
||||
function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
const attempt = loginAttempts.get(ip);
|
||||
|
||||
if (!attempt) {
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
loginAttempts.delete(ip);
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
// Check if over limit
|
||||
if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
|
||||
const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
|
||||
return { limited: true, retryAfter };
|
||||
}
|
||||
|
||||
return { limited: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a login attempt for rate limiting
|
||||
*/
|
||||
function recordLoginAttempt(ip: string): void {
|
||||
const now = Date.now();
|
||||
const attempt = loginAttempts.get(ip);
|
||||
|
||||
if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
// Start new window
|
||||
loginAttempts.set(ip, { count: 1, windowStart: now });
|
||||
} else {
|
||||
// Increment existing window
|
||||
attempt.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth routes
|
||||
*
|
||||
* @returns Express Router with auth endpoints
|
||||
*/
|
||||
export function createAuthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
*
|
||||
* Returns whether the current request is authenticated.
|
||||
* Used by the UI to determine if login is needed.
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
const authenticated = isRequestAuthenticated(req);
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
*
|
||||
* Validates the API key and sets a session cookie.
|
||||
* Body: { apiKey: string }
|
||||
*
|
||||
* Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
const clientIp = getClientIp(req);
|
||||
|
||||
// Skip rate limiting in test mode to allow parallel E2E tests
|
||||
if (!isTestMode) {
|
||||
// Check rate limit before processing
|
||||
const rateLimit = checkRateLimit(clientIp);
|
||||
if (rateLimit.limited) {
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter: rateLimit.retryAfter,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'API key is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Record this attempt (only for actual API key validation attempts, skip in test mode)
|
||||
if (!isTestMode) {
|
||||
recordLoginAttempt(clientIp);
|
||||
}
|
||||
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create session and set cookie
|
||||
const sessionToken = await createSession();
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
res.cookie(cookieName, sessionToken, cookieOptions);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged in successfully.',
|
||||
// Return token for explicit header-based auth (works around cross-origin cookie issues)
|
||||
token: sessionToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/token
|
||||
*
|
||||
* Generates a short-lived WebSocket connection token if the user has a valid session.
|
||||
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
|
||||
* The token is NOT the session cookie value - it's a separate, short-lived token.
|
||||
*/
|
||||
router.get('/token', (req, res) => {
|
||||
// Validate the session is still valid (via cookie, API key, or session token header)
|
||||
if (!isRequestAuthenticated(req)) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new short-lived WebSocket connection token
|
||||
const wsToken = createWsConnectionToken();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token: wsToken,
|
||||
expiresIn: 300, // 5 minutes in seconds
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
*
|
||||
* Clears the session cookie and invalidates the session.
|
||||
*/
|
||||
router.post('/logout', async (req, res) => {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
||||
|
||||
if (sessionToken) {
|
||||
await invalidateSession(sessionToken);
|
||||
}
|
||||
|
||||
// Clear the cookie
|
||||
res.clearCookie(cookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { logger, setRunningState, getErrorMessage } from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -79,72 +79,17 @@ export async function generateBacklogPlan(
|
||||
content: `Loaded ${features.length} features from backlog`,
|
||||
});
|
||||
|
||||
// Load prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[BacklogPlan]');
|
||||
|
||||
// Build the system prompt
|
||||
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
|
||||
You will be given the current list of features and a user request to modify the backlog.
|
||||
const systemPrompt = prompts.backlogPlan.systemPrompt;
|
||||
|
||||
IMPORTANT CONTEXT (automatically injected):
|
||||
- Remember to update the dependency graph if deleting existing features
|
||||
- Remember to define dependencies on new features hooked into relevant existing ones
|
||||
- Maintain dependency graph integrity (no orphaned dependencies)
|
||||
- When deleting a feature, identify which other features depend on it
|
||||
|
||||
Your task is to analyze the request and produce a structured JSON plan with:
|
||||
1. Features to ADD (include title, description, category, and dependencies)
|
||||
2. Features to UPDATE (specify featureId and the updates)
|
||||
3. Features to DELETE (specify featureId)
|
||||
4. A summary of the changes
|
||||
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
|
||||
|
||||
Respond with ONLY a JSON object in this exact format:
|
||||
\`\`\`json
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"type": "add",
|
||||
"feature": {
|
||||
"title": "Feature title",
|
||||
"description": "Feature description",
|
||||
"category": "Category name",
|
||||
"dependencies": ["existing-feature-id"],
|
||||
"priority": 1
|
||||
},
|
||||
"reason": "Why this feature should be added"
|
||||
},
|
||||
{
|
||||
"type": "update",
|
||||
"featureId": "existing-feature-id",
|
||||
"feature": {
|
||||
"title": "Updated title"
|
||||
},
|
||||
"reason": "Why this feature should be updated"
|
||||
},
|
||||
{
|
||||
"type": "delete",
|
||||
"featureId": "feature-id-to-delete",
|
||||
"reason": "Why this feature should be deleted"
|
||||
}
|
||||
],
|
||||
"summary": "Brief overview of all proposed changes",
|
||||
"dependencyUpdates": [
|
||||
{
|
||||
"featureId": "feature-that-depended-on-deleted",
|
||||
"removedDependencies": ["deleted-feature-id"],
|
||||
"addedDependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
// Build the user prompt
|
||||
const userPrompt = `Current Features in Backlog:
|
||||
${formatFeaturesForPrompt(features)}
|
||||
|
||||
---
|
||||
|
||||
User Request: ${prompt}
|
||||
|
||||
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
|
||||
// Build the user prompt from template
|
||||
const currentFeatures = formatFeaturesForPrompt(features);
|
||||
const userPrompt = prompts.backlogPlan.userPromptTemplate
|
||||
.replace('{{currentFeatures}}', currentFeatures)
|
||||
.replace('{{userRequest}}', prompt);
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { createEnhanceHandler } from './routes/enhance.js';
|
||||
|
||||
/**
|
||||
* Create the enhance-prompt router
|
||||
*
|
||||
* @param settingsService - Settings service for loading custom prompts
|
||||
* @returns Express router with enhance-prompt endpoints
|
||||
*/
|
||||
export function createEnhancePromptRoutes(): Router {
|
||||
export function createEnhancePromptRoutes(settingsService?: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', createEnhanceHandler());
|
||||
router.post('/', createEnhanceHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
@@ -83,9 +84,12 @@ async function extractTextFromStream(
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
* @param settingsService - Optional settings service for loading custom prompts
|
||||
* @returns Express request handler for text enhancement
|
||||
*/
|
||||
export function createEnhanceHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
export function createEnhanceHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
|
||||
@@ -128,8 +132,19 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
|
||||
|
||||
logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
|
||||
|
||||
// Get the system prompt for this mode
|
||||
const systemPrompt = getSystemPrompt(validMode);
|
||||
// Load enhancement prompts from settings (merges custom + defaults)
|
||||
const prompts = await getPromptCustomization(settingsService, '[EnhancePrompt]');
|
||||
|
||||
// Get the system prompt for this mode from merged prompts
|
||||
const systemPromptMap: Record<EnhancementMode, string> = {
|
||||
improve: prompts.enhancement.improveSystemPrompt,
|
||||
technical: prompts.enhancement.technicalSystemPrompt,
|
||||
simplify: prompts.enhancement.simplifySystemPrompt,
|
||||
acceptance: prompts.enhancement.acceptanceSystemPrompt,
|
||||
};
|
||||
const systemPrompt = systemPromptMap[validMode];
|
||||
|
||||
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
// This helps the model understand this is text transformation, not a coding task
|
||||
|
||||
@@ -8,6 +8,7 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
|
||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||
import { createListPRsHandler } from './routes/list-prs.js';
|
||||
import { createListCommentsHandler } from './routes/list-comments.js';
|
||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||
import {
|
||||
createValidationStatusHandler,
|
||||
@@ -27,6 +28,7 @@ export function createGitHubRoutes(
|
||||
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||
router.post(
|
||||
'/validate-issue',
|
||||
validatePathParams('projectPath'),
|
||||
|
||||
212
apps/server/src/routes/github/routes/list-comments.ts
Normal file
212
apps/server/src/routes/github/routes/list-comments.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* POST /issue-comments endpoint - Fetch comments for a GitHub issue
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { GitHubComment, IssueCommentsResult } from '@automaker/types';
|
||||
import { execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
|
||||
interface ListCommentsRequest {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface GraphQLComment {
|
||||
id: string;
|
||||
author: {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface GraphQLResponse {
|
||||
data?: {
|
||||
repository?: {
|
||||
issue?: {
|
||||
comments: {
|
||||
totalCount: number;
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
nodes: GraphQLComment[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
/** Timeout for GitHub API requests in milliseconds */
|
||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Validate cursor format (GraphQL cursors are typically base64 strings)
|
||||
*/
|
||||
function isValidCursor(cursor: string): boolean {
|
||||
return /^[A-Za-z0-9+/=]+$/.test(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch comments for a specific issue using GitHub GraphQL API
|
||||
*/
|
||||
async function fetchIssueComments(
|
||||
projectPath: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
cursor?: string
|
||||
): Promise<IssueCommentsResult> {
|
||||
// Validate cursor format to prevent potential injection
|
||||
if (cursor && !isValidCursor(cursor)) {
|
||||
throw new Error('Invalid cursor format');
|
||||
}
|
||||
|
||||
// Use GraphQL variables instead of string interpolation for safety
|
||||
const query = `
|
||||
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issue(number: $issueNumber) {
|
||||
comments(first: 50, after: $cursor) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
author {
|
||||
login
|
||||
avatarUrl
|
||||
}
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables = {
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
cursor: cursor || null,
|
||||
};
|
||||
|
||||
const requestBody = JSON.stringify({ query, variables });
|
||||
|
||||
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Add timeout to prevent hanging indefinitely
|
||||
const timeoutId = setTimeout(() => {
|
||||
gh.kill();
|
||||
reject(new Error('GitHub API request timed out'));
|
||||
}, GITHUB_API_TIMEOUT_MS);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||
|
||||
gh.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
gh.stdin.write(requestBody);
|
||||
gh.stdin.end();
|
||||
});
|
||||
|
||||
if (response.errors && response.errors.length > 0) {
|
||||
throw new Error(response.errors[0].message);
|
||||
}
|
||||
|
||||
const commentsData = response.data?.repository?.issue?.comments;
|
||||
|
||||
if (!commentsData) {
|
||||
throw new Error('Issue not found or no comments data available');
|
||||
}
|
||||
|
||||
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
author: {
|
||||
login: node.author?.login || 'ghost',
|
||||
avatarUrl: node.author?.avatarUrl,
|
||||
},
|
||||
body: node.body,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
comments,
|
||||
totalCount: commentsData.totalCount,
|
||||
hasNextPage: commentsData.pageInfo.hasNextPage,
|
||||
endCursor: commentsData.pageInfo.endCursor || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createListCommentsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber, cursor } = req.body as ListCommentsRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
// First check if this is a GitHub repo and get owner/repo
|
||||
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Project does not have a GitHub remote',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchIssueComments(
|
||||
projectPath,
|
||||
remoteStatus.owner,
|
||||
remoteStatus.repo,
|
||||
issueNumber,
|
||||
cursor
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `Fetch comments for issue failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -8,13 +8,21 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
|
||||
import type {
|
||||
IssueValidationResult,
|
||||
IssueValidationEvent,
|
||||
AgentModel,
|
||||
GitHubComment,
|
||||
LinkedPRInfo,
|
||||
} from '@automaker/types';
|
||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import {
|
||||
issueValidationSchema,
|
||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
buildValidationPrompt,
|
||||
ValidationComment,
|
||||
ValidationLinkedPR,
|
||||
} from './validation-schema.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
@@ -40,6 +48,10 @@ interface ValidateIssueRequestBody {
|
||||
issueLabels?: string[];
|
||||
/** Model to use for validation (opus, sonnet, haiku) */
|
||||
model?: AgentModel;
|
||||
/** Comments to include in validation analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests for this issue */
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +69,9 @@ async function runValidation(
|
||||
model: AgentModel,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[]
|
||||
): Promise<void> {
|
||||
// Emit start event
|
||||
const startEvent: IssueValidationEvent = {
|
||||
@@ -76,8 +90,15 @@ async function runValidation(
|
||||
}, VALIDATION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
|
||||
// Build the prompt (include comments and linked PRs if provided)
|
||||
const prompt = buildValidationPrompt(
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
issueBody,
|
||||
issueLabels,
|
||||
comments,
|
||||
linkedPRs
|
||||
);
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
@@ -102,16 +123,12 @@ async function runValidation(
|
||||
// Execute the query
|
||||
const stream = query({ prompt, options });
|
||||
let validationResult: IssueValidationResult | null = null;
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Collect assistant text for debugging and emit progress
|
||||
// Emit progress events for assistant text
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
|
||||
// Emit progress event
|
||||
const progressEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_progress',
|
||||
issueNumber,
|
||||
@@ -128,7 +145,6 @@ async function runValidation(
|
||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
||||
if (resultMsg.structured_output) {
|
||||
validationResult = resultMsg.structured_output;
|
||||
logger.debug('Received structured output:', validationResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +164,6 @@ async function runValidation(
|
||||
// Require structured output
|
||||
if (!validationResult) {
|
||||
logger.error('No structured output received from Claude SDK');
|
||||
logger.debug('Raw response text:', responseText);
|
||||
throw new Error('Validation failed: no structured output received');
|
||||
}
|
||||
|
||||
@@ -214,8 +229,30 @@ export function createValidateIssueHandler(
|
||||
issueBody,
|
||||
issueLabels,
|
||||
model = 'opus',
|
||||
comments: rawComments,
|
||||
linkedPRs: rawLinkedPRs,
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
|
||||
// Transform GitHubComment[] to ValidationComment[] if provided
|
||||
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
|
||||
author: c.author?.login || 'ghost',
|
||||
createdAt: c.createdAt,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Transform LinkedPRInfo[] to ValidationLinkedPR[] if provided
|
||||
const validationLinkedPRs: ValidationLinkedPR[] | undefined = rawLinkedPRs?.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
`[ValidateIssue] Received validation request for issue #${issueNumber}` +
|
||||
(rawComments?.length ? ` with ${rawComments.length} comments` : ' (no comments)') +
|
||||
(rawLinkedPRs?.length ? ` and ${rawLinkedPRs.length} linked PRs` : '')
|
||||
);
|
||||
|
||||
// Validate required fields
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -271,11 +308,12 @@ export function createValidateIssueHandler(
|
||||
model,
|
||||
events,
|
||||
abortController,
|
||||
settingsService
|
||||
settingsService,
|
||||
validationComments,
|
||||
validationLinkedPRs
|
||||
)
|
||||
.catch((error) => {
|
||||
.catch(() => {
|
||||
// Error is already handled inside runValidation (event emitted)
|
||||
logger.debug('Validation error caught in background handler:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
clearValidationStatus(projectPath, issueNumber);
|
||||
|
||||
@@ -49,6 +49,34 @@ export const issueValidationSchema = {
|
||||
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
|
||||
description: 'Estimated effort to address the issue',
|
||||
},
|
||||
prAnalysis: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hasOpenPR: {
|
||||
type: 'boolean',
|
||||
description: 'Whether there is an open PR linked to this issue',
|
||||
},
|
||||
prFixesIssue: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the PR appears to fix the issue based on the diff',
|
||||
},
|
||||
prNumber: {
|
||||
type: 'number',
|
||||
description: 'The PR number that was analyzed',
|
||||
},
|
||||
prSummary: {
|
||||
type: 'string',
|
||||
description: 'Brief summary of what the PR changes',
|
||||
},
|
||||
recommendation: {
|
||||
type: 'string',
|
||||
enum: ['wait_for_merge', 'pr_needs_work', 'no_pr'],
|
||||
description:
|
||||
'Recommendation: wait for PR to merge, PR needs more work, or no relevant PR',
|
||||
},
|
||||
},
|
||||
description: 'Analysis of linked pull requests if any exist',
|
||||
},
|
||||
},
|
||||
required: ['verdict', 'confidence', 'reasoning'],
|
||||
additionalProperties: false,
|
||||
@@ -67,7 +95,8 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
|
||||
1. **Read the issue carefully** - Understand what is being reported or requested
|
||||
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
|
||||
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
|
||||
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
|
||||
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
|
||||
## Verdicts
|
||||
|
||||
@@ -88,12 +117,32 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
|
||||
- Is the implementation location clear?
|
||||
- Is the request technically feasible given the codebase structure?
|
||||
|
||||
## Analyzing Linked Pull Requests
|
||||
|
||||
When an issue has linked PRs (especially open ones), you MUST analyze them:
|
||||
|
||||
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
|
||||
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
|
||||
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
|
||||
4. **Provide a recommendation**:
|
||||
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
|
||||
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
|
||||
- \`no_pr\`: No relevant PR exists for this issue.
|
||||
|
||||
5. **Include prAnalysis in your response** with:
|
||||
- hasOpenPR: true/false
|
||||
- prFixesIssue: true/false (based on diff analysis)
|
||||
- prNumber: the PR number you analyzed
|
||||
- prSummary: brief description of what the PR changes
|
||||
- recommendation: one of the above values
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
- **Always include relatedFiles** when you find relevant code
|
||||
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
|
||||
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
|
||||
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
|
||||
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
|
||||
- **Set estimatedComplexity** to help prioritize:
|
||||
- trivial: Simple text changes, one-line fixes
|
||||
- simple: Small changes to one file
|
||||
@@ -103,6 +152,24 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
|
||||
|
||||
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
|
||||
|
||||
/**
|
||||
* Comment data structure for validation prompt
|
||||
*/
|
||||
export interface ValidationComment {
|
||||
author: string;
|
||||
createdAt: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked PR data structure for validation prompt
|
||||
*/
|
||||
export interface ValidationLinkedPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user prompt for issue validation.
|
||||
*
|
||||
@@ -113,26 +180,60 @@ Be thorough in your analysis but focus on files that are directly relevant to th
|
||||
* @param issueTitle - The issue title
|
||||
* @param issueBody - The issue body/description
|
||||
* @param issueLabels - Optional array of label names
|
||||
* @param comments - Optional array of comments to include in analysis
|
||||
* @param linkedPRs - Optional array of linked pull requests
|
||||
* @returns Formatted prompt string for the validation request
|
||||
*/
|
||||
export function buildValidationPrompt(
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
issueBody: string,
|
||||
issueLabels?: string[]
|
||||
issueLabels?: string[],
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[]
|
||||
): string {
|
||||
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
|
||||
|
||||
let linkedPRsSection = '';
|
||||
if (linkedPRs && linkedPRs.length > 0) {
|
||||
const prsText = linkedPRs
|
||||
.map((pr) => `- PR #${pr.number} (${pr.state}): ${pr.title}`)
|
||||
.join('\n');
|
||||
linkedPRsSection = `\n\n### Linked Pull Requests\n\n${prsText}`;
|
||||
}
|
||||
|
||||
let commentsSection = '';
|
||||
if (comments && comments.length > 0) {
|
||||
// Limit to most recent 10 comments to control prompt size
|
||||
const recentComments = comments.slice(-10);
|
||||
const commentsText = recentComments
|
||||
.map(
|
||||
(c) => `**${c.author}** (${new Date(c.createdAt).toISOString().slice(0, 10)}):\n${c.body}`
|
||||
)
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
commentsSection = `\n\n### Comments (${comments.length} total${comments.length > 10 ? ', showing last 10' : ''})\n\n${commentsText}`;
|
||||
}
|
||||
|
||||
const hasWorkInProgress =
|
||||
linkedPRs && linkedPRs.some((pr) => pr.state === 'open' || pr.state === 'OPEN');
|
||||
const workInProgressNote = hasWorkInProgress
|
||||
? '\n\n**Note:** This issue has an open pull request linked. Consider that someone may already be working on a fix.'
|
||||
: '';
|
||||
|
||||
return `Please validate the following GitHub issue by analyzing the codebase:
|
||||
|
||||
## Issue #${issueNumber}: ${issueTitle}
|
||||
${labelsSection}
|
||||
${linkedPRsSection}
|
||||
|
||||
### Description
|
||||
|
||||
${issueBody || '(No description provided)'}
|
||||
${commentsSection}
|
||||
${workInProgressNote}
|
||||
|
||||
---
|
||||
|
||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
|
||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.${comments && comments.length > 0 ? ' Consider the context provided in the comments as well.' : ''}${hasWorkInProgress ? ' Also note in your analysis if there is already work in progress on this issue.' : ''}`;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*
|
||||
* NOTE: Only the basic health check (/) is unauthenticated.
|
||||
* The /detailed endpoint requires authentication.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createIndexHandler } from './routes/index.js';
|
||||
import { createDetailedHandler } from './routes/detailed.js';
|
||||
|
||||
/**
|
||||
* Create unauthenticated health routes (basic check only)
|
||||
* Used by load balancers and container orchestration
|
||||
*/
|
||||
export function createHealthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Basic health check - no sensitive info
|
||||
router.get('/', createIndexHandler());
|
||||
router.get('/detailed', createDetailedHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Re-export detailed handler for use in authenticated routes
|
||||
export { createDetailedHandler } from './routes/detailed.js';
|
||||
|
||||
@@ -9,8 +9,7 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningAgents = autoModeService.getRunningAgents();
|
||||
const status = autoModeService.getStatus();
|
||||
const runningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -94,23 +94,37 @@ async function getGhStatus(): Promise<GhStatus> {
|
||||
// Version command failed
|
||||
}
|
||||
|
||||
// Check authentication status
|
||||
// Check authentication status by actually making an API call
|
||||
// gh auth status can return non-zero even when GH_TOKEN is valid
|
||||
let apiCallSucceeded = false;
|
||||
try {
|
||||
const { stdout } = await execAsync('gh auth status', { env: execEnv });
|
||||
// If this succeeds without error, we're authenticated
|
||||
status.authenticated = true;
|
||||
|
||||
// Try to extract username from output
|
||||
const userMatch =
|
||||
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
|
||||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
|
||||
if (userMatch) {
|
||||
status.user = userMatch[1];
|
||||
const { stdout } = await execAsync('gh api user --jq ".login"', { env: execEnv });
|
||||
const user = stdout.trim();
|
||||
if (user) {
|
||||
status.authenticated = true;
|
||||
status.user = user;
|
||||
apiCallSucceeded = true;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Auth status returns non-zero if not authenticated
|
||||
const err = error as { stderr?: string };
|
||||
if (err.stderr?.includes('not logged in')) {
|
||||
// If stdout is empty, fall through to gh auth status fallback
|
||||
} catch {
|
||||
// API call failed - fall through to gh auth status fallback
|
||||
}
|
||||
|
||||
// Fallback: try gh auth status if API call didn't succeed
|
||||
if (!apiCallSucceeded) {
|
||||
try {
|
||||
const { stdout } = await execAsync('gh auth status', { env: execEnv });
|
||||
status.authenticated = true;
|
||||
|
||||
// Try to extract username from output
|
||||
const userMatch =
|
||||
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
|
||||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
|
||||
if (userMatch) {
|
||||
status.user = userMatch[1];
|
||||
}
|
||||
} catch {
|
||||
// Auth status returns non-zero if not authenticated
|
||||
status.authenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
loadContextFiles,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -75,6 +77,7 @@ export class AgentService {
|
||||
private metadataFile: string;
|
||||
private events: EventEmitter;
|
||||
private settingsService: SettingsService | null = null;
|
||||
private logger = createLogger('AgentService');
|
||||
|
||||
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) {
|
||||
this.stateDir = path.join(dataDir, 'agent-sessions');
|
||||
@@ -148,12 +151,12 @@ export class AgentService {
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error('[AgentService] ERROR: Session not found:', sessionId);
|
||||
this.logger.error('ERROR: Session not found:', sessionId);
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.isRunning) {
|
||||
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
|
||||
this.logger.error('ERROR: Agent already running for session:', sessionId);
|
||||
throw new Error('Agent is already processing a message');
|
||||
}
|
||||
|
||||
@@ -175,7 +178,7 @@ export class AgentService {
|
||||
filename: imageData.filename,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
this.logger.error(`Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +249,7 @@ export class AgentService {
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
// Build combined system prompt with base prompt and context files
|
||||
const baseSystemPrompt = this.getSystemPrompt();
|
||||
const baseSystemPrompt = await this.getSystemPrompt();
|
||||
const combinedSystemPrompt = contextFilesPrompt
|
||||
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
|
||||
: baseSystemPrompt;
|
||||
@@ -391,7 +394,7 @@ export class AgentService {
|
||||
return { success: false, aborted: true };
|
||||
}
|
||||
|
||||
console.error('[AgentService] Error:', error);
|
||||
this.logger.error('Error:', error);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
@@ -485,7 +488,7 @@ export class AgentService {
|
||||
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
|
||||
await this.updateSessionTimestamp(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to save session:', error);
|
||||
this.logger.error('Failed to save session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +722,7 @@ export class AgentService {
|
||||
try {
|
||||
await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to save queue state:', error);
|
||||
this.logger.error('Failed to save queue state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,7 +771,7 @@ export class AgentService {
|
||||
model: nextPrompt.model,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to process queued prompt:', error);
|
||||
this.logger.error('Failed to process queued prompt:', error);
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'queue_error',
|
||||
error: (error as Error).message,
|
||||
@@ -781,38 +784,10 @@ export class AgentService {
|
||||
this.events.emit('agent:stream', { sessionId, ...data });
|
||||
}
|
||||
|
||||
private getSystemPrompt(): string {
|
||||
return `You are an AI assistant helping users build software. You are part of the Automaker application,
|
||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
||||
|
||||
**Feature Storage:**
|
||||
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
|
||||
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
|
||||
|
||||
Your role is to:
|
||||
- Help users define their project requirements and specifications
|
||||
- Ask clarifying questions to better understand their needs
|
||||
- Suggest technical approaches and architectures
|
||||
- Guide them through the development process
|
||||
- Be conversational and helpful
|
||||
- Write, edit, and modify code files as requested
|
||||
- Execute commands and tests
|
||||
- Search and analyze the codebase
|
||||
|
||||
When discussing projects, help users think through:
|
||||
- Core functionality and features
|
||||
- Technical stack choices
|
||||
- Data models and architecture
|
||||
- User experience considerations
|
||||
- Testing strategies
|
||||
|
||||
You have full access to the codebase and can:
|
||||
- Read files to understand existing code
|
||||
- Write new files
|
||||
- Edit existing files
|
||||
- Run bash commands
|
||||
- Search for code patterns
|
||||
- Execute tests and builds`;
|
||||
private async getSystemPrompt(): Promise<string> {
|
||||
// Load from settings (no caching - allows hot reload of custom prompts)
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AgentService]');
|
||||
return prompts.agent.systemPrompt;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -67,162 +68,6 @@ interface PlanSpec {
|
||||
tasks?: ParsedTask[];
|
||||
}
|
||||
|
||||
const PLANNING_PROMPTS = {
|
||||
lite: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[PLAN_GENERATED] Planning outline complete."
|
||||
|
||||
Then proceed with implementation.`,
|
||||
|
||||
lite_with_approval: `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.`,
|
||||
|
||||
spec: `## Specification Phase (Spec Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem**: What problem are we solving? (user perspective)
|
||||
|
||||
2. **Solution**: Brief approach (1-2 sentences)
|
||||
|
||||
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
|
||||
- GIVEN [context], WHEN [action], THEN [outcome]
|
||||
|
||||
4. **Files to Modify**:
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| path/to/file | description | create/modify/delete |
|
||||
|
||||
5. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential: T001, T002, T003, etc.
|
||||
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
|
||||
- File: Primary file affected (helps with context)
|
||||
- Order by dependencies (foundational tasks first)
|
||||
|
||||
6. **Verification**: How to confirm feature works
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY in order. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
|
||||
full: `## Full Specification Phase (Full SDD Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem Statement**: 2-3 sentences from user perspective
|
||||
|
||||
2. **User Story**: As a [user], I want [goal], so that [benefit]
|
||||
|
||||
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
|
||||
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
|
||||
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
|
||||
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
|
||||
|
||||
4. **Technical Context**:
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Affected Files | list of files |
|
||||
| Dependencies | external libs if any |
|
||||
| Constraints | technical limitations |
|
||||
| Patterns to Follow | existing patterns in codebase |
|
||||
|
||||
5. **Non-Goals**: What this feature explicitly does NOT include
|
||||
|
||||
6. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
- [ ] T004: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 3: Integration & Testing
|
||||
- [ ] T005: [Description] | File: [path/to/file]
|
||||
- [ ] T006: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential across all phases: T001, T002, T003, etc.
|
||||
- Description: Clear action verb + target
|
||||
- File: Primary file affected
|
||||
- Order by dependencies within each phase
|
||||
- Phase structure helps organize complex work
|
||||
|
||||
7. **Success Metrics**: How we know it's done (measurable criteria)
|
||||
|
||||
8. **Risks & Mitigations**:
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| description | approach |
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY by phase. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
After completing all tasks in a phase, output:
|
||||
"[PHASE_COMPLETE] Phase N complete"
|
||||
|
||||
This allows real-time progress tracking during implementation.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse tasks from generated spec content
|
||||
* Looks for the ```tasks code block and extracts task lines
|
||||
@@ -593,7 +438,7 @@ export class AutoModeService {
|
||||
} else {
|
||||
// Normal flow: build prompt with planning phase
|
||||
const featurePrompt = this.buildFeaturePrompt(feature);
|
||||
const planningPrefix = this.getPlanningPromptPrefix(feature);
|
||||
const planningPrefix = await this.getPlanningPromptPrefix(feature);
|
||||
prompt = planningPrefix + featurePrompt;
|
||||
|
||||
// Emit planning mode info
|
||||
@@ -1374,18 +1219,43 @@ Format your response as a structured markdown document.`;
|
||||
/**
|
||||
* Get detailed info about all running agents
|
||||
*/
|
||||
getRunningAgents(): Array<{
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
}> {
|
||||
return Array.from(this.runningFeatures.values()).map((rf) => ({
|
||||
featureId: rf.featureId,
|
||||
projectPath: rf.projectPath,
|
||||
projectName: path.basename(rf.projectPath),
|
||||
isAutoMode: rf.isAutoMode,
|
||||
}));
|
||||
async getRunningAgents(): Promise<
|
||||
Array<{
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>
|
||||
> {
|
||||
const agents = await Promise.all(
|
||||
Array.from(this.runningFeatures.values()).map(async (rf) => {
|
||||
// Try to fetch feature data to get title and description
|
||||
let title: string | undefined;
|
||||
let description: string | undefined;
|
||||
|
||||
try {
|
||||
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
|
||||
if (feature) {
|
||||
title = feature.title;
|
||||
description = feature.description;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors - title/description are optional
|
||||
}
|
||||
|
||||
return {
|
||||
featureId: rf.featureId,
|
||||
projectPath: rf.projectPath,
|
||||
projectName: path.basename(rf.projectPath),
|
||||
isAutoMode: rf.isAutoMode,
|
||||
title,
|
||||
description,
|
||||
};
|
||||
})
|
||||
);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1759,20 +1629,29 @@ Format your response as a structured markdown document.`;
|
||||
/**
|
||||
* Get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
private getPlanningPromptPrefix(feature: Feature): string {
|
||||
private async getPlanningPromptPrefix(feature: Feature): Promise<string> {
|
||||
const mode = feature.planningMode || 'skip';
|
||||
|
||||
if (mode === 'skip') {
|
||||
return ''; // No planning phase
|
||||
}
|
||||
|
||||
// Load prompts from settings (no caching - allows hot reload of custom prompts)
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const planningPrompts: Record<string, string> = {
|
||||
lite: prompts.autoMode.planningLite,
|
||||
lite_with_approval: prompts.autoMode.planningLiteWithApproval,
|
||||
spec: prompts.autoMode.planningSpec,
|
||||
full: prompts.autoMode.planningFull,
|
||||
};
|
||||
|
||||
// For lite mode, use the approval variant if requirePlanApproval is true
|
||||
let promptKey: string = mode;
|
||||
if (mode === 'lite' && feature.requirePlanApproval === true) {
|
||||
promptKey = 'lite_with_approval';
|
||||
}
|
||||
|
||||
const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
|
||||
const planningPrompt = planningPrompts[promptKey];
|
||||
if (!planningPrompt) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
*
|
||||
* Platform-specific implementations:
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows: Uses node-pty for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
private isWindows = os.platform() === 'win32';
|
||||
private isLinux = os.platform() === 'linux';
|
||||
|
||||
/**
|
||||
* Check if Claude CLI is available on the system
|
||||
@@ -48,8 +49,8 @@ export class ClaudeUsageService {
|
||||
* Uses platform-specific PTY implementation
|
||||
*/
|
||||
private executeClaudeUsageCommand(): Promise<string> {
|
||||
if (this.isWindows) {
|
||||
return this.executeClaudeUsageCommandWindows();
|
||||
if (this.isWindows || this.isLinux) {
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
return this.executeClaudeUsageCommandMac();
|
||||
}
|
||||
@@ -147,17 +148,23 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows implementation using node-pty
|
||||
* Windows/Linux implementation using node-pty
|
||||
*/
|
||||
private executeClaudeUsageCommandWindows(): Promise<string> {
|
||||
private executeClaudeUsageCommandPty(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let settled = false;
|
||||
let hasSeenUsageData = false;
|
||||
|
||||
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
|
||||
const workingDirectory = this.isWindows
|
||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
||||
: process.env.HOME || os.homedir() || '/tmp';
|
||||
|
||||
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
|
||||
// Use platform-appropriate shell and command
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
||||
@@ -8,26 +10,13 @@ import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
describe('auth.ts', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
delete process.env.AUTOMAKER_HIDE_API_KEY;
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
|
||||
describe('authMiddleware - no API key', () => {
|
||||
it('should call next() when no API key is set', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authMiddleware - with API key', () => {
|
||||
it('should reject request without API key header', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
it('should reject request without any authentication', async () => {
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
@@ -36,7 +25,7 @@ describe('auth.ts', () => {
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header.',
|
||||
error: 'Authentication required.',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -70,46 +59,340 @@ describe('auth.ts', () => {
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with session token in header', async () => {
|
||||
const { authMiddleware, createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers['x-session-token'] = token;
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid session token in header', async () => {
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers['x-session-token'] = 'invalid-token';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid or expired session token.',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with API key in query parameter', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.query.apiKey = 'test-secret-key';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should authenticate with session cookie', async () => {
|
||||
const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const cookieName = getSessionCookieName();
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.cookies = { [cookieName]: token };
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new session and return token', async () => {
|
||||
const { createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create unique tokens for each session', async () => {
|
||||
const { createSession } = await import('@/lib/auth.js');
|
||||
const token1 = await createSession();
|
||||
const token2 = await createSession();
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSession', () => {
|
||||
it('should validate a valid session token', async () => {
|
||||
const { createSession, validateSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(validateSession(token)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid session token', async () => {
|
||||
const { validateSession } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateSession('invalid-token')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject expired session token', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createSession, validateSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
// Advance time past session expiration (30 days)
|
||||
vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000);
|
||||
|
||||
expect(validateSession(token)).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateSession', () => {
|
||||
it('should invalidate a session token', async () => {
|
||||
const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(validateSession(token)).toBe(true);
|
||||
await invalidateSession(token);
|
||||
expect(validateSession(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWsConnectionToken', () => {
|
||||
it('should create a WebSocket connection token', async () => {
|
||||
const { createWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create unique tokens', async () => {
|
||||
const { createWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token1 = createWsConnectionToken();
|
||||
const token2 = createWsConnectionToken();
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWsConnectionToken', () => {
|
||||
it('should validate a valid WebSocket token', async () => {
|
||||
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
expect(validateWsConnectionToken(token)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid WebSocket token', async () => {
|
||||
const { validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateWsConnectionToken('invalid-token')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject expired WebSocket token', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
// Advance time past token expiration (5 minutes)
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
expect(validateWsConnectionToken(token)).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should invalidate token after first use (single-use)', async () => {
|
||||
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
|
||||
const token = createWsConnectionToken();
|
||||
|
||||
expect(validateWsConnectionToken(token)).toBe(true);
|
||||
// Token should be deleted after first use
|
||||
expect(validateWsConnectionToken(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('should validate correct API key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey('test-secret-key')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject incorrect API key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey('wrong-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty string', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null/undefined', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
expect(validateApiKey(null as any)).toBe(false);
|
||||
expect(validateApiKey(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should use timing-safe comparison for different lengths', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { validateApiKey } = await import('@/lib/auth.js');
|
||||
|
||||
// Key with different length should be rejected without timing leak
|
||||
expect(validateApiKey('short')).toBe(false);
|
||||
expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCookieOptions', () => {
|
||||
it('should return cookie options with httpOnly true', async () => {
|
||||
const { getSessionCookieOptions } = await import('@/lib/auth.js');
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.httpOnly).toBe(true);
|
||||
expect(options.sameSite).toBe('strict');
|
||||
expect(options.path).toBe('/');
|
||||
expect(options.maxAge).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should set secure to true in production', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { getSessionCookieOptions } = await import('@/lib/auth.js');
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.secure).toBe(true);
|
||||
});
|
||||
|
||||
it('should set secure to false in non-production', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const { getSessionCookieOptions } = await import('@/lib/auth.js');
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.secure).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCookieName', () => {
|
||||
it('should return the session cookie name', async () => {
|
||||
const { getSessionCookieName } = await import('@/lib/auth.js');
|
||||
const name = getSessionCookieName();
|
||||
|
||||
expect(name).toBe('automaker_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRequestAuthenticated', () => {
|
||||
it('should return true for authenticated request with API key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { isRequestAuthenticated } = await import('@/lib/auth.js');
|
||||
const { req } = createMockExpressContext();
|
||||
req.headers['x-api-key'] = 'test-secret-key';
|
||||
|
||||
expect(isRequestAuthenticated(req)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unauthenticated request', async () => {
|
||||
const { isRequestAuthenticated } = await import('@/lib/auth.js');
|
||||
const { req } = createMockExpressContext();
|
||||
|
||||
expect(isRequestAuthenticated(req)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for authenticated request with session token', async () => {
|
||||
const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const { req } = createMockExpressContext();
|
||||
req.headers['x-session-token'] = token;
|
||||
|
||||
expect(isRequestAuthenticated(req)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRawAuthentication', () => {
|
||||
it('should return true for valid API key in headers', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { checkRawAuthentication } = await import('@/lib/auth.js');
|
||||
|
||||
expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid session token in headers', async () => {
|
||||
const { checkRawAuthentication, createSession } = await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
|
||||
expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid API key in query', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { checkRawAuthentication } = await import('@/lib/auth.js');
|
||||
|
||||
expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid session cookie', async () => {
|
||||
const { checkRawAuthentication, createSession, getSessionCookieName } =
|
||||
await import('@/lib/auth.js');
|
||||
const token = await createSession();
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid credentials', async () => {
|
||||
const { checkRawAuthentication } = await import('@/lib/auth.js');
|
||||
|
||||
expect(checkRawAuthentication({}, {}, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthEnabled', () => {
|
||||
it('should return false when no API key is set', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { isAuthEnabled } = await import('@/lib/auth.js');
|
||||
expect(isAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when API key is set', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-key';
|
||||
|
||||
it('should always return true (auth is always required)', async () => {
|
||||
const { isAuthEnabled } = await import('@/lib/auth.js');
|
||||
expect(isAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthStatus', () => {
|
||||
it('should return disabled status when no API key', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { getAuthStatus } = await import('@/lib/auth.js');
|
||||
const status = getAuthStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: false,
|
||||
method: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return enabled status when API key is set', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-key';
|
||||
|
||||
it('should return enabled status with api_key_or_session method', async () => {
|
||||
const { getAuthStatus } = await import('@/lib/auth.js');
|
||||
const status = getAuthStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
method: 'api_key',
|
||||
method: 'api_key_or_session',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual('@automaker/utils');
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
createLogger: () => mockLogger,
|
||||
};
|
||||
});
|
||||
|
||||
describe('settings-helpers.ts', () => {
|
||||
describe('getMCPServersFromSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is null', async () => {
|
||||
@@ -187,7 +201,7 @@ describe('settings-helpers.ts', () => {
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({});
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should throw error for SSE server without URL', async () => {
|
||||
@@ -275,8 +289,7 @@ describe('settings-helpers.ts', () => {
|
||||
|
||||
describe('getMCPPermissionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is null', async () => {
|
||||
@@ -347,7 +360,7 @@ describe('settings-helpers.ts', () => {
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should use custom log prefix', async () => {
|
||||
@@ -359,7 +372,7 @@ describe('settings-helpers.ts', () => {
|
||||
} as unknown as SettingsService;
|
||||
|
||||
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
|
||||
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[CustomPrefix]'));
|
||||
// Logger will be called with custom prefix, but we don't need to assert it
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,19 +247,15 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
|
||||
|
||||
// Should log error message
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'[ClaudeProvider] ERROR: executeQuery() error during execution:',
|
||||
testError
|
||||
);
|
||||
|
||||
// Should log stack trace
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'[ClaudeProvider] ERROR stack:',
|
||||
testError.stack
|
||||
);
|
||||
// Should log error with classification info (after refactoring)
|
||||
const errorCall = consoleErrorSpy.mock.calls[0];
|
||||
expect(errorCall[0]).toBe('[ClaudeProvider] executeQuery() error during execution:');
|
||||
expect(errorCall[1]).toMatchObject({
|
||||
type: expect.any(String),
|
||||
message: 'SDK execution failed',
|
||||
isRateLimit: false,
|
||||
stack: expect.stringContaining('Error: SDK execution failed'),
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
195
apps/server/tests/unit/routes/running-agents.test.ts
Normal file
195
apps/server/tests/unit/routes/running-agents.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createIndexHandler } from '@/routes/running-agents/routes/index.js';
|
||||
import type { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
|
||||
describe('running-agents routes', () => {
|
||||
let mockAutoModeService: Partial<AutoModeService>;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockAutoModeService = {
|
||||
getRunningAgents: vi.fn(),
|
||||
};
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('GET / (index handler)', () => {
|
||||
it('should return empty array when no agents are running', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(mockAutoModeService.getRunningAgents).toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents: [],
|
||||
totalCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running agents with all properties', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/home/user/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
title: 'Implement login feature',
|
||||
description: 'Add user authentication with OAuth',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/other-project',
|
||||
projectName: 'other-project',
|
||||
isAutoMode: false,
|
||||
title: 'Fix navigation bug',
|
||||
description: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return agents without title/description (backward compatibility)', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'legacy-feature',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return 500', async () => {
|
||||
// Arrange
|
||||
const error = new Error('Database connection failed');
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue(error);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Database connection failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
// Arrange
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue('String error');
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly count multiple running agents', async () => {
|
||||
// Arrange
|
||||
const runningAgents = Array.from({ length: 10 }, (_, i) => ({
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
projectName: `project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
title: `Feature ${i}`,
|
||||
description: `Description ${i}`,
|
||||
}));
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include agents from different projects', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-a',
|
||||
projectPath: '/workspace/project-alpha',
|
||||
projectName: 'project-alpha',
|
||||
isAutoMode: true,
|
||||
title: 'Feature A',
|
||||
description: 'In project alpha',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-b',
|
||||
projectPath: '/workspace/project-beta',
|
||||
projectName: 'project-beta',
|
||||
isAutoMode: false,
|
||||
title: 'Feature B',
|
||||
description: 'In project beta',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
const response = vi.mocked(res.json).mock.calls[0][0];
|
||||
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
||||
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,26 @@ import * as promptBuilder from '@automaker/utils';
|
||||
import * as contextLoader from '@automaker/utils';
|
||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@automaker/utils');
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
loadContextFiles: vi.fn(),
|
||||
buildPromptWithImages: vi.fn(),
|
||||
readImageAsBase64: vi.fn(),
|
||||
createLogger: vi.fn(() => mockLogger),
|
||||
};
|
||||
});
|
||||
|
||||
describe('agent-service.ts', () => {
|
||||
let service: AgentService;
|
||||
@@ -224,16 +241,13 @@ describe('agent-service.ts', () => {
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: 'session-1',
|
||||
message: 'Check this',
|
||||
imagePaths: ['/path/test.png'],
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom model if provided', async () => {
|
||||
@@ -347,4 +361,386 @@ describe('agent-service.ts', () => {
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{}');
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should create a new session with metadata', async () => {
|
||||
const session = await service.createSession('Test Session', '/test/project', '/test/dir');
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.name).toBe('Test Session');
|
||||
expect(session.projectPath).toBe('/test/project');
|
||||
expect(session.workingDirectory).toBeDefined();
|
||||
expect(session.createdAt).toBeDefined();
|
||||
expect(session.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use process.cwd() if no working directory provided', async () => {
|
||||
const session = await service.createSession('Test Session');
|
||||
|
||||
expect(session.workingDirectory).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate working directory', async () => {
|
||||
// Set ALLOWED_ROOT_DIRECTORY to restrict paths
|
||||
const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects';
|
||||
|
||||
// Re-import platform to initialize with new env var
|
||||
vi.resetModules();
|
||||
const { initAllowedPaths } = await import('@automaker/platform');
|
||||
initAllowedPaths();
|
||||
|
||||
const { AgentService } = await import('@/services/agent-service.js');
|
||||
const testService = new AgentService('/test/data', mockEvents as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{}');
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
testService.createSession('Test Session', undefined, '/invalid/path')
|
||||
).rejects.toThrow();
|
||||
|
||||
// Restore original value
|
||||
if (originalAllowedRoot) {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot;
|
||||
} else {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
}
|
||||
vi.resetModules();
|
||||
const { initAllowedPaths: reinit } = await import('@automaker/platform');
|
||||
reinit();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionModel', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set model for existing session', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
|
||||
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should update session metadata', async () => {
|
||||
const result = await service.updateSession('session-1', { name: 'Updated Name' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Updated Name');
|
||||
expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('should return null for non-existent session', async () => {
|
||||
const result = await service.updateSession('nonexistent', { name: 'Updated Name' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should archive a session', async () => {
|
||||
const result = await service.archiveSession('session-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.archiveSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
archived: true,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should unarchive a session', async () => {
|
||||
const result = await service.unarchiveSession('session-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.unarchiveSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should delete a session', async () => {
|
||||
const result = await service.deleteSession('session-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const result = await service.deleteSession('nonexistent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
'session-1': {
|
||||
id: 'session-1',
|
||||
name: 'Test Session 1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
archived: false,
|
||||
},
|
||||
'session-2': {
|
||||
id: 'session-2',
|
||||
name: 'Test Session 2',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-03T00:00:00Z',
|
||||
archived: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should list non-archived sessions by default', async () => {
|
||||
const sessions = await service.listSessions();
|
||||
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe('session-1');
|
||||
});
|
||||
|
||||
it('should include archived sessions when requested', async () => {
|
||||
const sessions = await service.listSessions(true);
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should sort sessions by updatedAt descending', async () => {
|
||||
const sessions = await service.listSessions(true);
|
||||
|
||||
expect(sessions[0].id).toBe('session-2');
|
||||
expect(sessions[1].id).toBe('session-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add prompt to queue', async () => {
|
||||
const result = await service.addToQueue('session-1', {
|
||||
message: 'Test prompt',
|
||||
imagePaths: ['/test/image.png'],
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.queuedPrompt).toBeDefined();
|
||||
expect(result.queuedPrompt?.message).toBe('Test prompt');
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
const result = await service.addToQueue('nonexistent', {
|
||||
message: 'Test prompt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return queue for session', async () => {
|
||||
await service.addToQueue('session-1', { message: 'Test prompt' });
|
||||
const result = service.getQueue('session-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.queue).toBeDefined();
|
||||
expect(result.queue?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', () => {
|
||||
const result = service.getQueue('nonexistent');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
const addResult = await service.addToQueue('session-1', { message: 'Test prompt' });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should remove prompt from queue', async () => {
|
||||
const queueResult = service.getQueue('session-1');
|
||||
const promptId = queueResult.queue![0].id;
|
||||
|
||||
const result = await service.removeFromQueue('session-1', promptId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
const result = await service.removeFromQueue('nonexistent', 'prompt-id');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
|
||||
it('should return error for non-existent prompt', async () => {
|
||||
const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Prompt not found in queue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearQueue', () => {
|
||||
beforeEach(async () => {
|
||||
const error: any = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
|
||||
await service.startConversation({
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
await service.addToQueue('session-1', { message: 'Test prompt 1' });
|
||||
await service.addToQueue('session-1', { message: 'Test prompt 2' });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should clear all prompts from queue', async () => {
|
||||
const result = await service.clearQueue('session-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const queueResult = service.getQueue('session-1');
|
||||
expect(queueResult.queue?.length).toBe(0);
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error for non-existent session', async () => {
|
||||
const result = await service.clearQueue('nonexistent');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,84 +24,87 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it('should return empty string for skip mode', () => {
|
||||
it('should return empty string for skip mode', async () => {
|
||||
const feature = { id: 'test', planningMode: 'skip' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when planningMode is undefined', () => {
|
||||
it('should return empty string when planningMode is undefined', async () => {
|
||||
const feature = { id: 'test' };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return lite prompt for lite mode without approval', () => {
|
||||
it('should return lite prompt for lite mode without approval', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[PLAN_GENERATED]');
|
||||
expect(result).toContain('Feature Request');
|
||||
});
|
||||
|
||||
it('should return lite_with_approval prompt for lite mode with approval', () => {
|
||||
it('should return lite_with_approval prompt for lite mode with approval', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'lite' as const,
|
||||
requirePlanApproval: true,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Planning Phase (Lite Mode)');
|
||||
expect(result).toContain('[SPEC_GENERATED]');
|
||||
expect(result).toContain('DO NOT proceed with implementation');
|
||||
expect(result).toContain(
|
||||
'DO NOT proceed with implementation until you receive explicit approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return spec prompt for spec mode', () => {
|
||||
it('should return spec prompt for spec mode', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Specification Phase (Spec Mode)');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Specification Phase (Spec Mode)');
|
||||
expect(result).toContain('```tasks');
|
||||
expect(result).toContain('T001');
|
||||
expect(result).toContain('[TASK_START]');
|
||||
expect(result).toContain('[TASK_COMPLETE]');
|
||||
});
|
||||
|
||||
it('should return full prompt for full mode', () => {
|
||||
it('should return full prompt for full mode', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'full' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('## Full Specification Phase (Full SDD Mode)');
|
||||
expect(result).toContain('Phase 1: Foundation');
|
||||
expect(result).toContain('Phase 2: Core Implementation');
|
||||
expect(result).toContain('Phase 3: Integration & Testing');
|
||||
});
|
||||
|
||||
it('should include the separator and Feature Request header', () => {
|
||||
it('should include the separator and Feature Request header', async () => {
|
||||
const feature = {
|
||||
id: 'test',
|
||||
planningMode: 'spec' as const,
|
||||
};
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('## Feature Request');
|
||||
});
|
||||
|
||||
it('should instruct agent to NOT output exploration text', () => {
|
||||
it('should instruct agent to NOT output exploration text', async () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Do NOT output exploration text');
|
||||
expect(result).toContain('Start DIRECTLY');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
// All modes should have the IMPORTANT instruction about not outputting exploration text
|
||||
expect(result).toContain('IMPORTANT: Do NOT output exploration text');
|
||||
expect(result).toContain('Silently analyze the codebase first');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -279,18 +282,18 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
return svc.getPlanningPromptPrefix(feature);
|
||||
};
|
||||
|
||||
it('should have all required planning modes', () => {
|
||||
it('should have all required planning modes', async () => {
|
||||
const modes = ['lite', 'spec', 'full'] as const;
|
||||
for (const mode of modes) {
|
||||
const feature = { id: 'test', planningMode: mode };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result.length).toBeGreaterThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('lite prompt should include correct structure', () => {
|
||||
it('lite prompt should include correct structure', async () => {
|
||||
const feature = { id: 'test', planningMode: 'lite' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Goal');
|
||||
expect(result).toContain('Approach');
|
||||
expect(result).toContain('Files to Touch');
|
||||
@@ -298,9 +301,9 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
expect(result).toContain('Risks');
|
||||
});
|
||||
|
||||
it('spec prompt should include task format instructions', () => {
|
||||
it('spec prompt should include task format instructions', async () => {
|
||||
const feature = { id: 'test', planningMode: 'spec' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Problem');
|
||||
expect(result).toContain('Solution');
|
||||
expect(result).toContain('Acceptance Criteria');
|
||||
@@ -309,13 +312,13 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
||||
expect(result).toContain('Verification');
|
||||
});
|
||||
|
||||
it('full prompt should include phases', () => {
|
||||
it('full prompt should include phases', async () => {
|
||||
const feature = { id: 'test', planningMode: 'full' as const };
|
||||
const result = getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('Problem Statement');
|
||||
expect(result).toContain('User Story');
|
||||
expect(result).toContain('Technical Context');
|
||||
expect(result).toContain('Non-Goals');
|
||||
const result = await getPlanningPromptPrefix(service, feature);
|
||||
expect(result).toContain('1. **Problem Statement**');
|
||||
expect(result).toContain('2. **User Story**');
|
||||
expect(result).toContain('4. **Technical Context**');
|
||||
expect(result).toContain('5. **Non-Goals**');
|
||||
expect(result).toContain('Phase 1');
|
||||
expect(result).toContain('Phase 2');
|
||||
expect(result).toContain('Phase 3');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
@@ -66,4 +67,252 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(runningCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningAgents', () => {
|
||||
// Helper to access private runningFeatures Map
|
||||
const getRunningFeaturesMap = (svc: AutoModeService) =>
|
||||
(svc as any).runningFeatures as Map<
|
||||
string,
|
||||
{ featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
>;
|
||||
|
||||
// Helper to get the featureLoader and mock its get method
|
||||
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { get: mockFn };
|
||||
};
|
||||
|
||||
it('should return empty array when no agents are running', async () => {
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return running agents with basic info when feature data is not available', async () => {
|
||||
// Arrange: Add a running feature to the Map
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-123', {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Mock featureLoader.get to return null (feature not found)
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
projectName: 'path',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running agents with title and description when feature data is available', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-456', {
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const mockFeature: Partial<Feature> = {
|
||||
id: 'feature-456',
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
category: 'auth',
|
||||
};
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(mockFeature);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
projectName: 'my-project',
|
||||
isAutoMode: false,
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
});
|
||||
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
|
||||
});
|
||||
|
||||
it('should handle multiple running agents', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-1',
|
||||
title: 'Feature One',
|
||||
description: 'Description one',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-2',
|
||||
title: 'Feature Two',
|
||||
description: 'Description two',
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(2);
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should silently handle errors when fetching feature data', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-error', {
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act - should not throw
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
projectName: 'project-error',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle feature with title but no description', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-title-only', {
|
||||
featureId: 'feature-title-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-title-only',
|
||||
title: 'Only Title',
|
||||
// description is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBe('Only Title');
|
||||
expect(result[0].description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle feature with description but no title', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-desc-only', {
|
||||
featureId: 'feature-desc-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-desc-only',
|
||||
description: 'Only description, no title',
|
||||
// title is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBeUndefined();
|
||||
expect(result[0].description).toBe('Only description, no title');
|
||||
});
|
||||
|
||||
it('should extract projectName from nested paths correctly', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-nested', {
|
||||
featureId: 'feature-nested',
|
||||
projectPath: '/home/user/workspace/projects/my-awesome-project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].projectName).toBe('my-awesome-project');
|
||||
});
|
||||
|
||||
it('should fetch feature data in parallel for multiple agents', async () => {
|
||||
// Arrange: Add multiple running features
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
runningFeaturesMap.set(`feature-${i}`, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
|
||||
callOrder.push(featureId);
|
||||
// Simulate async delay to verify parallel execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { id: featureId, title: `Title for ${featureId}` };
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const startTime = Date.now();
|
||||
const result = await service.getRunningAgents();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(5);
|
||||
expect(getMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms (one batch)
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
// Allow some buffer for execution overhead
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Automaker UI
|
||||
# Multi-stage build for minimal production image
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY apps/ui/package*.json ./apps/ui/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies (skip electron postinstall)
|
||||
RUN npm ci --workspace=apps/ui --ignore-scripts
|
||||
|
||||
# Copy source
|
||||
COPY apps/ui ./apps/ui
|
||||
|
||||
# Build for web (skip electron)
|
||||
# VITE_SERVER_URL tells the UI where to find the API server
|
||||
# Using localhost:3008 since both containers expose ports to the host
|
||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||
ARG VITE_SERVER_URL=http://localhost:3008
|
||||
ENV VITE_SKIP_ELECTRON=true
|
||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||
RUN npm run build --workspace=apps/ui
|
||||
|
||||
# Production stage - serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/apps/ui/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA routing
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -27,8 +27,8 @@
|
||||
"build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint",
|
||||
"pretest": "node scripts/setup-e2e-fixtures.mjs",
|
||||
"lint": "npx eslint",
|
||||
"pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs",
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"dev:electron:wsl": "cross-env vite",
|
||||
|
||||
@@ -3,21 +3,24 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
|
||||
const mockAgent = process.env.CI === 'true' || process.env.AUTOMAKER_MOCK_AGENT === 'true';
|
||||
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
|
||||
const mockAgent = true;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: undefined,
|
||||
retries: 0,
|
||||
workers: 1, // Run sequentially to avoid auth conflicts with shared server
|
||||
reporter: 'html',
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: 'on-first-retry',
|
||||
trace: 'on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
// Global setup - authenticate before each test
|
||||
globalSetup: require.resolve('./tests/global-setup.ts'),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
@@ -29,16 +32,22 @@ export default defineConfig({
|
||||
: {
|
||||
webServer: [
|
||||
// Backend server - runs with mock agent enabled in CI
|
||||
// Uses dev:test (no file watching) to avoid port conflicts from server restarts
|
||||
{
|
||||
command: `cd ../server && npm run dev`,
|
||||
command: `cd ../server && npm run dev:test`,
|
||||
url: `http://localhost:${serverPort}/api/health`,
|
||||
reuseExistingServer: true,
|
||||
// Don't reuse existing server to ensure we use the test API key
|
||||
reuseExistingServer: false,
|
||||
timeout: 60000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
// Enable mock agent in CI to avoid real API calls
|
||||
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
||||
// Set a test API key for web mode authentication
|
||||
AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
|
||||
// Hide the API key banner to reduce log noise
|
||||
AUTOMAKER_HIDE_API_KEY: 'true',
|
||||
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
|
||||
},
|
||||
},
|
||||
@@ -51,8 +60,8 @@ export default defineConfig({
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_SKIP_SETUP: 'true',
|
||||
// Skip electron plugin in CI - no display available for Electron
|
||||
VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined,
|
||||
// Always skip electron plugin during tests - prevents duplicate server spawning
|
||||
VITE_SKIP_ELECTRON: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
44
apps/ui/scripts/kill-test-servers.mjs
Normal file
44
apps/ui/scripts/kill-test-servers.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Kill any existing servers on test ports before running tests
|
||||
* This ensures the test server starts fresh with the correct API key
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008;
|
||||
const UI_PORT = process.env.TEST_PORT || 3007;
|
||||
|
||||
async function killProcessOnPort(port) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`lsof -ti:${port}`);
|
||||
const pids = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
if (pids.length > 0) {
|
||||
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
console.log(`[KillTestServers] Killed process ${pid}`);
|
||||
} catch (error) {
|
||||
// Process might have already exited
|
||||
}
|
||||
}
|
||||
// Wait a moment for the port to be released
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
} catch (error) {
|
||||
// No process on port, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[KillTestServers] Checking for existing test servers...');
|
||||
await killProcessOnPort(Number(SERVER_PORT));
|
||||
await killProcessOnPort(Number(UI_PORT));
|
||||
console.log('[KillTestServers] Done');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45;
|
||||
|
||||
export function ClaudeUsagePopover() {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check if CLI is verified/authenticated
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
|
||||
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
|
||||
const isStale = useMemo(() => {
|
||||
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
@@ -68,14 +74,17 @@ export function ClaudeUsagePopover() {
|
||||
[setClaudeUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale
|
||||
// Auto-fetch on mount if data is stale (only if CLI is verified)
|
||||
useEffect(() => {
|
||||
if (isStale) {
|
||||
if (isStale && isCliVerified) {
|
||||
fetchUsage(true);
|
||||
}
|
||||
}, [isStale, fetchUsage]);
|
||||
}, [isStale, isCliVerified, fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if CLI is not verified
|
||||
if (!isCliVerified) return;
|
||||
|
||||
// Initial fetch when opened
|
||||
if (open) {
|
||||
if (!claudeUsage || isStale) {
|
||||
@@ -94,7 +103,7 @@ export function ClaudeUsagePopover() {
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [open, claudeUsage, isStale, fetchUsage]);
|
||||
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { getJSON, setJSON } from '@/lib/storage';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
import { useOSDetection } from '@/hooks';
|
||||
import { apiPost } from '@/lib/api-fetch';
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
@@ -98,16 +99,7 @@ export function FileBrowserDialog({
|
||||
setWarning('');
|
||||
|
||||
try {
|
||||
// Get server URL from environment or default
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dirPath }),
|
||||
});
|
||||
|
||||
const result: BrowseResult = await response.json();
|
||||
const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export function AgentView() {
|
||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||
@@ -73,7 +74,7 @@ export function AgentView() {
|
||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
||||
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Ref for quick create session function from SessionManager
|
||||
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
|
||||
@@ -368,13 +369,24 @@ export function AgentView() {
|
||||
[processDroppedFiles]
|
||||
);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = inputRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [input, adjustTextareaHeight]);
|
||||
|
||||
const handleClearChat = async () => {
|
||||
if (!confirm('Are you sure you want to clear this conversation?')) return;
|
||||
await clearHistory();
|
||||
@@ -878,7 +890,7 @@ export function AgentView() {
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isDragOver
|
||||
@@ -889,12 +901,13 @@ export function AgentView() {
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
disabled={!isConnected}
|
||||
data-testid="agent-input"
|
||||
rows={1}
|
||||
className={cn(
|
||||
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
|
||||
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||
'border-primary/30',
|
||||
@@ -1000,7 +1013,11 @@ export function AgentView() {
|
||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||
Press{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||
send
|
||||
send,{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
||||
Shift+Enter
|
||||
</kbd>{' '}
|
||||
for new line
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
|
||||
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
@@ -34,12 +35,18 @@ export function BoardHeader({
|
||||
isMounted,
|
||||
}: BoardHeaderProps) {
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
// Only show if CLI has been verified/authenticated
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
@@ -10,6 +9,25 @@ import { CardContentSections } from './card-content-sections';
|
||||
import { AgentInfoPanel } from './agent-info-panel';
|
||||
import { CardActions } from './card-actions';
|
||||
|
||||
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
|
||||
if (!enabled) {
|
||||
return { borderWidth: '0px', borderColor: 'transparent' };
|
||||
}
|
||||
if (opacity !== 100) {
|
||||
return {
|
||||
borderWidth: '1px',
|
||||
borderColor: `color-mix(in oklch, var(--border) ${opacity}%, transparent)`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
|
||||
if (isOverlay) return 'cursor-grabbing';
|
||||
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
|
||||
return 'cursor-default';
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
@@ -35,6 +53,7 @@ interface KanbanCardProps {
|
||||
glassmorphism?: boolean;
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -62,64 +81,63 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
isOverlay,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOverlay) {
|
||||
requestAnimationFrame(() => {
|
||||
setIsLifted(true);
|
||||
});
|
||||
}
|
||||
}, [isOverlay]);
|
||||
|
||||
const isDraggable =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable,
|
||||
disabled: !isDraggable || isOverlay,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
const borderStyle: React.CSSProperties = { ...style };
|
||||
if (!cardBorderEnabled) {
|
||||
(borderStyle as Record<string, string>).borderWidth = '0px';
|
||||
(borderStyle as Record<string, string>).borderColor = 'transparent';
|
||||
} else if (cardBorderOpacity !== 100) {
|
||||
(borderStyle as Record<string, string>).borderWidth = '1px';
|
||||
(borderStyle as Record<string, string>).borderColor =
|
||||
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
||||
}
|
||||
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
|
||||
|
||||
const cardElement = (
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
||||
);
|
||||
|
||||
const isInteractive = !isDragging && !isOverlay;
|
||||
const hasError = feature.error && !isCurrentAutoTask;
|
||||
|
||||
const innerCardClasses = cn(
|
||||
'kanban-card-content h-full relative shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
|
||||
);
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={isCurrentAutoTask ? style : borderStyle}
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Premium shadow system
|
||||
'shadow-sm hover:shadow-md hover:shadow-black/10',
|
||||
// Subtle lift on hover
|
||||
'hover:-translate-y-0.5',
|
||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
|
||||
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
|
||||
!isDragging && 'bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||
// Error state - using CSS variable
|
||||
feature.error &&
|
||||
!isCurrentAutoTask &&
|
||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
!isDraggable && 'cursor-default'
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
style={isCurrentAutoTask ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={onEdit}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{!isDragging && (
|
||||
{(!isDragging || isOverlay) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl bg-card -z-10',
|
||||
@@ -185,10 +203,20 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Wrap with animated border when in progress
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={dndStyle}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
className={wrapperClasses}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
>
|
||||
{isCurrentAutoTask ? (
|
||||
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
||||
) : (
|
||||
renderCardContent()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ interface AgentOutputModalProps {
|
||||
featureStatus?: string;
|
||||
/** Called when a number key (0-9) is pressed while the modal is open */
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'parsed' | 'raw' | 'changes';
|
||||
@@ -34,6 +36,7 @@ export function AgentOutputModal({
|
||||
featureId,
|
||||
featureStatus,
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
}: AgentOutputModalProps) {
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -62,19 +65,19 @@ export function AgentOutputModal({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get current project path from store (we'll need to pass this)
|
||||
const currentProject = (window as any).__currentProject;
|
||||
if (!currentProject?.path) {
|
||||
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
|
||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
|
||||
if (!resolvedProjectPath) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectPathRef.current = currentProject.path;
|
||||
setProjectPath(currentProject.path);
|
||||
projectPathRef.current = resolvedProjectPath;
|
||||
setProjectPath(resolvedProjectPath);
|
||||
|
||||
// Use features API to get agent output
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(currentProject.path, featureId);
|
||||
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
|
||||
|
||||
if (result.success) {
|
||||
setOutput(result.content || '');
|
||||
@@ -93,7 +96,7 @@ export function AgentOutputModal({
|
||||
};
|
||||
|
||||
loadOutput();
|
||||
}, [open, featureId]);
|
||||
}, [open, featureId, projectPathProp]);
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { KanbanColumn, KanbanCard } from './components';
|
||||
@@ -241,19 +240,32 @@ export function KanbanBoard({
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<Card
|
||||
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
|
||||
style={{ width: `${columnWidth}px` }}
|
||||
>
|
||||
<CardHeader className="p-3">
|
||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
||||
{activeFeature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
{activeFeature.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div style={{ width: `${columnWidth}px` }}>
|
||||
<KanbanCard
|
||||
feature={activeFeature}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onViewOutput={() => {}}
|
||||
onVerify={() => {}}
|
||||
onResume={() => {}}
|
||||
onForceStop={() => {}}
|
||||
onManualVerify={() => {}}
|
||||
onMoveBackToInProgress={() => {}}
|
||||
onFollowUp={() => {}}
|
||||
onImplement={() => {}}
|
||||
onComplete={() => {}}
|
||||
onViewPlan={() => {}}
|
||||
onApprovePlan={() => {}}
|
||||
onSpawnTask={() => {}}
|
||||
hasContext={featuresWithContext.has(activeFeature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
@@ -11,12 +11,15 @@ import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'
|
||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||
import type { ValidateIssueOptions } from './github-issues-view/types';
|
||||
|
||||
export function GitHubIssuesView() {
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
|
||||
const [showValidationDialog, setShowValidationDialog] = useState(false);
|
||||
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
|
||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||
useState<ValidateIssueOptions | null>(null);
|
||||
|
||||
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
|
||||
useAppStore();
|
||||
@@ -203,7 +206,10 @@ export function GitHubIssuesView() {
|
||||
onViewCachedValidation={handleViewCachedValidation}
|
||||
onOpenInGitHub={handleOpenInGitHub}
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
|
||||
onShowRevalidateConfirm={(options) => {
|
||||
setPendingRevalidateOptions(options);
|
||||
setShowRevalidateConfirm(true);
|
||||
}}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
)}
|
||||
@@ -220,15 +226,24 @@ export function GitHubIssuesView() {
|
||||
{/* Revalidate Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showRevalidateConfirm}
|
||||
onOpenChange={setShowRevalidateConfirm}
|
||||
onOpenChange={(open) => {
|
||||
setShowRevalidateConfirm(open);
|
||||
if (!open) {
|
||||
setPendingRevalidateOptions(null);
|
||||
}
|
||||
}}
|
||||
title="Re-validate Issue"
|
||||
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
|
||||
icon={RefreshCw}
|
||||
iconClassName="text-primary"
|
||||
confirmText="Re-validate"
|
||||
onConfirm={() => {
|
||||
if (selectedIssue) {
|
||||
handleValidateIssue(selectedIssue, { forceRevalidate: true });
|
||||
if (selectedIssue && pendingRevalidateOptions) {
|
||||
console.log('[GitHubIssuesView] Revalidating with options:', {
|
||||
commentsCount: pendingRevalidateOptions.comments?.length ?? 0,
|
||||
linkedPRsCount: pendingRevalidateOptions.linkedPRs?.length ?? 0,
|
||||
});
|
||||
handleValidateIssue(selectedIssue, pendingRevalidateOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { User } from 'lucide-react';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import type { GitHubComment } from '@/lib/electron';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: GitHubComment;
|
||||
}
|
||||
|
||||
export function CommentItem({ comment }: CommentItemProps) {
|
||||
return (
|
||||
<div className="p-3 rounded-lg bg-background border border-border">
|
||||
{/* Comment Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{comment.author.avatarUrl ? (
|
||||
<img
|
||||
src={comment.author.avatarUrl}
|
||||
alt={comment.author.login}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium">{comment.author.login}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
commented {formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comment Body */}
|
||||
{comment.body ? (
|
||||
<Markdown className="text-sm">{comment.body}</Markdown>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No content</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { IssueRow } from './issue-row';
|
||||
export { IssueDetailPanel } from './issue-detail-panel';
|
||||
export { IssuesListHeader } from './issues-list-header';
|
||||
export { CommentItem } from './comment-item';
|
||||
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
GitPullRequest,
|
||||
User,
|
||||
RefreshCw,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueDetailPanelProps } from '../types';
|
||||
import { isValidationStale } from '../utils';
|
||||
import { useIssueComments } from '../hooks';
|
||||
import { CommentItem } from './comment-item';
|
||||
|
||||
export function IssueDetailPanel({
|
||||
issue,
|
||||
@@ -32,6 +39,32 @@ export function IssueDetailPanel({
|
||||
const cached = cachedValidations.get(issue.number);
|
||||
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
|
||||
|
||||
// Comments state
|
||||
const [commentsExpanded, setCommentsExpanded] = useState(true);
|
||||
const [includeCommentsInAnalysis, setIncludeCommentsInAnalysis] = useState(true);
|
||||
const {
|
||||
comments,
|
||||
totalCount,
|
||||
loading: commentsLoading,
|
||||
loadingMore,
|
||||
hasNextPage,
|
||||
error: commentsError,
|
||||
loadMore,
|
||||
} = useIssueComments(issue.number);
|
||||
|
||||
// Helper to get validation options with comments and linked PRs
|
||||
const getValidationOptions = (forceRevalidate = false) => {
|
||||
return {
|
||||
forceRevalidate,
|
||||
comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined,
|
||||
linkedPRs: issue.linkedPRs?.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
@@ -67,7 +100,7 @@ export function IssueDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onShowRevalidateConfirm}
|
||||
onClick={() => onShowRevalidateConfirm(getValidationOptions(true))}
|
||||
title="Re-validate"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
@@ -86,7 +119,7 @@ export function IssueDetailPanel({
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
|
||||
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
Re-validate
|
||||
@@ -96,7 +129,11 @@ export function IssueDetailPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
Validate with AI
|
||||
</Button>
|
||||
@@ -226,6 +263,74 @@ export function IssueDetailPanel({
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="mt-6 p-3 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="flex items-center gap-2 text-left"
|
||||
onClick={() => setCommentsExpanded(!commentsExpanded)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Comments {totalCount > 0 && `(${totalCount})`}
|
||||
</span>
|
||||
{commentsLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{commentsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{comments.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={includeCommentsInAnalysis}
|
||||
onCheckedChange={setIncludeCommentsInAnalysis}
|
||||
/>
|
||||
Include in AI analysis
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{commentsExpanded && (
|
||||
<div className="mt-3">
|
||||
{commentsError ? (
|
||||
<p className="text-sm text-red-500">{commentsError}</p>
|
||||
) : comments.length === 0 && !commentsLoading ? (
|
||||
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More Comments'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in GitHub CTA */}
|
||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
GitPullRequest,
|
||||
Clock,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
@@ -149,6 +152,77 @@ export function ValidationDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR Analysis Section - Show AI's analysis of linked PRs */}
|
||||
{validationResult.prAnalysis && validationResult.prAnalysis.hasOpenPR && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
validationResult.prAnalysis.recommendation === 'wait_for_merge'
|
||||
? 'bg-green-500/10 border-green-500/20'
|
||||
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
|
||||
? 'bg-yellow-500/10 border-yellow-500/20'
|
||||
: 'bg-purple-500/10 border-purple-500/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{validationResult.prAnalysis.recommendation === 'wait_for_merge' ? (
|
||||
<Clock className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
|
||||
) : validationResult.prAnalysis.recommendation === 'pr_needs_work' ? (
|
||||
<Wrench className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
validationResult.prAnalysis.recommendation === 'wait_for_merge'
|
||||
? 'text-green-500'
|
||||
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
|
||||
? 'text-yellow-500'
|
||||
: 'text-purple-500'
|
||||
)}
|
||||
>
|
||||
{validationResult.prAnalysis.recommendation === 'wait_for_merge'
|
||||
? 'Fix Ready - Wait for Merge'
|
||||
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
|
||||
? 'PR Needs Work'
|
||||
: 'Work in Progress'}
|
||||
</span>
|
||||
{validationResult.prAnalysis.prNumber && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
PR #{validationResult.prAnalysis.prNumber}
|
||||
{validationResult.prAnalysis.prFixesIssue && ' appears to fix this issue'}
|
||||
</p>
|
||||
)}
|
||||
{validationResult.prAnalysis.prSummary && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{validationResult.prAnalysis.prSummary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback Work in Progress Badge - Show when there's an open PR but no AI analysis */}
|
||||
{!validationResult.prAnalysis?.hasOpenPR &&
|
||||
issue.linkedPRs?.some((pr) => pr.state === 'open' || pr.state === 'OPEN') && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-purple-500/10 border border-purple-500/20">
|
||||
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-purple-500">Work in Progress</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{issue.linkedPRs
|
||||
.filter((pr) => pr.state === 'open' || pr.state === 'OPEN')
|
||||
.map((pr) => `PR #${pr.number}`)
|
||||
.join(', ')}{' '}
|
||||
is open for this issue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
@@ -218,12 +292,14 @@ export function ValidationDialog({
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{validationResult?.verdict === 'valid' && onConvertToTask && (
|
||||
<Button onClick={handleConvertToTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Convert to Task
|
||||
</Button>
|
||||
)}
|
||||
{validationResult?.verdict === 'valid' &&
|
||||
onConvertToTask &&
|
||||
validationResult?.prAnalysis?.recommendation !== 'wait_for_merge' && (
|
||||
<Button onClick={handleConvertToTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Convert to Task
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useGithubIssues } from './use-github-issues';
|
||||
export { useIssueValidation } from './use-issue-validation';
|
||||
export { useIssueComments } from './use-issue-comments';
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getElectronAPI, GitHubComment } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface UseIssueCommentsResult {
|
||||
comments: GitHubComment[];
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasNextPage: boolean;
|
||||
error: string | null;
|
||||
loadMore: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
|
||||
const { currentProject } = useAppStore();
|
||||
const [comments, setComments] = useState<GitHubComment[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchComments = useCallback(
|
||||
async (cursor?: string) => {
|
||||
if (!currentProject?.path || !issueNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoadingMore = !!cursor;
|
||||
|
||||
try {
|
||||
if (isMountedRef.current) {
|
||||
setError(null);
|
||||
if (isLoadingMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (api.github) {
|
||||
const result = await api.github.getIssueComments(
|
||||
currentProject.path,
|
||||
issueNumber,
|
||||
cursor
|
||||
);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
if (result.success) {
|
||||
if (isLoadingMore) {
|
||||
// Append new comments
|
||||
setComments((prev) => [...prev, ...(result.comments || [])]);
|
||||
} else {
|
||||
// Replace all comments
|
||||
setComments(result.comments || []);
|
||||
}
|
||||
setTotalCount(result.totalCount || 0);
|
||||
setHasNextPage(result.hasNextPage || false);
|
||||
setEndCursor(result.endCursor);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch comments');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
console.error('[useIssueComments] Error fetching comments:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentProject?.path, issueNumber]
|
||||
);
|
||||
|
||||
// Reset and fetch when issue changes
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (issueNumber) {
|
||||
// Reset state when issue changes
|
||||
setComments([]);
|
||||
setTotalCount(0);
|
||||
setHasNextPage(false);
|
||||
setEndCursor(undefined);
|
||||
setError(null);
|
||||
fetchComments();
|
||||
} else {
|
||||
// Clear comments when no issue is selected
|
||||
setComments([]);
|
||||
setTotalCount(0);
|
||||
setHasNextPage(false);
|
||||
setEndCursor(undefined);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [issueNumber, fetchComments]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasNextPage && endCursor && !loadingMore) {
|
||||
fetchComments(endCursor);
|
||||
}
|
||||
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setComments([]);
|
||||
setEndCursor(undefined);
|
||||
fetchComments();
|
||||
}, [fetchComments]);
|
||||
|
||||
return {
|
||||
comments,
|
||||
totalCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasNextPage,
|
||||
error,
|
||||
loadMore,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
getElectronAPI,
|
||||
GitHubIssue,
|
||||
GitHubComment,
|
||||
IssueValidationResult,
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
} from '@/lib/electron';
|
||||
import type { LinkedPRInfo } from '@automaker/types';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { isValidationStale } from '../utils';
|
||||
@@ -205,8 +207,15 @@ export function useIssueValidation({
|
||||
}, []);
|
||||
|
||||
const handleValidateIssue = useCallback(
|
||||
async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => {
|
||||
const { forceRevalidate = false } = options;
|
||||
async (
|
||||
issue: GitHubIssue,
|
||||
options: {
|
||||
forceRevalidate?: boolean;
|
||||
comments?: GitHubComment[];
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
} = {}
|
||||
) => {
|
||||
const { forceRevalidate = false, comments, linkedPRs } = options;
|
||||
|
||||
if (!currentProject?.path) {
|
||||
toast.error('No project selected');
|
||||
@@ -236,14 +245,17 @@ export function useIssueValidation({
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.github?.validateIssue) {
|
||||
const validationInput = {
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
issueBody: issue.body || '',
|
||||
issueLabels: issue.labels.map((l) => l.name),
|
||||
comments, // Include comments if provided
|
||||
linkedPRs, // Include linked PRs if provided
|
||||
};
|
||||
const result = await api.github.validateIssue(
|
||||
currentProject.path,
|
||||
{
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
issueBody: issue.body || '',
|
||||
issueLabels: issue.labels.map((l) => l.name),
|
||||
},
|
||||
validationInput,
|
||||
validationModel
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||
import type { LinkedPRInfo } from '@automaker/types';
|
||||
|
||||
export interface IssueRowProps {
|
||||
issue: GitHubIssue;
|
||||
@@ -12,17 +13,25 @@ export interface IssueRowProps {
|
||||
isValidating?: boolean;
|
||||
}
|
||||
|
||||
/** Options for issue validation */
|
||||
export interface ValidateIssueOptions {
|
||||
showDialog?: boolean;
|
||||
forceRevalidate?: boolean;
|
||||
/** Include comments in AI analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests */
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
}
|
||||
|
||||
export interface IssueDetailPanelProps {
|
||||
issue: GitHubIssue;
|
||||
validatingIssues: Set<number>;
|
||||
cachedValidations: Map<number, StoredValidation>;
|
||||
onValidateIssue: (
|
||||
issue: GitHubIssue,
|
||||
options?: { showDialog?: boolean; forceRevalidate?: boolean }
|
||||
) => Promise<void>;
|
||||
onValidateIssue: (issue: GitHubIssue, options?: ValidateIssueOptions) => Promise<void>;
|
||||
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
|
||||
onOpenInGitHub: (url: string) => void;
|
||||
onClose: () => void;
|
||||
onShowRevalidateConfirm: () => void;
|
||||
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
|
||||
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
110
apps/ui/src/components/views/login-view.tsx
Normal file
110
apps/ui/src/components/views/login-view.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Login View - Web mode authentication
|
||||
*
|
||||
* Prompts user to enter the API key shown in server console.
|
||||
* On successful login, sets an HTTP-only session cookie.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { login } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
export function LoginView() {
|
||||
const navigate = useNavigate();
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login(apiKey.trim());
|
||||
if (result.success) {
|
||||
// Redirect to home/board on success
|
||||
navigate({ to: '/' });
|
||||
} else {
|
||||
setError(result.error || 'Invalid API key');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to connect to server');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<KeyRound className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Enter the API key shown in the server console to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="font-mono"
|
||||
data-testid="login-api-key-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !apiKey.trim()}
|
||||
data-testid="login-submit-button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
'Login'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
<p className="font-medium">Where to find the API key:</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Look at the server terminal/console output</li>
|
||||
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
|
||||
<li>Copy the UUID displayed there</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
|
||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||
|
||||
export function RunningAgentsView() {
|
||||
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||
const { setCurrentProject, projects } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -94,6 +96,10 @@ export function RunningAgentsView() {
|
||||
[projects, setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleViewLogs = useCallback((agent: RunningAgent) => {
|
||||
setSelectedAgent(agent);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@@ -156,15 +162,25 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{agent.featureId}</span>
|
||||
<span className="font-medium truncate" title={agent.title || agent.featureId}>
|
||||
{agent.title || agent.featureId}
|
||||
</span>
|
||||
{agent.isAutoMode && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p
|
||||
className="text-sm text-muted-foreground truncate max-w-md"
|
||||
title={agent.description}
|
||||
>
|
||||
{agent.description}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleNavigateToProject(agent)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -177,6 +193,15 @@ export function RunningAgentsView() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewLogs(agent)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
View Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -199,6 +224,20 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
{selectedAgent && (
|
||||
<AgentOutputModal
|
||||
open={true}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
projectPath={selectedAgent.projectPath}
|
||||
featureDescription={
|
||||
selectedAgent.description || selectedAgent.title || selectedAgent.featureId
|
||||
}
|
||||
featureId={selectedAgent.featureId}
|
||||
featureStatus="running"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
import { useCliStatus, useSettingsView } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
@@ -19,6 +20,7 @@ import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/key
|
||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||
import type { Project as ElectronProject } from '@/lib/electron';
|
||||
|
||||
@@ -53,13 +55,22 @@ export function SettingsView() {
|
||||
setAutoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
setEnableSandboxMode,
|
||||
promptCustomization,
|
||||
setPromptCustomization,
|
||||
} = useAppStore();
|
||||
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
|
||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
||||
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
// Only show if CLI has been verified/authenticated
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
|
||||
@@ -119,6 +130,13 @@ export function SettingsView() {
|
||||
);
|
||||
case 'mcp-servers':
|
||||
return <MCPServersSection />;
|
||||
case 'prompts':
|
||||
return (
|
||||
<PromptCustomizationSection
|
||||
promptCustomization={promptCustomization}
|
||||
onPromptCustomizationChange={setPromptCustomization}
|
||||
/>
|
||||
);
|
||||
case 'ai-enhancement':
|
||||
return <AIEnhancementSection />;
|
||||
case 'appearance':
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Plug,
|
||||
MessageSquareText,
|
||||
} from 'lucide-react';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
|
||||
@@ -24,6 +25,7 @@ export const NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||
{ id: 'claude', label: 'Claude', icon: Terminal },
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SettingsViewId =
|
||||
| 'api-keys'
|
||||
| 'claude'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'ai-enhancement'
|
||||
| 'appearance'
|
||||
| 'terminal'
|
||||
|
||||
@@ -47,6 +47,7 @@ export function useMCPServers() {
|
||||
const [isGlobalJsonEditOpen, setIsGlobalJsonEditOpen] = useState(false);
|
||||
const [globalJsonValue, setGlobalJsonValue] = useState('');
|
||||
const autoTestedServersRef = useRef<Set<string>>(new Set());
|
||||
const pendingSyncServerIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Security warning dialog state
|
||||
const [isSecurityWarningOpen, setIsSecurityWarningOpen] = useState(false);
|
||||
@@ -130,10 +131,12 @@ export function useMCPServers() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-test all enabled servers on mount
|
||||
// Auto-test all enabled servers on mount (skip servers pending sync)
|
||||
useEffect(() => {
|
||||
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||
const serversToTest = enabledServers.filter((s) => !autoTestedServersRef.current.has(s.id));
|
||||
const serversToTest = enabledServers.filter(
|
||||
(s) => !autoTestedServersRef.current.has(s.id) && !pendingSyncServerIdsRef.current.has(s.id)
|
||||
);
|
||||
|
||||
if (serversToTest.length > 0) {
|
||||
// Mark all as being tested
|
||||
@@ -275,9 +278,16 @@ export function useMCPServers() {
|
||||
|
||||
// If editing an existing server, save directly (user already approved it)
|
||||
if (editingServer) {
|
||||
const previousData = { ...editingServer };
|
||||
updateMCPServer(editingServer.id, serverData);
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
if (!syncSuccess) {
|
||||
// Rollback local state on sync failure
|
||||
updateMCPServer(editingServer.id, previousData);
|
||||
toast.error('Failed to save MCP server to disk');
|
||||
return;
|
||||
}
|
||||
toast.success('MCP server updated');
|
||||
await syncSettingsToServer();
|
||||
handleCloseDialog();
|
||||
return;
|
||||
}
|
||||
@@ -302,15 +312,65 @@ export function useMCPServers() {
|
||||
if (!pendingServerData) return;
|
||||
|
||||
if (pendingServerData.type === 'add' && pendingServerData.serverData) {
|
||||
// Capture existing IDs before adding to find the new server reliably
|
||||
const existingIds = new Set(mcpServers.map((s) => s.id));
|
||||
addMCPServer(pendingServerData.serverData);
|
||||
|
||||
// Find the newly added server by comparing IDs
|
||||
const newServers = useAppStore.getState().mcpServers;
|
||||
const newServer = newServers.find((s) => !existingIds.has(s.id));
|
||||
if (newServer) {
|
||||
pendingSyncServerIdsRef.current.add(newServer.id);
|
||||
}
|
||||
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
|
||||
// Clear pending sync and trigger auto-test after sync
|
||||
if (newServer) {
|
||||
pendingSyncServerIdsRef.current.delete(newServer.id);
|
||||
if (syncSuccess && newServer.enabled !== false) {
|
||||
testServer(newServer, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!syncSuccess) {
|
||||
toast.error('Failed to save MCP server to disk');
|
||||
setIsSecurityWarningOpen(false);
|
||||
setPendingServerData(null);
|
||||
return;
|
||||
}
|
||||
toast.success('MCP server added');
|
||||
await syncSettingsToServer();
|
||||
handleCloseDialog();
|
||||
} else if (pendingServerData.type === 'import' && pendingServerData.importServers) {
|
||||
// Capture existing IDs before adding to find the new servers reliably
|
||||
const existingIds = new Set(mcpServers.map((s) => s.id));
|
||||
|
||||
for (const serverData of pendingServerData.importServers) {
|
||||
addMCPServer(serverData);
|
||||
}
|
||||
await syncSettingsToServer();
|
||||
|
||||
// Find all newly added servers by comparing IDs
|
||||
const newServers = useAppStore.getState().mcpServers.filter((s) => !existingIds.has(s.id));
|
||||
newServers.forEach((s) => pendingSyncServerIdsRef.current.add(s.id));
|
||||
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
|
||||
// Clear pending sync and trigger auto-test after sync
|
||||
newServers.forEach((s) => pendingSyncServerIdsRef.current.delete(s.id));
|
||||
if (syncSuccess) {
|
||||
for (const server of newServers) {
|
||||
if (server.enabled !== false) {
|
||||
testServer(server, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!syncSuccess) {
|
||||
toast.error('Failed to save MCP servers to disk');
|
||||
setIsSecurityWarningOpen(false);
|
||||
setPendingServerData(null);
|
||||
return;
|
||||
}
|
||||
const count = pendingServerData.importServers.length;
|
||||
toast.success(`Imported ${count} MCP server${count > 1 ? 's' : ''}`);
|
||||
setIsImportDialogOpen(false);
|
||||
@@ -322,96 +382,154 @@ export function useMCPServers() {
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (server: MCPServerConfig) => {
|
||||
const wasDisabled = server.enabled === false;
|
||||
const previousEnabled = server.enabled;
|
||||
updateMCPServer(server.id, { enabled: !server.enabled });
|
||||
await syncSettingsToServer();
|
||||
toast.success(server.enabled ? 'Server disabled' : 'Server enabled');
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
if (!syncSuccess) {
|
||||
// Rollback local state on sync failure
|
||||
updateMCPServer(server.id, { enabled: previousEnabled });
|
||||
toast.error('Failed to save settings to disk');
|
||||
return;
|
||||
}
|
||||
toast.success(wasDisabled ? 'Server enabled' : 'Server disabled');
|
||||
|
||||
// Auto-test if server was just enabled
|
||||
if (wasDisabled) {
|
||||
const updatedServer = useAppStore.getState().mcpServers.find((s) => s.id === server.id);
|
||||
if (updatedServer) {
|
||||
testServer(updatedServer, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
removeMCPServer(id);
|
||||
await syncSettingsToServer();
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
setDeleteConfirmId(null);
|
||||
if (!syncSuccess) {
|
||||
toast.error('Failed to save settings to disk');
|
||||
return;
|
||||
}
|
||||
toast.success('MCP server removed');
|
||||
};
|
||||
|
||||
/** Helper to parse a server config into importable format */
|
||||
const parseServerConfig = (
|
||||
name: string,
|
||||
serverConfig: Record<string, unknown>
|
||||
): Omit<MCPServerConfig, 'id'> | null => {
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name,
|
||||
type: (serverConfig.type as ServerType) || 'stdio',
|
||||
enabled: serverConfig.enabled !== false,
|
||||
};
|
||||
|
||||
if (serverConfig.description) {
|
||||
serverData.description = serverConfig.description as string;
|
||||
}
|
||||
|
||||
if (serverData.type === 'stdio') {
|
||||
if (!serverConfig.command) {
|
||||
console.warn(`Skipping ${name}: no command specified`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawCommand = serverConfig.command as string;
|
||||
|
||||
// Support both formats:
|
||||
// 1. Separate command/args: { "command": "npx", "args": ["-y", "package"] }
|
||||
// 2. Inline args (Claude Desktop format): { "command": "npx -y package" }
|
||||
if (Array.isArray(serverConfig.args) && serverConfig.args.length > 0) {
|
||||
serverData.command = rawCommand;
|
||||
serverData.args = serverConfig.args as string[];
|
||||
} else if (rawCommand.includes(' ')) {
|
||||
const parts = rawCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [rawCommand];
|
||||
serverData.command = parts[0];
|
||||
if (parts.length > 1) {
|
||||
serverData.args = parts.slice(1).map((arg) => arg.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
} else {
|
||||
serverData.command = rawCommand;
|
||||
}
|
||||
|
||||
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||
serverData.env = serverConfig.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
if (!serverConfig.url) {
|
||||
console.warn(`Skipping ${name}: no url specified`);
|
||||
return null;
|
||||
}
|
||||
serverData.url = serverConfig.url as string;
|
||||
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
return serverData;
|
||||
};
|
||||
|
||||
const handleImportJson = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(importJson);
|
||||
|
||||
// Support both formats:
|
||||
// 1. Claude Code format: { "mcpServers": { "name": { command, args, ... } } }
|
||||
// 2. Direct format: { "name": { command, args, ... } }
|
||||
// 1. Array format (new): { "mcpServers": [...] } or [...]
|
||||
// 2. Object format (legacy): { "mcpServers": {...} } or { "name": {...} }
|
||||
const servers = parsed.mcpServers || parsed;
|
||||
|
||||
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||
toast.error('Invalid format: expected object with server configurations');
|
||||
return;
|
||||
}
|
||||
|
||||
const serversToImport: Array<Omit<MCPServerConfig, 'id'>> = [];
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (typeof config !== 'object' || config === null) continue;
|
||||
if (Array.isArray(servers)) {
|
||||
// Array format - each item has name property
|
||||
for (const serverConfig of servers) {
|
||||
if (typeof serverConfig !== 'object' || serverConfig === null) continue;
|
||||
|
||||
const serverConfig = config as Record<string, unknown>;
|
||||
const config = serverConfig as Record<string, unknown>;
|
||||
const name = config.name as string;
|
||||
|
||||
// Check if server with this name already exists
|
||||
if (mcpServers.some((s) => s.name === name)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name,
|
||||
type: (serverConfig.type as ServerType) || 'stdio',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
if (serverData.type === 'stdio') {
|
||||
if (!serverConfig.command) {
|
||||
console.warn(`Skipping ${name}: no command specified`);
|
||||
if (!name) {
|
||||
console.warn('Skipping server: no name specified');
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawCommand = serverConfig.command as string;
|
||||
// Check if server with this name already exists
|
||||
if (mcpServers.some((s) => s.name === name)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Support both formats:
|
||||
// 1. Separate command/args: { "command": "npx", "args": ["-y", "package"] }
|
||||
// 2. Inline args (Claude Desktop format): { "command": "npx -y package" }
|
||||
if (Array.isArray(serverConfig.args) && serverConfig.args.length > 0) {
|
||||
// Args provided separately
|
||||
serverData.command = rawCommand;
|
||||
serverData.args = serverConfig.args as string[];
|
||||
} else if (rawCommand.includes(' ')) {
|
||||
// Parse inline command string - split on spaces but preserve quoted strings
|
||||
const parts = rawCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [rawCommand];
|
||||
serverData.command = parts[0];
|
||||
if (parts.length > 1) {
|
||||
// Remove quotes from args
|
||||
serverData.args = parts.slice(1).map((arg) => arg.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
const serverData = parseServerConfig(name, config);
|
||||
if (serverData) {
|
||||
serversToImport.push(serverData);
|
||||
} else {
|
||||
serverData.command = rawCommand;
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
} else if (typeof servers === 'object' && servers !== null) {
|
||||
// Object format - name is the key
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (typeof config !== 'object' || config === null) continue;
|
||||
|
||||
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||
serverData.env = serverConfig.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
if (!serverConfig.url) {
|
||||
console.warn(`Skipping ${name}: no url specified`);
|
||||
// Check if server with this name already exists
|
||||
if (mcpServers.some((s) => s.name === name)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
serverData.url = serverConfig.url as string;
|
||||
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||
|
||||
const serverData = parseServerConfig(name, config as Record<string, unknown>);
|
||||
if (serverData) {
|
||||
serversToImport.push(serverData);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
serversToImport.push(serverData);
|
||||
} else {
|
||||
toast.error('Invalid format: expected array or object with server configurations');
|
||||
return;
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
@@ -443,13 +561,21 @@ export function useMCPServers() {
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
const exportData: Record<string, Record<string, unknown>> = {};
|
||||
// Export as array format with IDs preserved for full fidelity
|
||||
const exportData: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const server of mcpServers) {
|
||||
const serverConfig: Record<string, unknown> = {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type || 'stdio',
|
||||
enabled: server.enabled ?? true,
|
||||
};
|
||||
|
||||
if (server.description) {
|
||||
serverConfig.description = server.description;
|
||||
}
|
||||
|
||||
if (server.type === 'stdio' || !server.type) {
|
||||
serverConfig.command = server.command;
|
||||
if (server.args?.length) serverConfig.args = server.args;
|
||||
@@ -460,7 +586,7 @@ export function useMCPServers() {
|
||||
serverConfig.headers = server.headers;
|
||||
}
|
||||
|
||||
exportData[server.name] = serverConfig;
|
||||
exportData.push(serverConfig);
|
||||
}
|
||||
|
||||
const json = JSON.stringify({ mcpServers: exportData }, null, 2);
|
||||
@@ -558,8 +684,11 @@ export function useMCPServers() {
|
||||
}
|
||||
|
||||
updateMCPServer(jsonEditServer.id, updateData);
|
||||
await syncSettingsToServer();
|
||||
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
if (!syncSuccess) {
|
||||
toast.error('Failed to save settings to disk');
|
||||
return;
|
||||
}
|
||||
toast.success('Server configuration updated');
|
||||
setJsonEditServer(null);
|
||||
setJsonEditValue('');
|
||||
@@ -569,22 +698,21 @@ export function useMCPServers() {
|
||||
};
|
||||
|
||||
const handleOpenGlobalJsonEdit = () => {
|
||||
// Build the full mcpServers config object
|
||||
const exportData: Record<string, Record<string, unknown>> = {};
|
||||
// Build the full mcpServers config as array with IDs preserved
|
||||
const exportData: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const server of mcpServers) {
|
||||
const serverConfig: Record<string, unknown> = {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type || 'stdio',
|
||||
enabled: server.enabled ?? true,
|
||||
};
|
||||
|
||||
if (server.description) {
|
||||
serverConfig.description = server.description;
|
||||
}
|
||||
|
||||
if (server.enabled === false) {
|
||||
serverConfig.enabled = false;
|
||||
}
|
||||
|
||||
if (server.type === 'stdio' || !server.type) {
|
||||
serverConfig.command = server.command;
|
||||
if (server.args?.length) serverConfig.args = server.args;
|
||||
@@ -596,97 +724,209 @@ export function useMCPServers() {
|
||||
}
|
||||
}
|
||||
|
||||
exportData[server.name] = serverConfig;
|
||||
exportData.push(serverConfig);
|
||||
}
|
||||
|
||||
setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2));
|
||||
setIsGlobalJsonEditOpen(true);
|
||||
};
|
||||
|
||||
/** Helper to save array format (with IDs preserved) */
|
||||
const handleSaveGlobalJsonArray = async (
|
||||
serversArray: Array<Record<string, unknown>>
|
||||
): Promise<boolean> => {
|
||||
// Validate all servers first
|
||||
const names = new Set<string>();
|
||||
for (const serverConfig of serversArray) {
|
||||
const name = serverConfig.name as string;
|
||||
if (!name || typeof name !== 'string') {
|
||||
toast.error('Each server must have a name');
|
||||
return false;
|
||||
}
|
||||
if (names.has(name)) {
|
||||
toast.error(`Duplicate server name found: "${name}"`);
|
||||
return false;
|
||||
}
|
||||
names.add(name);
|
||||
|
||||
const serverType = (serverConfig.type as string) || 'stdio';
|
||||
if (serverType === 'stdio') {
|
||||
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
|
||||
toast.error(`Command is required for "${name}" (stdio)`);
|
||||
return false;
|
||||
}
|
||||
} else if (serverType === 'sse' || serverType === 'http') {
|
||||
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
|
||||
toast.error(`URL is required for "${name}" (${serverType})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create maps for matching: by ID first, then by name
|
||||
const existingById = new Map(mcpServers.map((s) => [s.id, s]));
|
||||
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
// Update or add servers
|
||||
for (const serverConfig of serversArray) {
|
||||
const serverType = (serverConfig.type as ServerType) || 'stdio';
|
||||
const serverId = serverConfig.id as string | undefined;
|
||||
const serverName = serverConfig.name as string;
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name: serverName,
|
||||
type: serverType,
|
||||
description: (serverConfig.description as string) || undefined,
|
||||
enabled: serverConfig.enabled !== false,
|
||||
};
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
serverData.command = serverConfig.command as string;
|
||||
if (Array.isArray(serverConfig.args)) {
|
||||
serverData.args = serverConfig.args as string[];
|
||||
}
|
||||
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||
serverData.env = serverConfig.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
serverData.url = serverConfig.url as string;
|
||||
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
// Match by ID first (allows renaming), then by name (backward compatibility)
|
||||
const existingServer = serverId ? existingById.get(serverId) : existingByName.get(serverName);
|
||||
|
||||
if (existingServer) {
|
||||
updateMCPServer(existingServer.id, serverData);
|
||||
processedIds.add(existingServer.id);
|
||||
} else {
|
||||
addMCPServer(serverData);
|
||||
// Get the newly added server ID
|
||||
const newServers = useAppStore.getState().mcpServers;
|
||||
const newServer = newServers.find((s) => s.name === serverName);
|
||||
if (newServer) {
|
||||
processedIds.add(newServer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove servers that are no longer in the JSON
|
||||
for (const server of mcpServers) {
|
||||
if (!processedIds.has(server.id)) {
|
||||
removeMCPServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Helper to save object format (legacy Claude Desktop format) */
|
||||
const handleSaveGlobalJsonObject = async (
|
||||
serversObject: Record<string, Record<string, unknown>>
|
||||
): Promise<boolean> => {
|
||||
// Validate all servers first
|
||||
for (const [name, config] of Object.entries(serversObject)) {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
toast.error(`Invalid config for "${name}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverType = (config.type as string) || 'stdio';
|
||||
if (serverType === 'stdio') {
|
||||
if (!config.command || typeof config.command !== 'string') {
|
||||
toast.error(`Command is required for "${name}" (stdio)`);
|
||||
return false;
|
||||
}
|
||||
} else if (serverType === 'sse' || serverType === 'http') {
|
||||
if (!config.url || typeof config.url !== 'string') {
|
||||
toast.error(`URL is required for "${name}" (${serverType})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map of existing servers by name for updating
|
||||
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
||||
const processedNames = new Set<string>();
|
||||
|
||||
// Update or add servers
|
||||
for (const [name, config] of Object.entries(serversObject)) {
|
||||
const serverType = (config.type as ServerType) || 'stdio';
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name,
|
||||
type: serverType,
|
||||
description: (config.description as string) || undefined,
|
||||
enabled: config.enabled !== false,
|
||||
};
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
serverData.command = config.command as string;
|
||||
if (Array.isArray(config.args)) {
|
||||
serverData.args = config.args as string[];
|
||||
}
|
||||
if (typeof config.env === 'object' && config.env !== null) {
|
||||
serverData.env = config.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
serverData.url = config.url as string;
|
||||
if (typeof config.headers === 'object' && config.headers !== null) {
|
||||
serverData.headers = config.headers as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = existingByName.get(name);
|
||||
if (existing) {
|
||||
updateMCPServer(existing.id, serverData);
|
||||
} else {
|
||||
addMCPServer(serverData);
|
||||
}
|
||||
processedNames.add(name);
|
||||
}
|
||||
|
||||
// Remove servers that are no longer in the JSON
|
||||
for (const server of mcpServers) {
|
||||
if (!processedNames.has(server.name)) {
|
||||
removeMCPServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSaveGlobalJsonEdit = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(globalJsonValue);
|
||||
|
||||
// Support both formats
|
||||
// Support both formats:
|
||||
// 1. Array format (new, with IDs): { mcpServers: [...] } or [...]
|
||||
// 2. Object format (legacy Claude Desktop): { mcpServers: {...} } or {...}
|
||||
const servers = parsed.mcpServers || parsed;
|
||||
|
||||
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||
toast.error('Invalid format: expected object with server configurations');
|
||||
let success: boolean;
|
||||
if (Array.isArray(servers)) {
|
||||
// Array format - supports ID matching for renames
|
||||
success = await handleSaveGlobalJsonArray(servers);
|
||||
} else if (typeof servers === 'object' && servers !== null) {
|
||||
// Object format - legacy Claude Desktop compatibility
|
||||
success = await handleSaveGlobalJsonObject(servers);
|
||||
} else {
|
||||
toast.error('Invalid format: expected array or object with server configurations');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all servers first
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
toast.error(`Invalid config for "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = config as Record<string, unknown>;
|
||||
const serverType = (serverConfig.type as string) || 'stdio';
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
|
||||
toast.error(`Command is required for "${name}" (stdio)`);
|
||||
return;
|
||||
}
|
||||
} else if (serverType === 'sse' || serverType === 'http') {
|
||||
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
|
||||
toast.error(`URL is required for "${name}" (${serverType})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of existing servers by name for updating
|
||||
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
||||
const processedNames = new Set<string>();
|
||||
|
||||
// Update or add servers
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
const serverConfig = config as Record<string, unknown>;
|
||||
const serverType = (serverConfig.type as ServerType) || 'stdio';
|
||||
|
||||
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||
name,
|
||||
type: serverType,
|
||||
description: (serverConfig.description as string) || undefined,
|
||||
enabled: serverConfig.enabled !== false,
|
||||
};
|
||||
|
||||
if (serverType === 'stdio') {
|
||||
serverData.command = serverConfig.command as string;
|
||||
if (Array.isArray(serverConfig.args)) {
|
||||
serverData.args = serverConfig.args as string[];
|
||||
}
|
||||
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||
serverData.env = serverConfig.env as Record<string, string>;
|
||||
}
|
||||
} else {
|
||||
serverData.url = serverConfig.url as string;
|
||||
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = existingByName.get(name);
|
||||
if (existing) {
|
||||
updateMCPServer(existing.id, serverData);
|
||||
} else {
|
||||
addMCPServer(serverData);
|
||||
}
|
||||
processedNames.add(name);
|
||||
const syncSuccess = await syncSettingsToServer();
|
||||
if (!syncSuccess) {
|
||||
toast.error('Failed to save settings to disk');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove servers that are no longer in the JSON
|
||||
for (const server of mcpServers) {
|
||||
if (!processedNames.has(server.name)) {
|
||||
removeMCPServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
await syncSettingsToServer();
|
||||
|
||||
toast.success('MCP servers configuration updated');
|
||||
setIsGlobalJsonEditOpen(false);
|
||||
setGlobalJsonValue('');
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PromptCustomizationSection } from './prompt-customization-section';
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
MessageSquareText,
|
||||
Bot,
|
||||
KanbanSquare,
|
||||
Sparkles,
|
||||
RotateCcw,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_AUTO_MODE_PROMPTS,
|
||||
DEFAULT_AGENT_PROMPTS,
|
||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
interface PromptCustomizationSectionProps {
|
||||
promptCustomization?: PromptCustomization;
|
||||
onPromptCustomizationChange: (customization: PromptCustomization) => void;
|
||||
}
|
||||
|
||||
interface PromptFieldProps {
|
||||
label: string;
|
||||
description: string;
|
||||
defaultValue: string;
|
||||
customValue?: CustomPrompt;
|
||||
onCustomValueChange: (value: CustomPrompt | undefined) => void;
|
||||
critical?: boolean; // Whether this prompt requires strict output format
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dynamic minimum height based on content length
|
||||
* Ensures long prompts have adequate space
|
||||
*/
|
||||
function calculateMinHeight(text: string): string {
|
||||
const lines = text.split('\n').length;
|
||||
const estimatedLines = Math.max(lines, Math.ceil(text.length / 80));
|
||||
|
||||
// Min 120px, scales up for longer content, max 600px
|
||||
const minHeight = Math.min(Math.max(120, estimatedLines * 20), 600);
|
||||
return `${minHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptField Component
|
||||
*
|
||||
* Shows a prompt with a toggle to switch between default and custom mode.
|
||||
* - Toggle OFF: Shows default prompt in read-only mode, custom value is preserved but not used
|
||||
* - Toggle ON: Allows editing, custom value is used instead of default
|
||||
*
|
||||
* IMPORTANT: Custom value is ALWAYS preserved, even when toggle is OFF.
|
||||
* This prevents users from losing their work when temporarily switching to default.
|
||||
*/
|
||||
function PromptField({
|
||||
label,
|
||||
description,
|
||||
defaultValue,
|
||||
customValue,
|
||||
onCustomValueChange,
|
||||
critical = false,
|
||||
}: PromptFieldProps) {
|
||||
const isEnabled = customValue?.enabled ?? false;
|
||||
const displayValue = isEnabled ? (customValue?.value ?? defaultValue) : defaultValue;
|
||||
const minHeight = calculateMinHeight(displayValue);
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
// When toggling, preserve the existing custom value if it exists,
|
||||
// otherwise initialize with the default value.
|
||||
const value = customValue?.value ?? defaultValue;
|
||||
onCustomValueChange({ value, enabled });
|
||||
};
|
||||
|
||||
const handleTextChange = (newValue: string) => {
|
||||
// Only allow editing when enabled
|
||||
if (isEnabled) {
|
||||
onCustomValueChange({ value: newValue, enabled: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{critical && isEnabled && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-amber-500">Critical Prompt</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This prompt requires a specific output format. Changing it incorrectly may break
|
||||
functionality. Only modify if you understand the expected structure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={label} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{isEnabled ? 'Custom' : 'Default'}</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
className="data-[state=checked]:bg-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
id={label}
|
||||
value={displayValue}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
readOnly={!isEnabled}
|
||||
style={{ minHeight }}
|
||||
className={cn(
|
||||
'font-mono text-xs resize-y',
|
||||
!isEnabled && 'cursor-not-allowed bg-muted/50 text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptCustomizationSection Component
|
||||
*
|
||||
* Allows users to customize AI prompts for different parts of the application:
|
||||
* - Auto Mode (feature implementation)
|
||||
* - Agent Runner (interactive chat)
|
||||
* - Backlog Plan (Kanban planning)
|
||||
* - Enhancement (feature description improvement)
|
||||
*/
|
||||
export function PromptCustomizationSection({
|
||||
promptCustomization = {},
|
||||
onPromptCustomizationChange,
|
||||
}: PromptCustomizationSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState('auto-mode');
|
||||
|
||||
const updatePrompt = <T extends keyof PromptCustomization>(
|
||||
category: T,
|
||||
field: keyof NonNullable<PromptCustomization[T]>,
|
||||
value: CustomPrompt | undefined
|
||||
) => {
|
||||
const updated = {
|
||||
...promptCustomization,
|
||||
[category]: {
|
||||
...promptCustomization[category],
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
onPromptCustomizationChange(updated);
|
||||
};
|
||||
|
||||
const resetToDefaults = (category: keyof PromptCustomization) => {
|
||||
const updated = {
|
||||
...promptCustomization,
|
||||
[category]: {},
|
||||
};
|
||||
onPromptCustomizationChange(updated);
|
||||
};
|
||||
|
||||
const resetAllToDefaults = () => {
|
||||
onPromptCustomizationChange({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
data-testid="prompt-customization-section"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<MessageSquareText className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Prompt Customization
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={resetAllToDefaults} className="gap-2">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize AI prompts for Auto Mode, Agent Runner, and other features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="px-6 pt-6">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
|
||||
<Info className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground font-medium">How to Customize Prompts</p>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Toggle the switch to enable custom mode and edit the prompt. When disabled, the
|
||||
default built-in prompt is used. You can use the default as a starting point by
|
||||
enabling the toggle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-4 w-full">
|
||||
<TabsTrigger value="auto-mode" className="gap-2">
|
||||
<Bot className="w-4 h-4" />
|
||||
Auto Mode
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent" className="gap-2">
|
||||
<MessageSquareText className="w-4 h-4" />
|
||||
Agent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backlog-plan" className="gap-2">
|
||||
<KanbanSquare className="w-4 h-4" />
|
||||
Backlog Plan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="enhancement" className="gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Enhancement
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Auto Mode Tab */}
|
||||
<TabsContent value="auto-mode" className="space-y-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Auto Mode Prompts</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetToDefaults('autoMode')}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner for Auto Mode */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
|
||||
<Info className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground font-medium">Planning Mode Markers</p>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Planning prompts use special markers like{' '}
|
||||
<code className="px-1 py-0.5 rounded bg-muted text-xs">[PLAN_GENERATED]</code> and{' '}
|
||||
<code className="px-1 py-0.5 rounded bg-muted text-xs">[SPEC_GENERATED]</code> to
|
||||
control the Auto Mode workflow. These markers must be preserved for proper
|
||||
functionality.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PromptField
|
||||
label="Planning: Lite Mode"
|
||||
description="Quick planning outline without approval requirement"
|
||||
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningLite}
|
||||
customValue={promptCustomization?.autoMode?.planningLite}
|
||||
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningLite', value)}
|
||||
critical={true}
|
||||
/>
|
||||
|
||||
<PromptField
|
||||
label="Planning: Lite with Approval"
|
||||
description="Planning outline that waits for user approval"
|
||||
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningLiteWithApproval}
|
||||
customValue={promptCustomization?.autoMode?.planningLiteWithApproval}
|
||||
onCustomValueChange={(value) =>
|
||||
updatePrompt('autoMode', 'planningLiteWithApproval', value)
|
||||
}
|
||||
critical={true}
|
||||
/>
|
||||
|
||||
<PromptField
|
||||
label="Planning: Spec Mode"
|
||||
description="Detailed specification with task breakdown"
|
||||
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningSpec}
|
||||
customValue={promptCustomization?.autoMode?.planningSpec}
|
||||
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningSpec', value)}
|
||||
critical={true}
|
||||
/>
|
||||
|
||||
<PromptField
|
||||
label="Planning: Full SDD Mode"
|
||||
description="Comprehensive Software Design Document with phased implementation"
|
||||
defaultValue={DEFAULT_AUTO_MODE_PROMPTS.planningFull}
|
||||
customValue={promptCustomization?.autoMode?.planningFull}
|
||||
onCustomValueChange={(value) => updatePrompt('autoMode', 'planningFull', value)}
|
||||
critical={true}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Agent Tab */}
|
||||
<TabsContent value="agent" className="space-y-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Agent Runner Prompts</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetToDefaults('agent')}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PromptField
|
||||
label="System Prompt"
|
||||
description="Defines the AI's role and behavior in interactive chat sessions"
|
||||
defaultValue={DEFAULT_AGENT_PROMPTS.systemPrompt}
|
||||
customValue={promptCustomization?.agent?.systemPrompt}
|
||||
onCustomValueChange={(value) => updatePrompt('agent', 'systemPrompt', value)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Backlog Plan Tab */}
|
||||
<TabsContent value="backlog-plan" className="space-y-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Backlog Planning Prompts</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetToDefaults('backlogPlan')}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Critical Warning for Backlog Plan */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground font-medium">Warning: Critical Prompts</p>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
Backlog plan prompts require a strict JSON output format. Modifying these prompts
|
||||
incorrectly can break the backlog planning feature and potentially corrupt your
|
||||
feature data. Only customize if you fully understand the expected output
|
||||
structure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PromptField
|
||||
label="System Prompt"
|
||||
description="Defines how the AI modifies the feature backlog (Plan button on Kanban board)"
|
||||
defaultValue={DEFAULT_BACKLOG_PLAN_PROMPTS.systemPrompt}
|
||||
customValue={promptCustomization?.backlogPlan?.systemPrompt}
|
||||
onCustomValueChange={(value) => updatePrompt('backlogPlan', 'systemPrompt', value)}
|
||||
critical={true}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Enhancement Tab */}
|
||||
<TabsContent value="enhancement" className="space-y-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Enhancement Prompts</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetToDefaults('enhancement')}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PromptField
|
||||
label="Improve Mode"
|
||||
description="Transform vague requests into clear, actionable tasks"
|
||||
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.improveSystemPrompt}
|
||||
customValue={promptCustomization?.enhancement?.improveSystemPrompt}
|
||||
onCustomValueChange={(value) =>
|
||||
updatePrompt('enhancement', 'improveSystemPrompt', value)
|
||||
}
|
||||
/>
|
||||
|
||||
<PromptField
|
||||
label="Technical Mode"
|
||||
description="Add implementation details and technical specifications"
|
||||
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.technicalSystemPrompt}
|
||||
customValue={promptCustomization?.enhancement?.technicalSystemPrompt}
|
||||
onCustomValueChange={(value) =>
|
||||
updatePrompt('enhancement', 'technicalSystemPrompt', value)
|
||||
}
|
||||
/>
|
||||
|
||||
<PromptField
|
||||
label="Simplify Mode"
|
||||
description="Make verbose descriptions concise and focused"
|
||||
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.simplifySystemPrompt}
|
||||
customValue={promptCustomization?.enhancement?.simplifySystemPrompt}
|
||||
onCustomValueChange={(value) =>
|
||||
updatePrompt('enhancement', 'simplifySystemPrompt', value)
|
||||
}
|
||||
/>
|
||||
|
||||
<PromptField
|
||||
label="Acceptance Criteria Mode"
|
||||
description="Add testable acceptance criteria to descriptions"
|
||||
defaultValue={DEFAULT_ENHANCEMENT_PROMPTS.acceptanceSystemPrompt}
|
||||
customValue={promptCustomization?.enhancement?.acceptanceSystemPrompt}
|
||||
onCustomValueChange={(value) =>
|
||||
updatePrompt('enhancement', 'acceptanceSystemPrompt', value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
defaultDropAnimationSideEffects,
|
||||
} from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
|
||||
import { getApiKey } from '@/lib/http-api-client';
|
||||
|
||||
interface TerminalStatus {
|
||||
enabled: boolean;
|
||||
@@ -304,16 +306,13 @@ export function TerminalView() {
|
||||
await Promise.allSettled(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
} catch (err) {
|
||||
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}, [collectAllSessionIds, terminalState.authToken, serverUrl]);
|
||||
}, [collectAllSessionIds, terminalState.authToken]);
|
||||
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
|
||||
|
||||
// Helper to check if terminal creation should be debounced
|
||||
@@ -434,9 +433,10 @@ export function TerminalView() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`${serverUrl}/api/terminal/status`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
|
||||
'/api/terminal/status'
|
||||
);
|
||||
if (data.success && data.data) {
|
||||
setStatus(data.data);
|
||||
if (!data.data.passwordRequired) {
|
||||
setTerminalUnlocked(true);
|
||||
@@ -450,7 +450,7 @@ export function TerminalView() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverUrl, setTerminalUnlocked]);
|
||||
}, [setTerminalUnlocked]);
|
||||
|
||||
// Fetch server session settings
|
||||
const fetchServerSettings = useCallback(async () => {
|
||||
@@ -460,15 +460,17 @@ export function TerminalView() {
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const data = await apiGet<{
|
||||
success: boolean;
|
||||
data?: { currentSessions: number; maxSessions: number };
|
||||
}>('/api/terminal/settings', { headers });
|
||||
if (data.success && data.data) {
|
||||
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Terminal] Failed to fetch server settings:', err);
|
||||
}
|
||||
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]);
|
||||
}, [terminalState.isUnlocked, terminalState.authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
@@ -483,22 +485,20 @@ export function TerminalView() {
|
||||
const sessionIds = collectAllSessionIds();
|
||||
if (sessionIds.length === 0) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
// Try to use the bulk delete endpoint if available, otherwise delete individually
|
||||
// Using sendBeacon for reliability during page unload
|
||||
// Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
|
||||
sessionIds.forEach((sessionId) => {
|
||||
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
|
||||
// sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest
|
||||
// which is more reliable during page unload than fetch
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('DELETE', url, false); // synchronous
|
||||
xhr.withCredentials = true; // Include cookies for session auth
|
||||
// Add API auth header
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
xhr.setRequestHeader('X-API-Key', apiKey);
|
||||
}
|
||||
// Add terminal-specific auth
|
||||
if (terminalState.authToken) {
|
||||
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
|
||||
}
|
||||
@@ -593,9 +593,7 @@ export function TerminalView() {
|
||||
let reconnectedSessions = 0;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
// Get fresh auth token from store
|
||||
const authToken = useAppStore.getState().terminalState.authToken;
|
||||
if (authToken) {
|
||||
@@ -605,11 +603,9 @@ export function TerminalView() {
|
||||
// Helper to check if a session still exists on server
|
||||
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'GET',
|
||||
const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.success === true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -619,17 +615,12 @@ export function TerminalView() {
|
||||
// Helper to create a new terminal session
|
||||
const createSession = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentPath,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.success ? data.data.id : null;
|
||||
const data = await apiPost<{ success: boolean; data?: { id: string } }>(
|
||||
'/api/terminal/sessions',
|
||||
{ cwd: currentPath, cols: 80, rows: 24 },
|
||||
{ headers }
|
||||
);
|
||||
return data.success && data.data ? data.data.id : null;
|
||||
} catch (err) {
|
||||
console.error('[Terminal] Failed to create terminal session:', err);
|
||||
return null;
|
||||
@@ -801,14 +792,12 @@ export function TerminalView() {
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
|
||||
'/api/terminal/auth',
|
||||
{ password }
|
||||
);
|
||||
|
||||
if (data.success) {
|
||||
if (data.success && data.data) {
|
||||
setTerminalUnlocked(true, data.data.token);
|
||||
setPassword('');
|
||||
} else {
|
||||
@@ -833,21 +822,14 @@ export function TerminalView() {
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: 'POST',
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentProject?.path || undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -892,21 +874,14 @@ export function TerminalView() {
|
||||
|
||||
const tabId = addTerminalTab();
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: 'POST',
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentProject?.path || undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -959,10 +934,7 @@ export function TerminalView() {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
|
||||
// Always remove from UI - even if server says 404 (session may have already exited)
|
||||
removeTerminalFromLayout(sessionId);
|
||||
@@ -1008,10 +980,7 @@ export function TerminalView() {
|
||||
await Promise.all(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
|
||||
} catch (err) {
|
||||
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '@/config/terminal-themes';
|
||||
import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getApiKey, getSessionToken } from '@/lib/http-api-client';
|
||||
|
||||
// Font size constraints
|
||||
const MIN_FONT_SIZE = 8;
|
||||
@@ -485,6 +486,40 @@ export function TerminalPanel({
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const wsUrl = serverUrl.replace(/^http/, 'ws');
|
||||
|
||||
// Fetch a short-lived WebSocket token for secure authentication
|
||||
const fetchWsToken = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[Terminal] Failed to fetch wsToken:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.token) {
|
||||
return data.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[Terminal] Error fetching wsToken:', error);
|
||||
return null;
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
// Draggable - only the drag handle triggers drag
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
@@ -939,9 +974,24 @@ export function TerminalPanel({
|
||||
const terminal = xtermRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
const connect = () => {
|
||||
// Build WebSocket URL with token
|
||||
const connect = async () => {
|
||||
// Build WebSocket URL with auth params
|
||||
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
|
||||
|
||||
// Add API key for Electron mode auth
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
url += `&apiKey=${encodeURIComponent(apiKey)}`;
|
||||
} else {
|
||||
// In web mode, fetch a short-lived wsToken for secure authentication
|
||||
const wsToken = await fetchWsToken();
|
||||
if (wsToken) {
|
||||
url += `&wsToken=${encodeURIComponent(wsToken)}`;
|
||||
}
|
||||
// Cookies are also sent automatically with same-origin WebSocket
|
||||
}
|
||||
|
||||
// Add terminal password token if required
|
||||
if (authToken) {
|
||||
url += `&token=${encodeURIComponent(authToken)}`;
|
||||
}
|
||||
@@ -1154,7 +1204,7 @@ export function TerminalPanel({
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, authToken, wsUrl, isTerminalReady]);
|
||||
}, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]);
|
||||
|
||||
// Handle resize with debouncing
|
||||
const handleResize = useCallback(() => {
|
||||
|
||||
@@ -189,13 +189,9 @@ export function useSettingsMigration(): MigrationState {
|
||||
* Call this when important global settings change (theme, UI preferences, profiles, etc.)
|
||||
* Safe to call from store subscribers or change handlers.
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncSettingsToServer(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const automakerStorage = getItem('automaker-storage');
|
||||
@@ -231,6 +227,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
mcpServers: state.mcpServers,
|
||||
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
|
||||
promptCustomization: state.promptCustomization,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
projectHistory: state.projectHistory,
|
||||
@@ -255,8 +252,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
* Call this when API keys are added or updated in settings UI.
|
||||
* Only requires providing the keys that have changed.
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @param apiKeys - Partial credential object with optional anthropic, google, openai keys
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
@@ -265,8 +260,6 @@ export async function syncCredentialsToServer(apiKeys: {
|
||||
google?: string;
|
||||
openai?: string;
|
||||
}): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.updateCredentials({ apiKeys });
|
||||
@@ -287,7 +280,6 @@ export async function syncCredentialsToServer(apiKeys: {
|
||||
* Supports partial updates - only include fields that have changed.
|
||||
*
|
||||
* Call this when project settings are modified in the board or settings UI.
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param updates - Partial ProjectSettings with optional theme, worktree, and board settings
|
||||
@@ -309,8 +301,6 @@ export async function syncProjectSettingsToServer(
|
||||
}>;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.updateProject(projectPath, updates);
|
||||
@@ -328,13 +318,9 @@ export async function syncProjectSettingsToServer(
|
||||
* mcpServers state. Useful when settings were modified externally
|
||||
* (e.g., by editing the settings.json file directly).
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @returns Promise resolving to true if load succeeded, false otherwise
|
||||
*/
|
||||
export async function loadMCPServersFromServer(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.getGlobal();
|
||||
|
||||
161
apps/ui/src/lib/api-fetch.ts
Normal file
161
apps/ui/src/lib/api-fetch.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Authenticated fetch utility
|
||||
*
|
||||
* Provides a wrapper around fetch that automatically includes:
|
||||
* - X-API-Key header (for Electron mode)
|
||||
* - X-Session-Token header (for web mode with explicit token)
|
||||
* - credentials: 'include' (fallback for web mode session cookies)
|
||||
*
|
||||
* Use this instead of raw fetch() for all authenticated API calls.
|
||||
*/
|
||||
|
||||
import { getApiKey, getSessionToken } from './http-api-client';
|
||||
|
||||
// Server URL - configurable via environment variable
|
||||
const getServerUrl = (): string => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
}
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
export interface ApiFetchOptions extends Omit<RequestInit, 'method' | 'headers' | 'body'> {
|
||||
/** Additional headers to include (merged with auth headers) */
|
||||
headers?: Record<string, string>;
|
||||
/** Request body - will be JSON stringified if object */
|
||||
body?: unknown;
|
||||
/** Skip authentication headers (for public endpoints like /api/health) */
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for an authenticated request
|
||||
*/
|
||||
export function getAuthHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
// Electron mode: use API key
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Web mode: use session token if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated fetch request to the API
|
||||
*
|
||||
* @param endpoint - API endpoint (e.g., '/api/fs/browse')
|
||||
* @param method - HTTP method
|
||||
* @param options - Additional options
|
||||
* @returns Response from fetch
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Simple GET
|
||||
* const response = await apiFetch('/api/terminal/status', 'GET');
|
||||
*
|
||||
* // POST with body
|
||||
* const response = await apiFetch('/api/fs/browse', 'POST', {
|
||||
* body: { dirPath: '/home/user' }
|
||||
* });
|
||||
*
|
||||
* // With additional headers
|
||||
* const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
* headers: { 'X-Terminal-Token': token },
|
||||
* body: { cwd: '/home/user' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function apiFetch(
|
||||
endpoint: string,
|
||||
method: HttpMethod = 'GET',
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
||||
|
||||
const headers = skipAuth
|
||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||
: getAuthHeaders(additionalHeaders);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
...restOptions,
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${getServerUrl()}${endpoint}`;
|
||||
return fetch(url, fetchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated GET request
|
||||
*/
|
||||
export async function apiGet<T>(
|
||||
endpoint: string,
|
||||
options: Omit<ApiFetchOptions, 'body'> = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'GET', options);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated POST request
|
||||
*/
|
||||
export async function apiPost<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'POST', { ...options, body });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated PUT request
|
||||
*/
|
||||
export async function apiPut<T>(
|
||||
endpoint: string,
|
||||
body?: unknown,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'PUT', { ...options, body });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated DELETE request
|
||||
*/
|
||||
export async function apiDelete<T>(endpoint: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
const response = await apiFetch(endpoint, 'DELETE', options);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated DELETE request (returns raw response for status checking)
|
||||
*/
|
||||
export async function apiDeleteRaw(
|
||||
endpoint: string,
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
return apiFetch(endpoint, 'DELETE', options);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
AgentModel,
|
||||
GitHubComment,
|
||||
IssueCommentsResult,
|
||||
} from '@automaker/types';
|
||||
import { getJSON, setJSON, removeItem } from './storage';
|
||||
|
||||
@@ -24,6 +26,8 @@ export type {
|
||||
IssueValidationResponse,
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
GitHubComment,
|
||||
IssueCommentsResult,
|
||||
};
|
||||
|
||||
export interface FileEntry {
|
||||
@@ -102,6 +106,8 @@ export interface RunningAgent {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RunningAgentsResult {
|
||||
@@ -234,6 +240,19 @@ export interface GitHubAPI {
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
/** Subscribe to validation events */
|
||||
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void;
|
||||
/** Fetch comments for a specific issue */
|
||||
getIssueComments: (
|
||||
projectPath: string,
|
||||
issueNumber: number,
|
||||
cursor?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
comments?: GitHubComment[];
|
||||
totalCount?: number;
|
||||
hasNextPage?: boolean;
|
||||
endCursor?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Feature Suggestions types
|
||||
@@ -412,6 +431,7 @@ export interface SaveImageResult {
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
openDirectory: () => Promise<DialogResult>;
|
||||
openFile: (options?: object) => Promise<DialogResult>;
|
||||
@@ -2670,6 +2690,8 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
|
||||
projectPath: '/mock/project',
|
||||
projectName: 'Mock Project',
|
||||
isAutoMode: mockAutoModeRunning,
|
||||
title: `Mock Feature Title for ${featureId}`,
|
||||
description: 'This is a mock feature description for testing purposes.',
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
@@ -2786,6 +2808,15 @@ function createMockGitHubAPI(): GitHubAPI {
|
||||
mockValidationCallbacks = mockValidationCallbacks.filter((cb) => cb !== callback);
|
||||
};
|
||||
},
|
||||
getIssueComments: async (projectPath: string, issueNumber: number, cursor?: string) => {
|
||||
console.log('[Mock] Getting issue comments:', { projectPath, issueNumber, cursor });
|
||||
return {
|
||||
success: true,
|
||||
comments: [],
|
||||
totalCount: 0,
|
||||
hasNextPage: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,12 +41,232 @@ const getServerUrl = (): string => {
|
||||
return 'http://localhost:3008';
|
||||
};
|
||||
|
||||
// Get API key from environment variable
|
||||
const getApiKey = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
|
||||
// Cached API key for authentication (Electron mode only)
|
||||
let cachedApiKey: string | null = null;
|
||||
let apiKeyInitialized = false;
|
||||
|
||||
// Cached session token for authentication (Web mode - explicit header auth)
|
||||
let cachedSessionToken: string | null = null;
|
||||
|
||||
// Get API key for Electron mode (returns cached value after initialization)
|
||||
// Exported for use in WebSocket connections that need auth
|
||||
export const getApiKey = (): string | null => cachedApiKey;
|
||||
|
||||
// Get session token for Web mode (returns cached value after login or token fetch)
|
||||
export const getSessionToken = (): string | null => cachedSessionToken;
|
||||
|
||||
// Set session token (called after login or token fetch)
|
||||
export const setSessionToken = (token: string | null): void => {
|
||||
cachedSessionToken = token;
|
||||
};
|
||||
|
||||
// Clear session token (called on logout)
|
||||
export const clearSessionToken = (): void => {
|
||||
cachedSessionToken = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we're running in Electron mode
|
||||
*/
|
||||
export const isElectronMode = (): boolean => {
|
||||
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize API key for Electron mode authentication.
|
||||
* In web mode, authentication uses HTTP-only cookies instead.
|
||||
*
|
||||
* This should be called early in app initialization.
|
||||
*/
|
||||
export const initApiKey = async (): Promise<void> => {
|
||||
if (apiKeyInitialized) return;
|
||||
apiKeyInitialized = true;
|
||||
|
||||
// Only Electron mode uses API key header auth
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
|
||||
try {
|
||||
cachedApiKey = await window.electronAPI.getApiKey();
|
||||
if (cachedApiKey) {
|
||||
console.log('[HTTP Client] Using API key from Electron');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// In web mode, authentication is handled via HTTP-only cookies
|
||||
console.log('[HTTP Client] Web mode - using cookie-based authentication');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check authentication status with the server
|
||||
*/
|
||||
export const checkAuthStatus = async (): Promise<{
|
||||
authenticated: boolean;
|
||||
required: boolean;
|
||||
}> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||
credentials: 'include',
|
||||
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
return {
|
||||
authenticated: data.authenticated ?? false,
|
||||
required: data.required ?? true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Failed to check auth status:', error);
|
||||
return { authenticated: false, required: true };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Login with API key (for web mode)
|
||||
* After login succeeds, verifies the session is actually working by making
|
||||
* a request to an authenticated endpoint.
|
||||
*/
|
||||
export const login = async (
|
||||
apiKey: string
|
||||
): Promise<{ success: boolean; error?: string; token?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Store the session token if login succeeded
|
||||
if (data.success && data.token) {
|
||||
setSessionToken(data.token);
|
||||
console.log('[HTTP Client] Session token stored after login');
|
||||
|
||||
// Verify the session is actually working by making a request to an authenticated endpoint
|
||||
const verified = await verifySession();
|
||||
if (!verified) {
|
||||
console.error('[HTTP Client] Login appeared successful but session verification failed');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session verification failed. Please try again.',
|
||||
};
|
||||
}
|
||||
console.log('[HTTP Client] Login verified successfully');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Login failed:', error);
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the session cookie is still valid by making a request to an authenticated endpoint.
|
||||
* Note: This does NOT retrieve the session token - on page refresh we rely on cookies alone.
|
||||
* The session token is only available after a fresh login.
|
||||
*/
|
||||
export const fetchSessionToken = async (): Promise<boolean> => {
|
||||
// On page refresh, we can't retrieve the session token (it's stored in HTTP-only cookie).
|
||||
// We just verify the cookie is valid by checking auth status.
|
||||
// The session token is only stored in memory after a fresh login.
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||
credentials: 'include', // Send the session cookie
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('[HTTP Client] Failed to check auth status');
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.authenticated) {
|
||||
console.log('[HTTP Client] Session cookie is valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[HTTP Client] Session cookie is not authenticated');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Failed to check session:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout (for web mode)
|
||||
*/
|
||||
export const logout = async (): Promise<{ success: boolean }> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Clear the cached session token
|
||||
clearSessionToken();
|
||||
console.log('[HTTP Client] Session token cleared on logout');
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Logout failed:', error);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify that the current session is still valid by making a request to an authenticated endpoint.
|
||||
* If the session has expired or is invalid, clears the session and returns false.
|
||||
* This should be called:
|
||||
* 1. After login to verify the cookie was set correctly
|
||||
* 2. On app load to verify the session hasn't expired
|
||||
*/
|
||||
export const verifySession = async (): Promise<boolean> => {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add session token header if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
// Make a request to an authenticated endpoint to verify the session
|
||||
// We use /api/settings/status as it requires authentication and is lightweight
|
||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Check for authentication errors
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('[HTTP Client] Session verification failed - session expired or invalid');
|
||||
// Clear the session since it's no longer valid
|
||||
clearSessionToken();
|
||||
// Try to clear the cookie via logout (fire and forget)
|
||||
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[HTTP Client] Session verification failed with status:', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[HTTP Client] Session verified successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Session verification error:', error);
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EventType =
|
||||
@@ -79,6 +299,44 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a short-lived WebSocket token from the server
|
||||
* Used for secure WebSocket authentication without exposing session tokens in URLs
|
||||
*/
|
||||
private async fetchWsToken(): Promise<string | null> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add session token header if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[HttpApiClient] Failed to fetch wsToken:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.token) {
|
||||
return data.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[HttpApiClient] Error fetching wsToken:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
@@ -86,8 +344,37 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
// In Electron mode, use API key directly
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// In web mode, fetch a short-lived wsToken first
|
||||
this.fetchWsToken()
|
||||
.then((wsToken) => {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
if (wsToken) {
|
||||
this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`);
|
||||
} else {
|
||||
// Fallback: try connecting without token (will fail if not authenticated)
|
||||
console.warn('[HttpApiClient] No wsToken available, attempting connection anyway');
|
||||
this.establishWebSocket(wsUrl);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HttpApiClient] Failed to prepare WebSocket connection:', error);
|
||||
this.isConnecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish the actual WebSocket connection
|
||||
*/
|
||||
private establishWebSocket(wsUrl: string): void {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
@@ -155,10 +442,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Electron mode: use API key
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Web mode: use session token if available
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
headers['X-Session-Token'] = sessionToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -166,14 +463,17 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -181,6 +481,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
@@ -190,6 +491,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
@@ -766,6 +1068,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }),
|
||||
onValidationEvent: (callback: (event: IssueValidationEvent) => void) =>
|
||||
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
|
||||
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
|
||||
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
|
||||
};
|
||||
|
||||
// Workspace API
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
@@ -59,6 +60,46 @@ interface WindowBounds {
|
||||
// Debounce timer for saving window bounds
|
||||
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// API key for CSRF protection
|
||||
let apiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Get path to API key file in user data directory
|
||||
*/
|
||||
function getApiKeyPath(): string {
|
||||
return path.join(app.getPath('userData'), '.api-key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - load from file or generate new one.
|
||||
* This key is passed to the server for CSRF protection.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
const keyPath = getApiKeyPath();
|
||||
try {
|
||||
if (fs.existsSync(keyPath)) {
|
||||
const key = fs.readFileSync(keyPath, 'utf-8').trim();
|
||||
if (key) {
|
||||
apiKey = key;
|
||||
console.log('[Electron] Loaded existing API key');
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Error reading API key:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
apiKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Electron] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to save API key:', error);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
*/
|
||||
@@ -331,6 +372,8 @@ async function startServer(): Promise<void> {
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath('userData'),
|
||||
NODE_PATH: serverNodeModules,
|
||||
// Pass API key to server for CSRF protection
|
||||
AUTOMAKER_API_KEY: apiKey!,
|
||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||
// If not set, server will allow access to all paths
|
||||
...(process.env.ALLOWED_ROOT_DIRECTORY && {
|
||||
@@ -509,6 +552,9 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate or load API key for CSRF protection (before starting server)
|
||||
ensureApiKey();
|
||||
|
||||
try {
|
||||
// Start static file server in production
|
||||
if (app.isPackaged) {
|
||||
@@ -666,6 +712,11 @@ ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
|
||||
// Get API key for authentication
|
||||
ipcMain.handle('auth:getApiKey', () => {
|
||||
return apiKey;
|
||||
});
|
||||
|
||||
// Window management - update minimum width based on sidebar state
|
||||
// Now uses a fixed small minimum since horizontal scrolling handles overflow
|
||||
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
|
||||
|
||||
@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
|
||||
|
||||
// Get API key for authentication
|
||||
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:openDirectory'),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||
|
||||
@@ -22,6 +23,8 @@ function RootLayoutContent() {
|
||||
const [setupHydrated, setSetupHydrated] = useState(
|
||||
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
||||
);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
@@ -70,6 +73,53 @@ function RootLayoutContent() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Initialize authentication
|
||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||
// - Web mode: Uses HTTP-only session cookie
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Initialize API key for Electron mode
|
||||
await initApiKey();
|
||||
|
||||
// In Electron mode, we're always authenticated via header
|
||||
if (isElectronMode()) {
|
||||
setIsAuthenticated(true);
|
||||
setAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// In web mode, verify the session cookie is still valid
|
||||
// by making a request to an authenticated endpoint
|
||||
const isValid = await verifySession();
|
||||
|
||||
if (isValid) {
|
||||
setIsAuthenticated(true);
|
||||
setAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Session is invalid or expired - redirect to login
|
||||
console.log('Session invalid or expired - redirecting to login');
|
||||
setIsAuthenticated(false);
|
||||
setAuthChecked(true);
|
||||
|
||||
if (location.pathname !== '/login') {
|
||||
navigate({ to: '/login' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
setAuthChecked(true);
|
||||
// On error, redirect to login to be safe
|
||||
if (location.pathname !== '/login') {
|
||||
navigate({ to: '/login' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
// Wait for setup store hydration before enforcing routing rules
|
||||
useEffect(() => {
|
||||
if (useSetupStore.persist?.hasHydrated?.()) {
|
||||
@@ -147,8 +197,32 @@ function RootLayoutContent() {
|
||||
}
|
||||
}, [deferredTheme]);
|
||||
|
||||
// Setup view is full-screen without sidebar
|
||||
// Login and setup views are full-screen without sidebar
|
||||
const isSetupRoute = location.pathname === '/setup';
|
||||
const isLoginRoute = location.pathname === '/login';
|
||||
|
||||
// Show login page (full screen, no sidebar)
|
||||
if (isLoginRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for auth check before rendering protected routes (web mode only)
|
||||
if (!isElectronMode() && !authChecked) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated (web mode)
|
||||
if (!isElectronMode() && !isAuthenticated) {
|
||||
return null; // Will redirect via useEffect
|
||||
}
|
||||
|
||||
if (isSetupRoute) {
|
||||
return (
|
||||
|
||||
6
apps/ui/src/routes/login.tsx
Normal file
6
apps/ui/src/routes/login.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { LoginView } from '@/components/views/login-view';
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginView,
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
FeatureStatusWithPipeline,
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
PromptCustomization,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Re-export ThemeMode for convenience
|
||||
@@ -492,6 +493,9 @@ export interface AppState {
|
||||
mcpAutoApproveTools: boolean; // Auto-approve MCP tool calls without permission prompts
|
||||
mcpUnrestrictedTools: boolean; // Allow unrestricted tools when MCP servers are enabled
|
||||
|
||||
// Prompt Customization
|
||||
promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
@@ -774,6 +778,9 @@ export interface AppActions {
|
||||
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
|
||||
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||
@@ -972,6 +979,7 @@ const initialState: AppState = {
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
||||
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
||||
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
|
||||
aiProfiles: DEFAULT_AI_PROFILES,
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
@@ -1628,6 +1636,14 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: async (customization) => {
|
||||
set({ promptCustomization: customization });
|
||||
// Sync to server settings file
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile) => {
|
||||
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
@@ -2909,6 +2925,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
mcpServers: state.mcpServers,
|
||||
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
|
||||
// Prompt customization
|
||||
promptCustomization: state.promptCustomization,
|
||||
// Profiles and sessions
|
||||
aiProfiles: state.aiProfiles,
|
||||
chatSessions: state.chatSessions,
|
||||
|
||||
@@ -176,6 +176,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
isFirstRun: state.isFirstRun,
|
||||
setupComplete: state.setupComplete,
|
||||
skipClaudeSetup: state.skipClaudeSetup,
|
||||
claudeAuthStatus: state.claudeAuthStatus,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
1
apps/ui/src/types/electron.d.ts
vendored
1
apps/ui/src/types/electron.d.ts
vendored
@@ -464,6 +464,7 @@ export interface AutoModeAPI {
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Dialog APIs
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
clickNewSessionButton,
|
||||
waitForNewSession,
|
||||
countSessionItems,
|
||||
authenticateForTests,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('agent-session-test');
|
||||
@@ -61,6 +62,7 @@ test.describe('Agent Chat Session', () => {
|
||||
test('should start a new agent chat session', async ({ page }) => {
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
navigateToContext,
|
||||
waitForContextFile,
|
||||
waitForNetworkIdle,
|
||||
authenticateForTests,
|
||||
} from '../utils';
|
||||
|
||||
test.describe('Add Context Image', () => {
|
||||
@@ -117,13 +118,26 @@ test.describe('Add Context Image', () => {
|
||||
|
||||
test('should import an image file to context', async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Check if we're on the login screen and authenticate if needed
|
||||
const loginInput = page.locator('input[type="password"][placeholder*="API key"]');
|
||||
const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
if (isLoginScreen) {
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
await loginInput.fill(apiKey);
|
||||
await page.locator('button:has-text("Login")').click();
|
||||
await page.waitForURL('**/', { timeout: 5000 });
|
||||
await waitForNetworkIdle(page);
|
||||
}
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Get the file input element and set the file
|
||||
// Wait for the file input to be attached to the DOM before setting files
|
||||
const fileInput = page.locator('[data-testid="file-import-input"]');
|
||||
await expect(fileInput).toBeAttached({ timeout: 10000 });
|
||||
|
||||
// Use setInputFiles to upload the image
|
||||
await fileInput.setInputFiles(testImagePath);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getByTestId,
|
||||
waitForNetworkIdle,
|
||||
getContextEditorContent,
|
||||
authenticateForTests,
|
||||
} from '../utils';
|
||||
|
||||
test.describe('Context File Management', () => {
|
||||
@@ -31,6 +32,7 @@ test.describe('Context File Management', () => {
|
||||
|
||||
test('should create a new markdown context file', async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
clickElement,
|
||||
fillInput,
|
||||
waitForNetworkIdle,
|
||||
authenticateForTests,
|
||||
} from '../utils';
|
||||
|
||||
test.describe('Delete Context File', () => {
|
||||
@@ -33,6 +34,7 @@ test.describe('Delete Context File', () => {
|
||||
const fileName = 'to-delete.md';
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
clickAddFeature,
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('feature-backlog-test');
|
||||
@@ -61,7 +63,11 @@ test.describe('Feature Backlog', () => {
|
||||
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
clickElement,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('edit-feature-test');
|
||||
@@ -63,7 +65,10 @@ test.describe('Edit Feature', () => {
|
||||
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
setupRealProject,
|
||||
waitForNetworkIdle,
|
||||
getKanbanColumn,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
||||
@@ -83,7 +85,10 @@ test.describe('Feature Manual Review Flow', () => {
|
||||
test('should manually verify a feature in waiting_approval column', async ({ page }) => {
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
isSkipTestsBadgeVisible,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('skip-tests-toggle-test');
|
||||
@@ -65,7 +67,10 @@ test.describe('Feature Skip Tests Badge', () => {
|
||||
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
createTempDirPath,
|
||||
setupProjectWithPath,
|
||||
waitForBoardView,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('worktree-tests');
|
||||
@@ -47,7 +49,10 @@ test.describe('Worktree Integration', () => {
|
||||
|
||||
test('should display worktree selector with main branch', async ({ page }) => {
|
||||
await setupProjectWithPath(page, testRepo.path);
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
|
||||
12
apps/ui/tests/global-setup.ts
Normal file
12
apps/ui/tests/global-setup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Global setup for all e2e tests
|
||||
* This runs once before all tests start
|
||||
*/
|
||||
|
||||
async function globalSetup() {
|
||||
// Note: Server killing is handled by the pretest script in package.json
|
||||
// GlobalSetup runs AFTER webServer starts, so we can't kill the server here
|
||||
console.log('[GlobalSetup] Setup complete');
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -14,12 +14,17 @@ import {
|
||||
saveProfile,
|
||||
waitForSuccessToast,
|
||||
countCustomProfiles,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
test.describe('AI Profiles', () => {
|
||||
test('should create a new profile', async ({ page }) => {
|
||||
await setupMockProjectWithProfiles(page, { customProfilesCount: 0 });
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
await navigateToProfiles(page);
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils';
|
||||
import {
|
||||
createTempDirPath,
|
||||
cleanupTempDir,
|
||||
setupWelcomeView,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
|
||||
|
||||
@@ -26,8 +32,10 @@ test.describe('Project Creation', () => {
|
||||
const projectName = `test-project-${Date.now()}`;
|
||||
|
||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils';
|
||||
import {
|
||||
createTempDirPath,
|
||||
cleanupTempDir,
|
||||
setupWelcomeView,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
} from '../utils';
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath('open-project-test');
|
||||
@@ -74,8 +80,10 @@ test.describe('Open Project', () => {
|
||||
});
|
||||
|
||||
// Navigate to the app
|
||||
await authenticateForTests(page);
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
|
||||
// Wait for welcome view to be visible
|
||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Page, APIResponse } from '@playwright/test';
|
||||
import { API_ENDPOINTS } from '../core/constants';
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../core/constants';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -270,3 +270,92 @@ export async function apiListBranches(
|
||||
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
|
||||
return new WorktreeApiClient(page).listBranches(worktreePath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Authenticate with the server using an API key
|
||||
* This sets a session cookie that will be used for subsequent requests
|
||||
* Uses browser context to ensure cookies are properly set
|
||||
*/
|
||||
export async function authenticateWithApiKey(page: Page, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
// Ensure we're on a page (needed for cookies to work)
|
||||
const currentUrl = page.url();
|
||||
if (!currentUrl || currentUrl === 'about:blank') {
|
||||
await page.goto('http://localhost:3007', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
// Use browser context fetch to ensure cookies are set in the browser
|
||||
const response = await page.evaluate(
|
||||
async ({ url, apiKey }) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { success: data.success, token: data.token };
|
||||
},
|
||||
{ url: `${API_BASE_URL}/api/auth/login`, apiKey }
|
||||
);
|
||||
|
||||
if (response.success && response.token) {
|
||||
// Manually set the cookie in the browser context
|
||||
// The server sets a cookie named 'automaker_session' (see SESSION_COOKIE_NAME in auth.ts)
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'automaker_session',
|
||||
value: response.token,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
|
||||
// Verify the session is working by polling auth status
|
||||
// This replaces arbitrary timeout with actual condition check
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
while (attempts < maxAttempts) {
|
||||
const statusResponse = await page.evaluate(
|
||||
async ({ url }) => {
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
{ url: `${API_BASE_URL}/api/auth/status` }
|
||||
);
|
||||
|
||||
if (statusResponse.authenticated === true) {
|
||||
return true;
|
||||
}
|
||||
attempts++;
|
||||
// Use a very short wait between polling attempts (this is acceptable for polling)
|
||||
await page.waitForFunction(() => true, { timeout: 50 });
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using the API key from environment variable
|
||||
* Falls back to a test default if AUTOMAKER_API_KEY is not set
|
||||
*/
|
||||
export async function authenticateForTests(page: Page): Promise<boolean> {
|
||||
// Use the API key from environment, or a test default
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
return authenticateWithApiKey(page, apiKey);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { getByTestId, getButtonByText } from './elements';
|
||||
|
||||
/**
|
||||
@@ -48,6 +48,72 @@ export async function pressShortcut(page: Page, key: string): Promise<void> {
|
||||
await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a URL with authentication
|
||||
* This wrapper ensures authentication happens before navigation
|
||||
*/
|
||||
export async function gotoWithAuth(page: Page, url: string): Promise<void> {
|
||||
const { authenticateForTests } = await import('../api/client');
|
||||
await authenticateForTests(page);
|
||||
await page.goto(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login screen if it appears after navigation
|
||||
* Returns true if login was handled, false if no login screen was found
|
||||
*/
|
||||
export async function handleLoginScreenIfPresent(page: Page): Promise<boolean> {
|
||||
// Check for login screen by waiting for either login input or app-container to be visible
|
||||
// Use data-testid selector (preferred) with fallback to the old selector
|
||||
const loginInput = page
|
||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||
.first();
|
||||
const appContent = page.locator(
|
||||
'[data-testid="welcome-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"]'
|
||||
);
|
||||
|
||||
// Race between login screen and actual content
|
||||
const loginVisible = await Promise.race([
|
||||
loginInput
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
appContent
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => false)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
if (loginVisible) {
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
await loginInput.fill(apiKey);
|
||||
|
||||
// Wait a moment for the button to become enabled
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Wait for button to be enabled (it's disabled when input is empty)
|
||||
const loginButton = page
|
||||
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||
.first();
|
||||
await expect(loginButton).toBeEnabled({ timeout: 5000 });
|
||||
await loginButton.click();
|
||||
|
||||
// Wait for navigation away from login - either to content or URL change
|
||||
await Promise.race([
|
||||
page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 10000 }),
|
||||
appContent.first().waitFor({ state: 'visible', timeout: 10000 }),
|
||||
]).catch(() => {});
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a number key (0-9) on the keyboard
|
||||
*/
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { clickElement } from '../core/interactions';
|
||||
import { waitForElement } from '../core/waiting';
|
||||
import { authenticateForTests } from '../api/client';
|
||||
|
||||
/**
|
||||
* Navigate to the board/kanban view
|
||||
* Note: Navigates directly to /board since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToBoard(page: Page): Promise<void> {
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
|
||||
// Navigate directly to /board route
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
// Check if we're on the login screen and handle it
|
||||
const loginInput = page
|
||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||
.first();
|
||||
const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
if (isLoginScreen) {
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
await loginInput.fill(apiKey);
|
||||
await page.waitForTimeout(100);
|
||||
await page
|
||||
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL('**/board', { timeout: 5000 });
|
||||
await page.waitForLoadState('load');
|
||||
}
|
||||
|
||||
// Wait for the board view to be visible
|
||||
await waitForElement(page, 'board-view', { timeout: 10000 });
|
||||
}
|
||||
@@ -20,10 +41,30 @@ export async function navigateToBoard(page: Page): Promise<void> {
|
||||
* Note: Navigates directly to /context since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToContext(page: Page): Promise<void> {
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
|
||||
// Navigate directly to /context route
|
||||
await page.goto('/context');
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
// Check if we're on the login screen and handle it
|
||||
const loginInputCtx = page
|
||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||
.first();
|
||||
const isLoginScreenCtx = await loginInputCtx.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
if (isLoginScreenCtx) {
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
await loginInputCtx.fill(apiKey);
|
||||
await page.waitForTimeout(100);
|
||||
await page
|
||||
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL('**/context', { timeout: 5000 });
|
||||
await page.waitForLoadState('load');
|
||||
}
|
||||
|
||||
// Wait for loading to complete (if present)
|
||||
const loadingElement = page.locator('[data-testid="context-view-loading"]');
|
||||
try {
|
||||
@@ -37,7 +78,8 @@ export async function navigateToContext(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
// Wait for the context view to be visible
|
||||
await waitForElement(page, 'context-view', { timeout: 10000 });
|
||||
// Increase timeout to handle slower server startup
|
||||
await waitForElement(page, 'context-view', { timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +87,9 @@ export async function navigateToContext(page: Page): Promise<void> {
|
||||
* Note: Navigates directly to /spec since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToSpec(page: Page): Promise<void> {
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
|
||||
// Navigate directly to /spec route
|
||||
await page.goto('/spec');
|
||||
await page.waitForLoadState('load');
|
||||
@@ -75,10 +120,30 @@ export async function navigateToSpec(page: Page): Promise<void> {
|
||||
* Note: Navigates directly to /agent since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToAgent(page: Page): Promise<void> {
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
|
||||
// Navigate directly to /agent route
|
||||
await page.goto('/agent');
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
// Check if we're on the login screen and handle it
|
||||
const loginInputAgent = page
|
||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||
.first();
|
||||
const isLoginScreenAgent = await loginInputAgent.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
if (isLoginScreenAgent) {
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
await loginInputAgent.fill(apiKey);
|
||||
await page.waitForTimeout(100);
|
||||
await page
|
||||
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL('**/agent', { timeout: 5000 });
|
||||
await page.waitForLoadState('load');
|
||||
}
|
||||
|
||||
// Wait for the agent view to be visible
|
||||
await waitForElement(page, 'agent-view', { timeout: 10000 });
|
||||
}
|
||||
@@ -88,6 +153,9 @@ export async function navigateToAgent(page: Page): Promise<void> {
|
||||
* Note: Navigates directly to /settings since index route shows WelcomeView
|
||||
*/
|
||||
export async function navigateToSettings(page: Page): Promise<void> {
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
|
||||
// Navigate directly to /settings route
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('load');
|
||||
@@ -113,8 +181,31 @@ export async function navigateToSetup(page: Page): Promise<void> {
|
||||
* Navigate to the welcome view (clear project selection)
|
||||
*/
|
||||
export async function navigateToWelcome(page: Page): Promise<void> {
|
||||
// Authenticate before navigating
|
||||
await authenticateForTests(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
// Check if we're on the login screen and handle it
|
||||
const loginInputWelcome = page
|
||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||
.first();
|
||||
const isLoginScreenWelcome = await loginInputWelcome
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false);
|
||||
if (isLoginScreenWelcome) {
|
||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||
await loginInputWelcome.fill(apiKey);
|
||||
await page.waitForTimeout(100);
|
||||
await page
|
||||
.locator('[data-testid="login-submit-button"], button:has-text("Login")')
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL('**/', { timeout: 5000 });
|
||||
await page.waitForLoadState('load');
|
||||
}
|
||||
|
||||
await waitForElement(page, 'welcome-view', { timeout: 10000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ services:
|
||||
# Set root directory for all projects and file operations
|
||||
# Users can only create/open projects within this directory
|
||||
- ALLOWED_ROOT_DIRECTORY=/projects
|
||||
- NODE_ENV=development
|
||||
|
||||
@@ -13,7 +13,8 @@ services:
|
||||
ui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/ui/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
target: ui
|
||||
container_name: automaker-ui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -25,7 +26,8 @@ services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/server/Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
target: server
|
||||
container_name: automaker-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -34,7 +36,7 @@ services:
|
||||
# Required
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
|
||||
# Optional - authentication (leave empty to disable)
|
||||
# Optional - authentication, one will generate if left blank
|
||||
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
|
||||
|
||||
# Optional - restrict to specific directory within container only
|
||||
@@ -47,7 +49,7 @@ services:
|
||||
- DATA_DIR=/data
|
||||
|
||||
# Optional - CORS origin (default allows all)
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-*}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007}
|
||||
volumes:
|
||||
# ONLY named volumes - these are isolated from your host filesystem
|
||||
# This volume persists data between restarts but is container-managed
|
||||
|
||||
94
docs/plans/2025-12-29-api-security-hardening-design.md
Normal file
94
docs/plans/2025-12-29-api-security-hardening-design.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# API Security Hardening Design
|
||||
|
||||
**Date:** 2025-12-29
|
||||
**Branch:** protect-api-with-api-key
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Security improvements for the API authentication system before merging the PR. These changes harden the existing implementation for production deployment scenarios (local, Docker, internet-exposed).
|
||||
|
||||
## Fixes to Implement
|
||||
|
||||
### 1. Use Short-Lived wsToken for WebSocket Authentication
|
||||
|
||||
**Problem:** The client currently passes `sessionToken` in WebSocket URL query parameters. Query params get logged and can leak credentials.
|
||||
|
||||
**Solution:** Update the client to:
|
||||
|
||||
1. Fetch a wsToken from `/api/auth/token` before each WebSocket connection
|
||||
2. Use `wsToken` query param instead of `sessionToken`
|
||||
3. Never put session tokens in URLs
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- `apps/ui/src/lib/http-api-client.ts` - Update `connectWebSocket()` to fetch wsToken first
|
||||
|
||||
---
|
||||
|
||||
### 2. Add Environment Variable to Hide API Key from Logs
|
||||
|
||||
**Problem:** The API key is printed to console on startup, which gets captured by logging systems in production.
|
||||
|
||||
**Solution:** Add `AUTOMAKER_HIDE_API_KEY=true` env var to suppress the banner.
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- `apps/server/src/lib/auth.ts` - Wrap console.log banner in env var check
|
||||
|
||||
---
|
||||
|
||||
### 3. Add Rate Limiting to Login Endpoint
|
||||
|
||||
**Problem:** No brute force protection on `/api/auth/login`. Attackers could attempt many API keys.
|
||||
|
||||
**Solution:** Add basic in-memory rate limiting:
|
||||
|
||||
- ~5 attempts per minute per IP
|
||||
- In-memory Map tracking (resets on server restart)
|
||||
- Return 429 Too Many Requests when exceeded
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- `apps/server/src/routes/auth/index.ts` - Add rate limiting logic to login handler
|
||||
|
||||
---
|
||||
|
||||
### 4. Use Timing-Safe Comparison for API Key
|
||||
|
||||
**Problem:** Using `===` for API key comparison is vulnerable to timing attacks.
|
||||
|
||||
**Solution:** Use `crypto.timingSafeEqual()` for constant-time comparison.
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- `apps/server/src/lib/auth.ts` - Update `validateApiKey()` function
|
||||
|
||||
---
|
||||
|
||||
### 5. Make WebSocket Tokens Single-Use
|
||||
|
||||
**Problem:** wsTokens can be reused within the 5-minute window. If intercepted, attackers have time to use them.
|
||||
|
||||
**Solution:** Delete the token after first successful validation.
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- `apps/server/src/lib/auth.ts` - Update `validateWsConnectionToken()` to delete after use
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Fix #4 (timing-safe comparison) - Simple, isolated change
|
||||
2. Fix #5 (single-use wsToken) - Simple, isolated change
|
||||
3. Fix #2 (hide API key env var) - Simple, isolated change
|
||||
4. Fix #3 (rate limiting) - Moderate complexity
|
||||
5. Fix #1 (client wsToken usage) - Requires coordination with server
|
||||
|
||||
## Testing Notes
|
||||
|
||||
- Test login with rate limiting (verify 429 after 5 attempts)
|
||||
- Test WebSocket connection with new wsToken flow
|
||||
- Test wsToken is invalidated after first use
|
||||
- Verify `AUTOMAKER_HIDE_API_KEY=true` suppresses banner
|
||||
13
init.mjs
13
init.mjs
@@ -352,14 +352,21 @@ async function main() {
|
||||
fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true });
|
||||
}
|
||||
|
||||
// Start server in background
|
||||
// Start server in background, showing output in console AND logging to file
|
||||
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
|
||||
serverProcess = runNpm(['run', 'dev:server'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
serverProcess.stdout?.pipe(logStream);
|
||||
serverProcess.stderr?.pipe(logStream);
|
||||
// Pipe to both log file and console so user can see API key
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
process.stdout.write(data);
|
||||
logStream.write(data);
|
||||
});
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
process.stderr.write(data);
|
||||
logStream.write(data);
|
||||
});
|
||||
|
||||
log('Waiting for server to be ready...', 'yellow');
|
||||
|
||||
|
||||
433
libs/prompts/src/defaults.ts
Normal file
433
libs/prompts/src/defaults.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Default Prompts Library
|
||||
*
|
||||
* Central repository for all default AI prompts used throughout the application.
|
||||
* These prompts can be overridden by user customization in settings.
|
||||
*
|
||||
* Extracted from:
|
||||
* - apps/server/src/services/auto-mode-service.ts (Auto Mode planning prompts)
|
||||
* - apps/server/src/services/agent-service.ts (Agent Runner system prompt)
|
||||
* - apps/server/src/routes/backlog-plan/generate-plan.ts (Backlog planning prompts)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ResolvedAutoModePrompts,
|
||||
ResolvedAgentPrompts,
|
||||
ResolvedBacklogPlanPrompts,
|
||||
ResolvedEnhancementPrompts,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* ========================================================================
|
||||
* AUTO MODE PROMPTS
|
||||
* ========================================================================
|
||||
*/
|
||||
|
||||
export const DEFAULT_AUTO_MODE_PLANNING_LITE = `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[PLAN_GENERATED] Planning outline complete."
|
||||
|
||||
Then proceed with implementation.
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_PLANNING_LITE_WITH_APPROVAL = `## Planning Phase (Lite Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
|
||||
|
||||
Create a brief planning outline:
|
||||
|
||||
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||
2. **Approach**: How will we do it? (2-3 sentences)
|
||||
3. **Files to Touch**: List files and what changes
|
||||
4. **Tasks**: Numbered task list (3-7 items)
|
||||
5. **Risks**: Any gotchas to watch for
|
||||
|
||||
After generating the outline, output:
|
||||
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_PLANNING_SPEC = `## Specification Phase (Spec Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem**: What problem are we solving? (user perspective)
|
||||
|
||||
2. **Solution**: Brief approach (1-2 sentences)
|
||||
|
||||
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
|
||||
- GIVEN [context], WHEN [action], THEN [outcome]
|
||||
|
||||
4. **Files to Modify**:
|
||||
| File | Purpose | Action |
|
||||
|------|---------|--------|
|
||||
| path/to/file | description | create/modify/delete |
|
||||
|
||||
5. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential: T001, T002, T003, etc.
|
||||
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
|
||||
- File: Primary file affected (helps with context)
|
||||
- Order by dependencies (foundational tasks first)
|
||||
|
||||
6. **Verification**: How to confirm feature works
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY in order. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
This allows real-time progress tracking during implementation.
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_PLANNING_FULL = `## Full Specification Phase (Full SDD Mode)
|
||||
|
||||
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
|
||||
|
||||
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
|
||||
|
||||
### Specification Format
|
||||
|
||||
1. **Problem Statement**: 2-3 sentences from user perspective
|
||||
|
||||
2. **User Story**: As a [user], I want [goal], so that [benefit]
|
||||
|
||||
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
|
||||
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
|
||||
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
|
||||
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
|
||||
|
||||
4. **Technical Context**:
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| Affected Files | list of files |
|
||||
| Dependencies | external libs if any |
|
||||
| Constraints | technical limitations |
|
||||
| Patterns to Follow | existing patterns in codebase |
|
||||
|
||||
5. **Non-Goals**: What this feature explicitly does NOT include
|
||||
|
||||
6. **Implementation Tasks**:
|
||||
Use this EXACT format for each task (the system will parse these):
|
||||
\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
- [ ] T004: [Description] | File: [path/to/file]
|
||||
|
||||
## Phase 3: Integration & Testing
|
||||
- [ ] T005: [Description] | File: [path/to/file]
|
||||
- [ ] T006: [Description] | File: [path/to/file]
|
||||
\`\`\`
|
||||
|
||||
Task ID rules:
|
||||
- Sequential across all phases: T001, T002, T003, etc.
|
||||
- Description: Clear action verb + target
|
||||
- File: Primary file affected
|
||||
- Order by dependencies within each phase
|
||||
- Phase structure helps organize complex work
|
||||
|
||||
7. **Success Metrics**: How we know it's done (measurable criteria)
|
||||
|
||||
8. **Risks & Mitigations**:
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| description | approach |
|
||||
|
||||
After generating the spec, output on its own line:
|
||||
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||
|
||||
DO NOT proceed with implementation until you receive explicit approval.
|
||||
|
||||
When approved, execute tasks SEQUENTIALLY by phase. For each task:
|
||||
1. BEFORE starting, output: "[TASK_START] T###: Description"
|
||||
2. Implement the task
|
||||
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
|
||||
|
||||
After completing all tasks in a phase, output:
|
||||
"[PHASE_COMPLETE] Phase N complete"
|
||||
|
||||
This allows real-time progress tracking during implementation.
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_FEATURE_PROMPT_TEMPLATE = `## Feature Implementation Task
|
||||
|
||||
**Feature ID:** {{featureId}}
|
||||
**Title:** {{title}}
|
||||
**Description:** {{description}}
|
||||
|
||||
{{#if spec}}
|
||||
**Specification:**
|
||||
{{spec}}
|
||||
{{/if}}
|
||||
|
||||
{{#if imagePaths}}
|
||||
**Context Images:**
|
||||
{{#each imagePaths}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if dependencies}}
|
||||
**Dependencies:**
|
||||
This feature depends on: {{dependencies}}
|
||||
{{/if}}
|
||||
|
||||
{{#if verificationInstructions}}
|
||||
**Verification:**
|
||||
{{verificationInstructions}}
|
||||
{{/if}}
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation
|
||||
|
||||
{{featurePrompt}}
|
||||
|
||||
## Previous Agent Work
|
||||
{{previousContext}}
|
||||
|
||||
## Follow-up Instructions
|
||||
{{followUpInstructions}}
|
||||
|
||||
## Task
|
||||
Address the follow-up instructions above.
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_CONTINUATION_PROMPT_TEMPLATE = `## Continuing Feature Implementation
|
||||
|
||||
{{featurePrompt}}
|
||||
|
||||
## Previous Context
|
||||
{{previousContext}}
|
||||
|
||||
## Instructions
|
||||
Review the previous work and continue the implementation.
|
||||
`;
|
||||
|
||||
export const DEFAULT_AUTO_MODE_PIPELINE_STEP_PROMPT_TEMPLATE = `## Pipeline Step: {{stepName}}
|
||||
|
||||
### Feature Context
|
||||
{{featurePrompt}}
|
||||
|
||||
### Previous Work
|
||||
{{previousContext}}
|
||||
|
||||
### Pipeline Step Instructions
|
||||
{{stepInstructions}}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Default Auto Mode prompts (from auto-mode-service.ts)
|
||||
*/
|
||||
export const DEFAULT_AUTO_MODE_PROMPTS: ResolvedAutoModePrompts = {
|
||||
planningLite: DEFAULT_AUTO_MODE_PLANNING_LITE,
|
||||
planningLiteWithApproval: DEFAULT_AUTO_MODE_PLANNING_LITE_WITH_APPROVAL,
|
||||
planningSpec: DEFAULT_AUTO_MODE_PLANNING_SPEC,
|
||||
planningFull: DEFAULT_AUTO_MODE_PLANNING_FULL,
|
||||
featurePromptTemplate: DEFAULT_AUTO_MODE_FEATURE_PROMPT_TEMPLATE,
|
||||
followUpPromptTemplate: DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE,
|
||||
continuationPromptTemplate: DEFAULT_AUTO_MODE_CONTINUATION_PROMPT_TEMPLATE,
|
||||
pipelineStepPromptTemplate: DEFAULT_AUTO_MODE_PIPELINE_STEP_PROMPT_TEMPLATE,
|
||||
};
|
||||
|
||||
/**
|
||||
* ========================================================================
|
||||
* AGENT RUNNER PROMPTS
|
||||
* ========================================================================
|
||||
*/
|
||||
|
||||
export const DEFAULT_AGENT_SYSTEM_PROMPT = `You are an AI assistant helping users build software. You are part of the Automaker application,
|
||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
||||
|
||||
**Feature Storage:**
|
||||
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
|
||||
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
|
||||
|
||||
Your role is to:
|
||||
- Help users define their project requirements and specifications
|
||||
- Ask clarifying questions to better understand their needs
|
||||
- Suggest technical approaches and architectures
|
||||
- Guide them through the development process
|
||||
- Be conversational and helpful
|
||||
- Write, edit, and modify code files as requested
|
||||
- Execute commands and tests
|
||||
- Search and analyze the codebase
|
||||
|
||||
**Tools Available:**
|
||||
You have access to several tools:
|
||||
- UpdateFeatureStatus: Update feature status (NOT file edits)
|
||||
- Read/Write/Edit: File operations
|
||||
- Bash: Execute commands
|
||||
- Glob/Grep: Search codebase
|
||||
- WebSearch/WebFetch: Research online
|
||||
|
||||
**Important Guidelines:**
|
||||
1. When users want to add or modify features, help them create clear feature definitions
|
||||
2. Use UpdateFeatureStatus tool to manage features in the backlog
|
||||
3. Be proactive in suggesting improvements and best practices
|
||||
4. Ask questions when requirements are unclear
|
||||
5. Guide users toward good software design principles
|
||||
|
||||
Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`;
|
||||
|
||||
/**
|
||||
* Default Agent Runner prompts (from agent-service.ts)
|
||||
*/
|
||||
export const DEFAULT_AGENT_PROMPTS: ResolvedAgentPrompts = {
|
||||
systemPrompt: DEFAULT_AGENT_SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
/**
|
||||
* ========================================================================
|
||||
* BACKLOG PLAN PROMPTS
|
||||
* ========================================================================
|
||||
*/
|
||||
|
||||
export const DEFAULT_BACKLOG_PLAN_SYSTEM_PROMPT = `You are an AI assistant helping to modify a software project's feature backlog.
|
||||
You will be given the current list of features and a user request to modify the backlog.
|
||||
|
||||
IMPORTANT CONTEXT (automatically injected):
|
||||
- Remember to update the dependency graph if deleting existing features
|
||||
- Remember to define dependencies on new features hooked into relevant existing ones
|
||||
- Maintain dependency graph integrity (no orphaned dependencies)
|
||||
- When deleting a feature, identify which other features depend on it
|
||||
|
||||
Your task is to analyze the request and produce a structured JSON plan with:
|
||||
1. Features to ADD (include title, description, category, and dependencies)
|
||||
2. Features to UPDATE (specify featureId and the updates)
|
||||
3. Features to DELETE (specify featureId)
|
||||
4. A summary of the changes
|
||||
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
|
||||
|
||||
Respond with ONLY a JSON object in this exact format:
|
||||
{
|
||||
"plan": {
|
||||
"add": [
|
||||
{
|
||||
"title": "string",
|
||||
"description": "string",
|
||||
"category": "feature" | "bug" | "enhancement" | "refactor",
|
||||
"dependencies": ["featureId1", "featureId2"]
|
||||
}
|
||||
],
|
||||
"update": [
|
||||
{
|
||||
"featureId": "string",
|
||||
"updates": {
|
||||
"title"?: "string",
|
||||
"description"?: "string",
|
||||
"category"?: "feature" | "bug" | "enhancement" | "refactor",
|
||||
"priority"?: number,
|
||||
"dependencies"?: ["featureId1"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"delete": ["featureId1", "featureId2"],
|
||||
"summary": "Brief summary of all changes",
|
||||
"dependencyUpdates": [
|
||||
{
|
||||
"featureId": "string",
|
||||
"action": "remove_dependency" | "add_dependency",
|
||||
"dependencyId": "string",
|
||||
"reason": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Important rules:
|
||||
- Only include fields that need to change in updates
|
||||
- Ensure dependency references are valid (don't reference deleted features)
|
||||
- Provide clear, actionable descriptions
|
||||
- Maintain category consistency (feature, bug, enhancement, refactor)
|
||||
- When adding dependencies, ensure the referenced features exist or are being added in the same plan
|
||||
`;
|
||||
|
||||
export const DEFAULT_BACKLOG_PLAN_USER_PROMPT_TEMPLATE = `Current Features in Backlog:
|
||||
{{currentFeatures}}
|
||||
|
||||
---
|
||||
|
||||
User Request: {{userRequest}}
|
||||
|
||||
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
|
||||
|
||||
/**
|
||||
* Default Backlog Plan prompts (from backlog-plan/generate-plan.ts)
|
||||
*/
|
||||
export const DEFAULT_BACKLOG_PLAN_PROMPTS: ResolvedBacklogPlanPrompts = {
|
||||
systemPrompt: DEFAULT_BACKLOG_PLAN_SYSTEM_PROMPT,
|
||||
userPromptTemplate: DEFAULT_BACKLOG_PLAN_USER_PROMPT_TEMPLATE,
|
||||
};
|
||||
|
||||
/**
|
||||
* ========================================================================
|
||||
* ENHANCEMENT PROMPTS
|
||||
* ========================================================================
|
||||
* Note: Enhancement prompts are already defined in enhancement.ts
|
||||
* We import and re-export them here for consistency
|
||||
*/
|
||||
|
||||
import {
|
||||
IMPROVE_SYSTEM_PROMPT,
|
||||
TECHNICAL_SYSTEM_PROMPT,
|
||||
SIMPLIFY_SYSTEM_PROMPT,
|
||||
ACCEPTANCE_SYSTEM_PROMPT,
|
||||
} from './enhancement.js';
|
||||
|
||||
/**
|
||||
* Default Enhancement prompts (from libs/prompts/src/enhancement.ts)
|
||||
*/
|
||||
export const DEFAULT_ENHANCEMENT_PROMPTS: ResolvedEnhancementPrompts = {
|
||||
improveSystemPrompt: IMPROVE_SYSTEM_PROMPT,
|
||||
technicalSystemPrompt: TECHNICAL_SYSTEM_PROMPT,
|
||||
simplifySystemPrompt: SIMPLIFY_SYSTEM_PROMPT,
|
||||
acceptanceSystemPrompt: ACCEPTANCE_SYSTEM_PROMPT,
|
||||
};
|
||||
|
||||
/**
|
||||
* ========================================================================
|
||||
* COMBINED DEFAULTS
|
||||
* ========================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* All default prompts in one object for easy access
|
||||
*/
|
||||
export const DEFAULT_PROMPTS = {
|
||||
autoMode: DEFAULT_AUTO_MODE_PROMPTS,
|
||||
agent: DEFAULT_AGENT_PROMPTS,
|
||||
backlogPlan: DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||
enhancement: DEFAULT_ENHANCEMENT_PROMPTS,
|
||||
} as const;
|
||||
@@ -23,3 +23,40 @@ export {
|
||||
|
||||
// Re-export types from @automaker/types
|
||||
export type { EnhancementMode, EnhancementExample } from '@automaker/types';
|
||||
|
||||
// Default prompts
|
||||
export {
|
||||
DEFAULT_AUTO_MODE_PLANNING_LITE,
|
||||
DEFAULT_AUTO_MODE_PLANNING_LITE_WITH_APPROVAL,
|
||||
DEFAULT_AUTO_MODE_PLANNING_SPEC,
|
||||
DEFAULT_AUTO_MODE_PLANNING_FULL,
|
||||
DEFAULT_AUTO_MODE_FEATURE_PROMPT_TEMPLATE,
|
||||
DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE,
|
||||
DEFAULT_AUTO_MODE_CONTINUATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_AUTO_MODE_PIPELINE_STEP_PROMPT_TEMPLATE,
|
||||
DEFAULT_AUTO_MODE_PROMPTS,
|
||||
DEFAULT_AGENT_SYSTEM_PROMPT,
|
||||
DEFAULT_AGENT_PROMPTS,
|
||||
DEFAULT_BACKLOG_PLAN_SYSTEM_PROMPT,
|
||||
DEFAULT_BACKLOG_PLAN_USER_PROMPT_TEMPLATE,
|
||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||
DEFAULT_PROMPTS,
|
||||
} from './defaults.js';
|
||||
|
||||
// Prompt merging utilities
|
||||
export {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
mergeBacklogPlanPrompts,
|
||||
mergeEnhancementPrompts,
|
||||
mergeAllPrompts,
|
||||
} from './merge.js';
|
||||
|
||||
// Re-export resolved prompt types from @automaker/types
|
||||
export type {
|
||||
ResolvedAutoModePrompts,
|
||||
ResolvedAgentPrompts,
|
||||
ResolvedBacklogPlanPrompts,
|
||||
ResolvedEnhancementPrompts,
|
||||
} from '@automaker/types';
|
||||
|
||||
130
libs/prompts/src/merge.ts
Normal file
130
libs/prompts/src/merge.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Prompt Merging Utilities
|
||||
*
|
||||
* Merges user-customized prompts with built-in defaults.
|
||||
* Used by services to get effective prompts at runtime.
|
||||
*
|
||||
* Custom prompts have an `enabled` flag - when true, the custom value is used.
|
||||
* When false or undefined, the default is used instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PromptCustomization,
|
||||
AutoModePrompts,
|
||||
AgentPrompts,
|
||||
BacklogPlanPrompts,
|
||||
EnhancementPrompts,
|
||||
CustomPrompt,
|
||||
ResolvedAutoModePrompts,
|
||||
ResolvedAgentPrompts,
|
||||
ResolvedBacklogPlanPrompts,
|
||||
ResolvedEnhancementPrompts,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_AUTO_MODE_PROMPTS,
|
||||
DEFAULT_AGENT_PROMPTS,
|
||||
DEFAULT_BACKLOG_PLAN_PROMPTS,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS,
|
||||
} from './defaults.js';
|
||||
|
||||
/**
|
||||
* Resolve a custom prompt to its effective string value
|
||||
* Returns the custom value if enabled=true, otherwise returns the default
|
||||
*/
|
||||
function resolvePrompt(custom: CustomPrompt | undefined, defaultValue: string): string {
|
||||
return custom?.enabled ? custom.value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge custom Auto Mode prompts with defaults
|
||||
* Custom prompts override defaults only when enabled=true
|
||||
*/
|
||||
export function mergeAutoModePrompts(custom?: AutoModePrompts): ResolvedAutoModePrompts {
|
||||
return {
|
||||
planningLite: resolvePrompt(custom?.planningLite, DEFAULT_AUTO_MODE_PROMPTS.planningLite),
|
||||
planningLiteWithApproval: resolvePrompt(
|
||||
custom?.planningLiteWithApproval,
|
||||
DEFAULT_AUTO_MODE_PROMPTS.planningLiteWithApproval
|
||||
),
|
||||
planningSpec: resolvePrompt(custom?.planningSpec, DEFAULT_AUTO_MODE_PROMPTS.planningSpec),
|
||||
planningFull: resolvePrompt(custom?.planningFull, DEFAULT_AUTO_MODE_PROMPTS.planningFull),
|
||||
featurePromptTemplate: resolvePrompt(
|
||||
custom?.featurePromptTemplate,
|
||||
DEFAULT_AUTO_MODE_PROMPTS.featurePromptTemplate
|
||||
),
|
||||
followUpPromptTemplate: resolvePrompt(
|
||||
custom?.followUpPromptTemplate,
|
||||
DEFAULT_AUTO_MODE_PROMPTS.followUpPromptTemplate
|
||||
),
|
||||
continuationPromptTemplate: resolvePrompt(
|
||||
custom?.continuationPromptTemplate,
|
||||
DEFAULT_AUTO_MODE_PROMPTS.continuationPromptTemplate
|
||||
),
|
||||
pipelineStepPromptTemplate: resolvePrompt(
|
||||
custom?.pipelineStepPromptTemplate,
|
||||
DEFAULT_AUTO_MODE_PROMPTS.pipelineStepPromptTemplate
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge custom Agent prompts with defaults
|
||||
* Custom prompts override defaults only when enabled=true
|
||||
*/
|
||||
export function mergeAgentPrompts(custom?: AgentPrompts): ResolvedAgentPrompts {
|
||||
return {
|
||||
systemPrompt: resolvePrompt(custom?.systemPrompt, DEFAULT_AGENT_PROMPTS.systemPrompt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge custom Backlog Plan prompts with defaults
|
||||
* Custom prompts override defaults only when enabled=true
|
||||
*/
|
||||
export function mergeBacklogPlanPrompts(custom?: BacklogPlanPrompts): ResolvedBacklogPlanPrompts {
|
||||
return {
|
||||
systemPrompt: resolvePrompt(custom?.systemPrompt, DEFAULT_BACKLOG_PLAN_PROMPTS.systemPrompt),
|
||||
userPromptTemplate: resolvePrompt(
|
||||
custom?.userPromptTemplate,
|
||||
DEFAULT_BACKLOG_PLAN_PROMPTS.userPromptTemplate
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge custom Enhancement prompts with defaults
|
||||
* Custom prompts override defaults only when enabled=true
|
||||
*/
|
||||
export function mergeEnhancementPrompts(custom?: EnhancementPrompts): ResolvedEnhancementPrompts {
|
||||
return {
|
||||
improveSystemPrompt: resolvePrompt(
|
||||
custom?.improveSystemPrompt,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS.improveSystemPrompt
|
||||
),
|
||||
technicalSystemPrompt: resolvePrompt(
|
||||
custom?.technicalSystemPrompt,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS.technicalSystemPrompt
|
||||
),
|
||||
simplifySystemPrompt: resolvePrompt(
|
||||
custom?.simplifySystemPrompt,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS.simplifySystemPrompt
|
||||
),
|
||||
acceptanceSystemPrompt: resolvePrompt(
|
||||
custom?.acceptanceSystemPrompt,
|
||||
DEFAULT_ENHANCEMENT_PROMPTS.acceptanceSystemPrompt
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all custom prompts with defaults
|
||||
* Returns a complete PromptCustomization with all fields populated
|
||||
*/
|
||||
export function mergeAllPrompts(custom?: PromptCustomization) {
|
||||
return {
|
||||
autoMode: mergeAutoModePrompts(custom?.autoMode),
|
||||
agent: mergeAgentPrompts(custom?.agent),
|
||||
backlogPlan: mergeBacklogPlanPrompts(custom?.backlogPlan),
|
||||
enhancement: mergeEnhancementPrompts(custom?.enhancement),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = 'authentication' | 'cancellation' | 'abort' | 'execution' | 'unknown';
|
||||
export type ErrorType =
|
||||
| 'authentication'
|
||||
| 'cancellation'
|
||||
| 'abort'
|
||||
| 'execution'
|
||||
| 'rate_limit'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
@@ -12,5 +18,7 @@ export interface ErrorInfo {
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
isCancellation: boolean;
|
||||
isRateLimit: boolean;
|
||||
retryAfter?: number; // Seconds to wait before retrying (for rate limit errors)
|
||||
originalError: unknown;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ export { specOutputSchema } from './spec.js';
|
||||
// Enhancement types
|
||||
export type { EnhancementMode, EnhancementExample } from './enhancement.js';
|
||||
|
||||
// Prompt customization types
|
||||
export type {
|
||||
CustomPrompt,
|
||||
AutoModePrompts,
|
||||
AgentPrompts,
|
||||
BacklogPlanPrompts,
|
||||
EnhancementPrompts,
|
||||
PromptCustomization,
|
||||
ResolvedAutoModePrompts,
|
||||
ResolvedAgentPrompts,
|
||||
ResolvedBacklogPlanPrompts,
|
||||
ResolvedEnhancementPrompts,
|
||||
} from './prompts.js';
|
||||
export { DEFAULT_PROMPT_CUSTOMIZATION } from './prompts.js';
|
||||
|
||||
// Settings types and constants
|
||||
export type {
|
||||
ThemeMode,
|
||||
@@ -93,6 +108,9 @@ export type {
|
||||
IssueValidationVerdict,
|
||||
IssueValidationConfidence,
|
||||
IssueComplexity,
|
||||
PRRecommendation,
|
||||
PRAnalysis,
|
||||
LinkedPRInfo,
|
||||
IssueValidationInput,
|
||||
IssueValidationRequest,
|
||||
IssueValidationResult,
|
||||
@@ -100,6 +118,9 @@ export type {
|
||||
IssueValidationErrorResponse,
|
||||
IssueValidationEvent,
|
||||
StoredValidation,
|
||||
GitHubCommentAuthor,
|
||||
GitHubComment,
|
||||
IssueCommentsResult,
|
||||
} from './issue-validation.js';
|
||||
|
||||
// Backlog plan types
|
||||
|
||||
@@ -21,6 +21,36 @@ export type IssueValidationConfidence = 'high' | 'medium' | 'low';
|
||||
*/
|
||||
export type IssueComplexity = 'trivial' | 'simple' | 'moderate' | 'complex' | 'very_complex';
|
||||
|
||||
/**
|
||||
* Recommendation for PR-related action
|
||||
*/
|
||||
export type PRRecommendation = 'wait_for_merge' | 'pr_needs_work' | 'no_pr';
|
||||
|
||||
/**
|
||||
* Analysis of a linked pull request
|
||||
*/
|
||||
export interface PRAnalysis {
|
||||
/** Whether there is an open PR linked to this issue */
|
||||
hasOpenPR: boolean;
|
||||
/** Whether the PR appears to fix the issue based on the diff */
|
||||
prFixesIssue?: boolean;
|
||||
/** The PR number that was analyzed */
|
||||
prNumber?: number;
|
||||
/** Brief summary of what the PR changes */
|
||||
prSummary?: string;
|
||||
/** Recommendation: wait for PR to merge, PR needs more work, or no relevant PR */
|
||||
recommendation: PRRecommendation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked PR info for validation
|
||||
*/
|
||||
export interface LinkedPRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue data for validation (without projectPath)
|
||||
* Used by UI when calling the validation API
|
||||
@@ -30,6 +60,10 @@ export interface IssueValidationInput {
|
||||
issueTitle: string;
|
||||
issueBody: string;
|
||||
issueLabels?: string[];
|
||||
/** Comments to include in validation analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests for this issue */
|
||||
linkedPRs?: LinkedPRInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +94,8 @@ export interface IssueValidationResult {
|
||||
missingInfo?: string[];
|
||||
/** Estimated effort to address the issue */
|
||||
estimatedComplexity?: IssueComplexity;
|
||||
/** Analysis of linked pull requests (if any) */
|
||||
prAnalysis?: PRAnalysis;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,3 +169,41 @@ export interface StoredValidation {
|
||||
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
|
||||
viewedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Author of a GitHub comment
|
||||
*/
|
||||
export interface GitHubCommentAuthor {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment on a GitHub issue
|
||||
*/
|
||||
export interface GitHubComment {
|
||||
/** Unique comment ID */
|
||||
id: string;
|
||||
/** Author of the comment */
|
||||
author: GitHubCommentAuthor;
|
||||
/** Comment body (markdown) */
|
||||
body: string;
|
||||
/** ISO timestamp when comment was created */
|
||||
createdAt: string;
|
||||
/** ISO timestamp when comment was last updated */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from fetching issue comments
|
||||
*/
|
||||
export interface IssueCommentsResult {
|
||||
/** List of comments */
|
||||
comments: GitHubComment[];
|
||||
/** Total number of comments on the issue */
|
||||
totalCount: number;
|
||||
/** Whether there are more comments to fetch */
|
||||
hasNextPage: boolean;
|
||||
/** Cursor for pagination (pass to next request) */
|
||||
endCursor?: string;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user