mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 22:32:04 +00:00
Compare commits
84 Commits
feature/mc
...
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da38adcba6 | ||
|
|
af493fb73e | ||
|
|
79bf1c9bec | ||
|
|
b9a6e29ee8 | ||
|
|
2828431cca | ||
|
|
d3f46f565b | ||
|
|
3f4f2199eb | ||
|
|
38f0b16530 | ||
|
|
bd22323149 | ||
|
|
f6ce03d59a | ||
|
|
63816043cf | ||
|
|
eafe474dbc | ||
|
|
59bbbd43c5 | ||
|
|
2b89b0606c | ||
|
|
07327e48b4 | ||
|
|
04aac7ec07 | ||
|
|
944e2f5ffe | ||
|
|
847a8ff327 | ||
|
|
504c19aef5 | ||
|
|
ed2da7932c | ||
|
|
968d889346 | ||
|
|
04aca1c8cb | ||
|
|
784d7fc059 | ||
|
|
d6705fbfb5 | ||
|
|
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 |
49
.claude/commands/validate-build.md
Normal file
49
.claude/commands/validate-build.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Project Build and Fix Command
|
||||
|
||||
Run all builds and intelligently fix any failures based on what changed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Run the build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This builds all packages and the UI application.
|
||||
|
||||
2. **If the build succeeds**, report success and stop.
|
||||
|
||||
3. **If the build fails**, analyze the failures:
|
||||
- Note which build step failed and the error messages
|
||||
- Check for TypeScript compilation errors, missing dependencies, or configuration issues
|
||||
- Run `git diff main` to see what code has changed
|
||||
|
||||
4. **Determine the nature of the failure**:
|
||||
- **If the failure is due to intentional changes** (new features, refactoring, dependency updates):
|
||||
- Fix any TypeScript type errors introduced by the changes
|
||||
- Update build configuration if needed (e.g., tsconfig.json, vite.config.mts)
|
||||
- Ensure all new dependencies are properly installed
|
||||
- Fix import paths or module resolution issues
|
||||
|
||||
- **If the failure appears to be a regression** (broken imports, missing files, configuration errors):
|
||||
- Fix the source code to restore the build
|
||||
- Check for accidentally deleted files or broken references
|
||||
- Verify build configuration files are correct
|
||||
|
||||
5. **Common build issues to check**:
|
||||
- **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports
|
||||
- **Missing dependencies**: Run `npm install` if packages are missing
|
||||
- **Import/export errors**: Fix incorrect import paths or missing exports
|
||||
- **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs
|
||||
- **Package build order**: Ensure `build:packages` completes before building apps
|
||||
|
||||
6. **How to decide if it's intentional vs regression**:
|
||||
- Look at the git diff and commit messages
|
||||
- If the change was deliberate and introduced new code that needs fixing → fix the new code
|
||||
- If the change broke existing functionality that should still build → fix the regression
|
||||
- When in doubt, ask the user
|
||||
|
||||
7. **After making fixes**, re-run the build to verify everything compiles successfully.
|
||||
|
||||
8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.).
|
||||
36
.claude/commands/validate-tests.md
Normal file
36
.claude/commands/validate-tests.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Project Test and Fix Command
|
||||
|
||||
Run all tests and intelligently fix any failures based on what changed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Run all tests**
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
2. **If all tests pass**, report success and stop.
|
||||
|
||||
3. **If any tests fail**, analyze the failures:
|
||||
- Note which tests failed and their error messages
|
||||
- Run `git diff main` to see what code has changed
|
||||
|
||||
4. **Determine the nature of the change**:
|
||||
- **If the logic change is intentional** (new feature, refactor, behavior change):
|
||||
- Update the failing tests to match the new expected behavior
|
||||
- The tests should reflect what the code NOW does correctly
|
||||
|
||||
- **If the logic change appears to be a bug** (regression, unintended side effect):
|
||||
- Fix the source code to restore the expected behavior
|
||||
- Do NOT modify the tests - they are catching a real bug
|
||||
|
||||
5. **How to decide if it's a bug vs intentional change**:
|
||||
- Look at the git diff and commit messages
|
||||
- If the change was deliberate and the test expectations are now outdated → update tests
|
||||
- If the change broke existing functionality that should still work → fix the code
|
||||
- When in doubt, ask the user
|
||||
|
||||
6. **After making fixes**, re-run the tests to verify everything passes.
|
||||
|
||||
7. **Report summary** of what was fixed (tests updated vs code fixed).
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"autoAllowBashIfSandboxed": true
|
||||
},
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits",
|
||||
"allow": [
|
||||
"Read(./**)",
|
||||
"Write(./**)",
|
||||
"Edit(./**)",
|
||||
"Glob(./**)",
|
||||
"Grep(./**)",
|
||||
"Bash(*)",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate"
|
||||
]
|
||||
}
|
||||
}
|
||||
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
|
||||
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
|
||||
|
||||
@@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY=
|
||||
|
||||
# CORS origin - which domains can access the API
|
||||
# Use "*" for development, set specific origin for production
|
||||
CORS_ORIGIN=*
|
||||
CORS_ORIGIN=http://localhost:3007
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Server
|
||||
|
||||
@@ -1,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"]
|
||||
@@ -5,10 +5,14 @@
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:test": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/",
|
||||
@@ -20,32 +24,35 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/git-utils": "^1.0.0",
|
||||
"@automaker/model-resolver": "^1.0.0",
|
||||
"@automaker/platform": "^1.0.0",
|
||||
"@automaker/prompts": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"morgan": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.72",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
"@automaker/platform": "1.0.0",
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.25.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.3"
|
||||
"ws": "8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.16"
|
||||
"@types/cookie": "0.6.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/morgan": "1.9.10",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vitest/ui": "4.0.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 path from 'path';
|
||||
import * as secureFs from './secure-fs.js';
|
||||
|
||||
// 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 (secureFs.existsSync(SESSIONS_FILE)) {
|
||||
const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
|
||||
const sessions = JSON.parse(data) as Array<
|
||||
[string, { createdAt: number; expiresAt: number }]
|
||||
>;
|
||||
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 secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
const sessions = Array.from(validSessions.entries());
|
||||
await secureFs.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 (secureFs.existsSync(API_KEY_FILE)) {
|
||||
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
|
||||
if (key) {
|
||||
console.log('[Auth] Loaded API key from file');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Error reading API key file:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
const newKey = crypto.randomUUID();
|
||||
try {
|
||||
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Auth] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save API key:', error);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
export const {
|
||||
// Async methods
|
||||
access,
|
||||
readFile,
|
||||
writeFile,
|
||||
@@ -20,6 +21,16 @@ export const {
|
||||
lstat,
|
||||
joinPath,
|
||||
resolvePath,
|
||||
// Sync methods
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
accessSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
// Throttling configuration and monitoring
|
||||
configureThrottling,
|
||||
getThrottlingConfig,
|
||||
|
||||
@@ -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,
|
||||
@@ -14,6 +15,32 @@ import type {
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
'TERM',
|
||||
'USER',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables
|
||||
*/
|
||||
function buildEnv(): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'claude';
|
||||
@@ -56,6 +83,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
@@ -107,9 +136,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',
|
||||
|
||||
@@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import * as fs from 'fs';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
@@ -57,13 +57,13 @@ function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unk
|
||||
*/
|
||||
function findActualFilePath(requestedPath: string): string | null {
|
||||
// First, try the exact path
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
if (secureFs.existsSync(requestedPath)) {
|
||||
return requestedPath;
|
||||
}
|
||||
|
||||
// Try with Unicode normalization
|
||||
const normalizedPath = requestedPath.normalize('NFC');
|
||||
if (fs.existsSync(normalizedPath)) {
|
||||
if (secureFs.existsSync(normalizedPath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ function findActualFilePath(requestedPath: string): string | null {
|
||||
const dir = path.dirname(requestedPath);
|
||||
const baseName = path.basename(requestedPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
if (!secureFs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
const files = secureFs.readdirSync(dir);
|
||||
|
||||
// Normalize the requested basename for comparison
|
||||
// Replace various space-like characters with regular space for comparison
|
||||
@@ -281,9 +281,9 @@ export function createDescribeImageHandler(
|
||||
}
|
||||
|
||||
// Log path + stats (this is often where issues start: missing file, perms, size)
|
||||
let stat: fs.Stats | null = null;
|
||||
let stat: ReturnType<typeof secureFs.statSync> | null = null;
|
||||
try {
|
||||
stat = fs.statSync(actualPath);
|
||||
stat = secureFs.statSync(actualPath);
|
||||
logger.info(
|
||||
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
|
||||
);
|
||||
|
||||
@@ -6,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
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createBrowseHandler() {
|
||||
@@ -40,9 +40,16 @@ export function createBrowseHandler() {
|
||||
return drives;
|
||||
};
|
||||
|
||||
// Get parent directory
|
||||
// Get parent directory - only if it's within the allowed root
|
||||
const parentPath = path.dirname(targetPath);
|
||||
const hasParent = parentPath !== targetPath;
|
||||
|
||||
// Determine if parent navigation should be allowed:
|
||||
// 1. Must have a different parent (not at filesystem root)
|
||||
// 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it
|
||||
const hasParent = parentPath !== targetPath && isPathAllowed(parentPath);
|
||||
|
||||
// Security: Don't expose parent path outside allowed root
|
||||
const safeParentPath = hasParent ? parentPath : null;
|
||||
|
||||
// Get available drives
|
||||
const drives = await detectDrives();
|
||||
@@ -70,7 +77,7 @@ export function createBrowseHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: targetPath,
|
||||
parentPath: hasParent ? parentPath : null,
|
||||
parentPath: safeParentPath,
|
||||
directories,
|
||||
drives,
|
||||
});
|
||||
@@ -84,7 +91,7 @@ export function createBrowseHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
currentPath: targetPath,
|
||||
parentPath: hasParent ? parentPath : null,
|
||||
parentPath: safeParentPath,
|
||||
directories: [],
|
||||
drives,
|
||||
warning:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { isPathAllowed } from '@automaker/platform';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
@@ -20,6 +20,20 @@ export function createValidatePathHandler() {
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists
|
||||
if (!isPathAllowed(resolvedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
|
||||
: `Path not allowed: ${filePath}`;
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
isAllowed: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
const stats = await secureFs.stat(resolvedPath);
|
||||
@@ -32,7 +46,7 @@ export function createValidatePathHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
isAllowed: isPathAllowed(resolvedPath),
|
||||
isAllowed: true,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({ success: false, error: 'Path does not exist' });
|
||||
|
||||
@@ -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,30 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*
|
||||
* NOTE: Only the basic health check (/) and environment check are unauthenticated.
|
||||
* The /detailed endpoint requires authentication.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createIndexHandler } from './routes/index.js';
|
||||
import { createDetailedHandler } from './routes/detailed.js';
|
||||
import { createEnvironmentHandler } from './routes/environment.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());
|
||||
|
||||
// Environment info including containerization status
|
||||
// This is unauthenticated so the UI can check on startup
|
||||
router.get('/environment', createEnvironmentHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Re-export detailed handler for use in authenticated routes
|
||||
export { createDetailedHandler } from './routes/detailed.js';
|
||||
|
||||
20
apps/server/src/routes/health/routes/environment.ts
Normal file
20
apps/server/src/routes/health/routes/environment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* GET /environment endpoint - Environment information including containerization status
|
||||
*
|
||||
* This endpoint is unauthenticated so the UI can check it on startup
|
||||
* before login to determine if sandbox risk warnings should be shown.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export interface EnvironmentResponse {
|
||||
isContainerized: boolean;
|
||||
}
|
||||
|
||||
export function createEnvironmentHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
isContainerized: process.env.IS_CONTAINERIZED === 'true',
|
||||
} satisfies EnvironmentResponse);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
@@ -35,36 +35,13 @@ export function getAllApiKeys(): Record<string, string> {
|
||||
|
||||
/**
|
||||
* Helper to persist API keys to .env file
|
||||
* Uses centralized secureFs.writeEnvKey for path validation
|
||||
*/
|
||||
export async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
try {
|
||||
let envContent = '';
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, 'utf-8');
|
||||
} catch {
|
||||
// .env file doesn't exist, we'll create it
|
||||
}
|
||||
|
||||
// Parse existing env content
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
let found = false;
|
||||
const newLines = lines.map((line) => {
|
||||
if (keyRegex.test(line)) {
|
||||
found = true;
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
// Add the key at the end
|
||||
newLines.push(`${key}=${value}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join('\n'));
|
||||
await secureFs.writeEnvKey(envPath, key, value);
|
||||
logger.info(`[Setup] Persisted ${key} to .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to persist ${key} to .env:`, error);
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
||||
import { getApiKey } from './common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -37,42 +35,25 @@ export async function getClaudeStatus() {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, try common locations based on platform
|
||||
const commonPaths = isWindows
|
||||
? (() => {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
return [
|
||||
// Windows-specific paths
|
||||
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
|
||||
path.join(appData, 'npm', 'claude.cmd'),
|
||||
path.join(appData, 'npm', 'claude'),
|
||||
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
|
||||
path.join(appData, '.npm-global', 'bin', 'claude'),
|
||||
];
|
||||
})()
|
||||
: [
|
||||
// Unix (Linux/macOS) paths
|
||||
path.join(os.homedir(), '.local', 'bin', 'claude'),
|
||||
path.join(os.homedir(), '.claude', 'local', 'claude'),
|
||||
'/usr/local/bin/claude',
|
||||
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
|
||||
];
|
||||
// Not in PATH, try common locations from centralized system paths
|
||||
const commonPaths = getClaudeCliPaths();
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
cliPath = p;
|
||||
installed = true;
|
||||
method = 'local';
|
||||
if (await systemPathAccess(p)) {
|
||||
cliPath = p;
|
||||
installed = true;
|
||||
method = 'local';
|
||||
|
||||
// Get version from this path
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
// Get version from this path
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
@@ -82,7 +63,7 @@ export async function getClaudeStatus() {
|
||||
// Check authentication - detect all possible auth methods
|
||||
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
||||
// apiKeys.anthropic stores direct API keys for pay-per-use
|
||||
let auth = {
|
||||
const auth = {
|
||||
authenticated: false,
|
||||
method: 'none' as string,
|
||||
hasCredentialsFile: false,
|
||||
@@ -97,76 +78,36 @@ export async function getClaudeStatus() {
|
||||
hasRecentActivity: false,
|
||||
};
|
||||
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
// Use centralized system paths to check Claude authentication indicators
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
|
||||
// Check for recent Claude CLI activity - indicates working authentication
|
||||
// The stats-cache.json file is only populated when the CLI is working properly
|
||||
const statsCachePath = path.join(claudeDir, 'stats-cache.json');
|
||||
try {
|
||||
const statsContent = await fs.readFile(statsCachePath, 'utf-8');
|
||||
const stats = JSON.parse(statsContent);
|
||||
// Check for recent activity (indicates working authentication)
|
||||
if (indicators.hasStatsCacheWithActivity) {
|
||||
auth.hasRecentActivity = true;
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
|
||||
// Check if there's any activity (which means the CLI is authenticated and working)
|
||||
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
|
||||
auth.hasRecentActivity = true;
|
||||
auth.hasCliAuth = true;
|
||||
// Check for settings + sessions (indicates CLI is set up)
|
||||
if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) {
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
|
||||
// Check credentials file
|
||||
if (indicators.hasCredentialsFile && indicators.credentials) {
|
||||
auth.hasCredentialsFile = true;
|
||||
if (indicators.credentials.hasOAuthToken) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
} catch {
|
||||
// Stats file doesn't exist or is invalid
|
||||
}
|
||||
|
||||
// Check for settings.json - indicates CLI has been set up
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
try {
|
||||
await fs.access(settingsPath);
|
||||
// If settings exist but no activity, CLI might be set up but not authenticated
|
||||
if (!auth.hasCliAuth) {
|
||||
// Try to check for other indicators of auth
|
||||
const sessionsDir = path.join(claudeDir, 'projects');
|
||||
try {
|
||||
const sessions = await fs.readdir(sessionsDir);
|
||||
if (sessions.length > 0) {
|
||||
auth.hasCliAuth = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'cli_authenticated';
|
||||
}
|
||||
} catch {
|
||||
// Sessions directory doesn't exist
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Settings file doesn't exist
|
||||
}
|
||||
|
||||
// Check for credentials file (OAuth tokens from claude login)
|
||||
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
|
||||
const credentialsPaths = [
|
||||
path.join(claudeDir, '.credentials.json'),
|
||||
path.join(claudeDir, 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credentialsPath of credentialsPaths) {
|
||||
try {
|
||||
const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
|
||||
const credentials = JSON.parse(credentialsContent);
|
||||
auth.hasCredentialsFile = true;
|
||||
|
||||
// Check what type of token is in credentials
|
||||
if (credentials.oauth_token || credentials.access_token) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'oauth_token'; // Stored OAuth token from credentials file
|
||||
} else if (credentials.api_key) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'api_key'; // Stored API key in credentials file
|
||||
}
|
||||
break; // Found and processed credentials file
|
||||
} catch {
|
||||
// No credentials file at this path or invalid format
|
||||
auth.method = 'oauth_token';
|
||||
} else if (indicators.credentials.hasApiKey) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = 'api_key';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,21 +115,21 @@ export async function getClaudeStatus() {
|
||||
if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
|
||||
auth.method = 'api_key_env';
|
||||
}
|
||||
|
||||
// In-memory stored OAuth token (from setup wizard - subscription auth)
|
||||
if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
|
||||
auth.authenticated = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
|
||||
auth.method = 'oauth_token';
|
||||
}
|
||||
|
||||
// In-memory stored API key (from settings UI - pay-per-use)
|
||||
if (!auth.authenticated && getApiKey('anthropic')) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = 'api_key'; // Manually stored API key
|
||||
auth.method = 'api_key';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,40 +5,22 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
|
||||
// In-memory storage reference (imported from common.ts pattern)
|
||||
// We need to modify common.ts to export a deleteApiKey function
|
||||
import { setApiKey } from '../common.js';
|
||||
|
||||
/**
|
||||
* Remove an API key from the .env file
|
||||
* Uses centralized secureFs.removeEnvKey for path validation
|
||||
*/
|
||||
async function removeApiKeyFromEnv(key: string): Promise<void> {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
try {
|
||||
let envContent = '';
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, 'utf-8');
|
||||
} catch {
|
||||
// .env file doesn't exist, nothing to delete
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse existing env content and remove the key
|
||||
const lines = envContent.split('\n');
|
||||
const keyRegex = new RegExp(`^${key}=`);
|
||||
const newLines = lines.filter((line) => !keyRegex.test(line));
|
||||
|
||||
// Remove empty lines at the end
|
||||
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
|
||||
newLines.pop();
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : ''));
|
||||
await secureFs.removeEnvKey(envPath, key);
|
||||
logger.info(`[Setup] Removed ${key} from .env file`);
|
||||
} catch (error) {
|
||||
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);
|
||||
|
||||
@@ -5,27 +5,14 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
PATH: getExtendedPath(),
|
||||
};
|
||||
|
||||
export interface GhStatus {
|
||||
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
|
||||
status.path = stdout.trim().split(/\r?\n/)[0];
|
||||
status.installed = true;
|
||||
} catch {
|
||||
// gh not in PATH, try common locations
|
||||
const commonPaths = isWindows
|
||||
? [
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
|
||||
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
|
||||
]
|
||||
: [
|
||||
'/opt/homebrew/bin/gh',
|
||||
'/usr/local/bin/gh',
|
||||
path.join(os.homedir(), '.local', 'bin', 'gh'),
|
||||
'/home/linuxbrew/.linuxbrew/bin/gh',
|
||||
];
|
||||
// gh not in PATH, try common locations from centralized system paths
|
||||
const commonPaths = getGitHubCliPaths();
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
if (await systemPathAccess(p)) {
|
||||
status.path = p;
|
||||
status.installed = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
@@ -94,23 +72,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ export function createSessionsListHandler() {
|
||||
}
|
||||
|
||||
export function createSessionsCreateHandler() {
|
||||
return (req: Request, res: Response): void => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminalService = getTerminalService();
|
||||
const { cwd, cols, rows, shell } = req.body;
|
||||
|
||||
const session = terminalService.createSession({
|
||||
const session = await terminalService.createSession({
|
||||
cwd,
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
|
||||
@@ -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,
|
||||
@@ -172,7 +179,12 @@ export class ClaudeUsageService {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ptyProcess.kill();
|
||||
reject(new Error('Command timed out'));
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
} else {
|
||||
reject(new Error('Command timed out'));
|
||||
}
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
@@ -186,6 +198,13 @@ export class ClaudeUsageService {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -185,9 +185,8 @@ export class FeatureLoader {
|
||||
})) as any[];
|
||||
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
// Load each feature
|
||||
const features: Feature[] = [];
|
||||
for (const dir of featureDirs) {
|
||||
// Load all features concurrently (secureFs has built-in concurrency limiting)
|
||||
const featurePromises = featureDirs.map(async (dir) => {
|
||||
const featureId = dir.name;
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
|
||||
@@ -199,13 +198,13 @@ export class FeatureLoader {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
||||
);
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
features.push(feature);
|
||||
return feature as Feature;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
continue;
|
||||
return null;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
||||
@@ -216,8 +215,12 @@ export class FeatureLoader {
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(featurePromises);
|
||||
const features = results.filter((f): f is Feature => f !== null);
|
||||
|
||||
// Sort by creation order (feature IDs contain timestamp)
|
||||
features.sort((a, b) => {
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
import * as pty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// secureFs is used for user-controllable paths (working directory validation)
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
|
||||
import {
|
||||
systemPathExists,
|
||||
systemPathReadFileSync,
|
||||
getWslVersionPath,
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
@@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
* Uses getShellPaths() to iterate through allowed shell paths
|
||||
*/
|
||||
detectShell(): { shell: string; args: string[] } {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Check if running in WSL
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
// In WSL, prefer the user's configured shell or bash
|
||||
const userShell = process.env.SHELL || '/bin/bash';
|
||||
if (fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to first available POSIX shell
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
}
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'win32': {
|
||||
// Windows: prefer PowerShell, fall back to cmd
|
||||
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
|
||||
if (fs.existsSync(pwshCore)) {
|
||||
return { shell: pwshCore, args: [] };
|
||||
// For all platforms: first try user's shell if set
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(pwsh)) {
|
||||
return { shell: pwsh, args: [] };
|
||||
}
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
|
||||
case 'darwin': {
|
||||
// macOS: prefer user's shell, then zsh, then bash
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/zsh')) {
|
||||
return { shell: '/bin/zsh', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
case 'linux':
|
||||
default: {
|
||||
// Linux: prefer user's shell, then bash, then sh
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/bash')) {
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through allowed shell paths and return first existing one
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallbacks based on platform
|
||||
if (platform === 'win32') {
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,8 +168,9 @@ export class TerminalService extends EventEmitter {
|
||||
isWSL(): boolean {
|
||||
try {
|
||||
// Check /proc/version for Microsoft/WSL indicators
|
||||
if (fs.existsSync('/proc/version')) {
|
||||
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
||||
const wslVersionPath = getWslVersionPath();
|
||||
if (systemPathExists(wslVersionPath)) {
|
||||
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
|
||||
return version.includes('microsoft') || version.includes('wsl');
|
||||
}
|
||||
// Check for WSL environment variable
|
||||
@@ -157,8 +204,9 @@ export class TerminalService extends EventEmitter {
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
* Includes basic sanitization against null bytes and path normalization
|
||||
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
|
||||
*/
|
||||
private resolveWorkingDirectory(requestedCwd?: string): string {
|
||||
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// If no cwd requested, use home
|
||||
@@ -187,15 +235,19 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
// This prevents terminals from being opened in directories outside the allowed workspace
|
||||
try {
|
||||
const stat = fs.statSync(cwd);
|
||||
if (stat.isDirectory()) {
|
||||
const statResult = await secureFs.stat(cwd);
|
||||
if (statResult.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||
console.warn(
|
||||
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
|
||||
);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -228,7 +280,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Create a new terminal session
|
||||
* Returns null if the maximum session limit has been reached
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
@@ -241,12 +293,23 @@ export class TerminalService extends EventEmitter {
|
||||
const shell = options.shell || detectedShell;
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
const cwd = await this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with some useful defaults
|
||||
// These settings ensure consistent terminal behavior across platforms
|
||||
// First, create a clean copy of process.env excluding Automaker-specific variables
|
||||
// that could pollute user shells (e.g., PORT would affect Next.js/other dev servers)
|
||||
const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH'];
|
||||
const cleanEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !automakerEnvVars.includes(key)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'automaker-terminal',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,7 +485,7 @@ Resets in 2h
|
||||
await expect(promise).rejects.toThrow('Authentication required');
|
||||
});
|
||||
|
||||
it('should handle timeout', async () => {
|
||||
it('should handle timeout with no data', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockSpawnProcess.stdout = {
|
||||
@@ -619,7 +619,7 @@ Resets in 2h
|
||||
await expect(promise).rejects.toThrow('Authentication required');
|
||||
});
|
||||
|
||||
it('should handle timeout on Windows', async () => {
|
||||
it('should handle timeout with no data on Windows', async () => {
|
||||
vi.useFakeTimers();
|
||||
const windowsService = new ClaudeUsageService();
|
||||
|
||||
@@ -640,5 +640,69 @@ Resets in 2h
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return data on timeout if data was captured', async () => {
|
||||
vi.useFakeTimers();
|
||||
const windowsService = new ClaudeUsageService();
|
||||
|
||||
let dataCallback: Function | undefined;
|
||||
|
||||
const mockPty = {
|
||||
onData: vi.fn((callback: Function) => {
|
||||
dataCallback = callback;
|
||||
}),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
const promise = windowsService.fetchUsageData();
|
||||
|
||||
// Simulate receiving usage data
|
||||
dataCallback!('Current session\n65% left\nResets in 2h');
|
||||
|
||||
// Advance time past timeout (30 seconds)
|
||||
vi.advanceTimersByTime(31000);
|
||||
|
||||
// Should resolve with data instead of rejecting
|
||||
const result = await promise;
|
||||
expect(result.sessionPercentage).toBe(35); // 100 - 65
|
||||
expect(mockPty.kill).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should send SIGTERM after ESC if process does not exit', async () => {
|
||||
vi.useFakeTimers();
|
||||
const windowsService = new ClaudeUsageService();
|
||||
|
||||
let dataCallback: Function | undefined;
|
||||
|
||||
const mockPty = {
|
||||
onData: vi.fn((callback: Function) => {
|
||||
dataCallback = callback;
|
||||
}),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
|
||||
|
||||
windowsService.fetchUsageData();
|
||||
|
||||
// Simulate seeing usage data
|
||||
dataCallback!('Current session\n65% left');
|
||||
|
||||
// Advance 2s to trigger ESC
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
|
||||
|
||||
// Advance another 2s to trigger SIGTERM fallback
|
||||
vi.advanceTimersByTime(2100);
|
||||
expect(mockPty.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TerminalService, getTerminalService } from '@/services/terminal-service.js';
|
||||
import * as pty from 'node-pty';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as platform from '@automaker/platform';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
|
||||
vi.mock('node-pty');
|
||||
vi.mock('fs');
|
||||
vi.mock('os');
|
||||
vi.mock('@automaker/platform', async () => {
|
||||
const actual = await vi.importActual('@automaker/platform');
|
||||
return {
|
||||
...actual,
|
||||
systemPathExists: vi.fn(),
|
||||
systemPathReadFileSync: vi.fn(),
|
||||
getWslVersionPath: vi.fn(),
|
||||
getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing
|
||||
isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
|
||||
describe('terminal-service.ts', () => {
|
||||
let service: TerminalService;
|
||||
let mockPtyProcess: any;
|
||||
|
||||
// Shell paths for each platform (matching system-paths.ts)
|
||||
const linuxShellPaths = [
|
||||
'/bin/zsh',
|
||||
'/bin/bash',
|
||||
'/bin/sh',
|
||||
'/usr/bin/zsh',
|
||||
'/usr/bin/bash',
|
||||
'/usr/bin/sh',
|
||||
'/usr/local/bin/zsh',
|
||||
'/usr/local/bin/bash',
|
||||
'/opt/homebrew/bin/zsh',
|
||||
'/opt/homebrew/bin/bash',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sh',
|
||||
];
|
||||
|
||||
const windowsShellPaths = [
|
||||
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe',
|
||||
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
'C:\\Windows\\System32\\cmd.exe',
|
||||
'pwsh.exe',
|
||||
'pwsh',
|
||||
'powershell.exe',
|
||||
'powershell',
|
||||
'cmd.exe',
|
||||
'cmd',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TerminalService();
|
||||
@@ -29,6 +71,13 @@ describe('terminal-service.ts', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
|
||||
// Default mocks for system paths and secureFs
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('');
|
||||
vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version');
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,7 +87,8 @@ describe('terminal-service.ts', () => {
|
||||
describe('detectShell', () => {
|
||||
it('should detect PowerShell Core on Windows when available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
});
|
||||
|
||||
@@ -50,7 +100,8 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
it('should fall back to PowerShell on Windows if Core not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
});
|
||||
|
||||
@@ -62,7 +113,8 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
it('should fall back to cmd.exe on Windows if no PowerShell', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -73,7 +125,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect user shell on macOS', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -84,7 +136,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to zsh on macOS if user shell not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/zsh';
|
||||
});
|
||||
|
||||
@@ -97,7 +149,10 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to bash on macOS if zsh not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
// zsh not available, but bash is
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/bash';
|
||||
});
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -108,7 +163,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect user shell on Linux', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -119,7 +174,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to bash on Linux if user shell not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
|
||||
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
|
||||
return path === '/bin/bash';
|
||||
});
|
||||
|
||||
@@ -132,7 +187,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should fall back to sh on Linux if bash not available', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -143,8 +198,10 @@ describe('terminal-service.ts', () => {
|
||||
it('should detect WSL and use appropriate shell', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
|
||||
'Linux version 5.10.0-microsoft-standard-WSL2'
|
||||
);
|
||||
|
||||
const result = service.detectShell();
|
||||
|
||||
@@ -155,43 +212,45 @@ describe('terminal-service.ts', () => {
|
||||
|
||||
describe('isWSL', () => {
|
||||
it('should return true if /proc/version contains microsoft', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
|
||||
'Linux version 5.10.0-microsoft-standard-WSL2'
|
||||
);
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if /proc/version contains wsl', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if WSL_DISTRO_NAME is set', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if WSLENV is set', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' });
|
||||
|
||||
expect(service.isWSL()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if not in WSL', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(false);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({});
|
||||
|
||||
expect(service.isWSL()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if error reading /proc/version', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
@@ -203,7 +262,7 @@ describe('terminal-service.ts', () => {
|
||||
it('should return platform information', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.arch).mockReturnValue('x64');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const info = service.getPlatformInfo();
|
||||
@@ -216,20 +275,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new terminal session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should create a new terminal session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/test/dir',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
expect(session.id).toMatch(/^term-/);
|
||||
expect(session.cwd).toBe('/test/dir');
|
||||
expect(session.shell).toBe('/bin/bash');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toMatch(/^term-/);
|
||||
expect(session!.cwd).toBe('/test/dir');
|
||||
expect(session!.shell).toBe('/bin/bash');
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
'/bin/bash',
|
||||
['--login'],
|
||||
@@ -241,12 +301,12 @@ describe('terminal-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default cols and rows if not provided', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should use default cols and rows if not provided', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
expect(pty.spawn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -258,66 +318,68 @@ describe('terminal-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to home directory if cwd does not exist', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
it('should fall back to home directory if cwd does not exist', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/nonexistent',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/home/user');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should fall back to home directory if cwd is not a directory', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
it('should fall back to home directory if cwd is not a directory', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '/file.txt',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/home/user');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should fix double slashes in path', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should fix double slashes in path', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '//test/dir',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('/test/dir');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('/test/dir');
|
||||
});
|
||||
|
||||
it('should preserve WSL UNC paths', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should preserve WSL UNC paths', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession({
|
||||
const session = await service.createSession({
|
||||
cwd: '//wsl$/Ubuntu/home',
|
||||
});
|
||||
|
||||
expect(session.cwd).toBe('//wsl$/Ubuntu/home');
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.cwd).toBe('//wsl$/Ubuntu/home');
|
||||
});
|
||||
|
||||
it('should handle data events from PTY', () => {
|
||||
it('should handle data events from PTY', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const dataCallback = vi.fn();
|
||||
service.onData(dataCallback);
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
// Simulate data event
|
||||
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
|
||||
@@ -331,33 +393,34 @@ describe('terminal-service.ts', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle exit events from PTY', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle exit events from PTY', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const exitCallback = vi.fn();
|
||||
service.onExit(exitCallback);
|
||||
|
||||
const session = service.createSession();
|
||||
const session = await service.createSession();
|
||||
|
||||
// Simulate exit event
|
||||
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
|
||||
onExitHandler({ exitCode: 0 });
|
||||
|
||||
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
expect(session).not.toBeNull();
|
||||
expect(exitCallback).toHaveBeenCalledWith(session!.id, 0);
|
||||
expect(service.getSession(session!.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
it('should write data to existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should write data to existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.write(session.id, 'ls\n');
|
||||
const session = await service.createSession();
|
||||
const result = service.write(session!.id, 'ls\n');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
|
||||
@@ -372,13 +435,13 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
it('should resize existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should resize existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
const session = await service.createSession();
|
||||
const result = service.resize(session!.id, 120, 40);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
|
||||
@@ -391,30 +454,30 @@ describe('terminal-service.ts', () => {
|
||||
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle resize errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle resize errors', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.resize.mockImplementation(() => {
|
||||
throw new Error('Resize failed');
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.resize(session.id, 120, 40);
|
||||
const session = await service.createSession();
|
||||
const result = service.resize(session!.id, 120, 40);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killSession', () => {
|
||||
it('should kill existing session', () => {
|
||||
it('should kill existing session', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const result = service.killSession(session!.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
@@ -423,7 +486,7 @@ describe('terminal-service.ts', () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(service.getSession(session.id)).toBeUndefined();
|
||||
expect(service.getSession(session!.id)).toBeUndefined();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -434,29 +497,29 @@ describe('terminal-service.ts', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle kill errors', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle kill errors', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
const session = service.createSession();
|
||||
const result = service.killSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const result = service.killSession(session!.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
const retrieved = service.getSession(session.id);
|
||||
const session = await service.createSession();
|
||||
const retrieved = service.getSession(session!.id);
|
||||
|
||||
expect(retrieved).toBe(session);
|
||||
});
|
||||
@@ -469,15 +532,15 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getScrollback', () => {
|
||||
it('should return scrollback buffer for existing session', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return scrollback buffer for existing session', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session = service.createSession();
|
||||
session.scrollbackBuffer = 'test scrollback';
|
||||
const session = await service.createSession();
|
||||
session!.scrollbackBuffer = 'test scrollback';
|
||||
|
||||
const scrollback = service.getScrollback(session.id);
|
||||
const scrollback = service.getScrollback(session!.id);
|
||||
|
||||
expect(scrollback).toBe('test scrollback');
|
||||
});
|
||||
@@ -490,19 +553,21 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('getAllSessions', () => {
|
||||
it('should return all active sessions', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should return all active sessions', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session1 = service.createSession({ cwd: '/dir1' });
|
||||
const session2 = service.createSession({ cwd: '/dir2' });
|
||||
const session1 = await service.createSession({ cwd: '/dir1' });
|
||||
const session2 = await service.createSession({ cwd: '/dir2' });
|
||||
|
||||
const sessions = service.getAllSessions();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].id).toBe(session1.id);
|
||||
expect(sessions[1].id).toBe(session2.id);
|
||||
expect(session1).not.toBeNull();
|
||||
expect(session2).not.toBeNull();
|
||||
expect(sessions[0].id).toBe(session1!.id);
|
||||
expect(sessions[1].id).toBe(session2!.id);
|
||||
expect(sessions[0].cwd).toBe('/dir1');
|
||||
expect(sessions[1].cwd).toBe('/dir2');
|
||||
});
|
||||
@@ -535,30 +600,32 @@ describe('terminal-service.ts', () => {
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up all sessions', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should clean up all sessions', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
|
||||
const session1 = service.createSession();
|
||||
const session2 = service.createSession();
|
||||
const session1 = await service.createSession();
|
||||
const session2 = await service.createSession();
|
||||
|
||||
service.cleanup();
|
||||
|
||||
expect(service.getSession(session1.id)).toBeUndefined();
|
||||
expect(service.getSession(session2.id)).toBeUndefined();
|
||||
expect(session1).not.toBeNull();
|
||||
expect(session2).not.toBeNull();
|
||||
expect(service.getSession(session1!.id)).toBeUndefined();
|
||||
expect(service.getSession(session2!.id)).toBeUndefined();
|
||||
expect(service.getAllSessions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle cleanup errors gracefully', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it('should handle cleanup errors gracefully', async () => {
|
||||
vi.mocked(platform.systemPathExists).mockReturnValue(true);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
|
||||
mockPtyProcess.kill.mockImplementation(() => {
|
||||
throw new Error('Kill failed');
|
||||
});
|
||||
|
||||
service.createSession();
|
||||
await service.createSession();
|
||||
|
||||
expect(() => service.cleanup()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -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;"]
|
||||
@@ -10,6 +10,9 @@
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,95 +30,95 @@
|
||||
"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",
|
||||
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "^1.0.0",
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.6",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-router": "1.141.6",
|
||||
"@uiw/react-codemirror": "4.25.4",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/addon-search": "0.15.0",
|
||||
"@xterm/addon-web-links": "0.11.0",
|
||||
"@xterm/addon-webgl": "0.18.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"@xyflow/react": "12.10.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"dagre": "0.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"geist": "1.5.1",
|
||||
"lucide-react": "0.562.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zustand": "^5.0.9"
|
||||
"react-markdown": "10.1.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"rehype-raw": "7.0.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "^1.29.2",
|
||||
"lightningcss-darwin-x64": "^1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "^1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "^1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-x64-musl": "^1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "^1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "^1.29.2"
|
||||
"lightningcss-darwin-arm64": "1.29.2",
|
||||
"lightningcss-darwin-x64": "1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "1.29.2",
|
||||
"lightningcss-linux-x64-musl": "1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "1.29.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/router-plugin": "^1.141.7",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.50.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"@electron/rebuild": "4.0.2",
|
||||
"@eslint/js": "9.0.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@tanstack/router-plugin": "1.141.7",
|
||||
"@types/dagre": "0.7.53",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
||||
"@typescript-eslint/parser": "8.50.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"cross-env": "10.1.0",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"electron-builder": "26.0.12",
|
||||
"eslint": "9.39.2",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
"vite": "7.3.0",
|
||||
"vite-plugin-electron": "0.29.0",
|
||||
"vite-plugin-electron-renderer": "0.14.6"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.automaker.app",
|
||||
|
||||
@@ -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,17 +32,25 @@ 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
|
||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true',
|
||||
},
|
||||
},
|
||||
// Frontend Vite dev server
|
||||
@@ -51,8 +62,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);
|
||||
|
||||
@@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-
|
||||
export { DeleteSessionDialog } from './delete-session-dialog';
|
||||
export { FileBrowserDialog } from './file-browser-dialog';
|
||||
export { NewProjectModal } from './new-project-modal';
|
||||
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
||||
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
||||
export { WorkspacePickerModal } from './workspace-picker-modal';
|
||||
|
||||
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
90
apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Sandbox Rejection Screen
|
||||
*
|
||||
* Shown in web mode when user denies the sandbox risk confirmation.
|
||||
* Prompts them to either restart the app in a container or reload to try again.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRejectionScreen() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleReload = () => {
|
||||
// Clear the rejection state and reload
|
||||
sessionStorage.removeItem('automaker-sandbox-denied');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-destructive/10 p-4">
|
||||
<ShieldX className="w-12 h-12 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Access Denied</h1>
|
||||
<p className="text-muted-foreground">
|
||||
You declined to accept the risks of running Automaker outside a sandbox environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Automaker in a containerized sandbox environment:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReload}
|
||||
className="gap-2"
|
||||
data-testid="sandbox-retry"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload & Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
112
apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Sandbox Risk Confirmation Dialog
|
||||
*
|
||||
* Shows when the app is running outside a containerized environment.
|
||||
* Users must acknowledge the risks before proceeding.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SandboxRiskDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DOCKER_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-lg"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
Sandbox Environment Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="text-muted-foreground">
|
||||
<strong>Warning:</strong> This application is running outside of a containerized
|
||||
sandbox environment. AI agents will have direct access to your filesystem and can
|
||||
execute commands on your system.
|
||||
</p>
|
||||
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>Agents can read, modify, or delete files on your system</li>
|
||||
<li>Agents can execute arbitrary commands and install software</li>
|
||||
<li>Agents can access environment variables and credentials</li>
|
||||
<li>Unintended side effects from agent actions may affect your system</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For safer operation, consider running Automaker in Docker:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
|
||||
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-8 px-2 hover:bg-muted"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||
Deny & Exit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="px-4"
|
||||
data-testid="sandbox-confirm"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
||||
import { isElectron } from '@/lib/electron';
|
||||
import { getItem, removeItem } from '@/lib/storage';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -99,6 +99,10 @@ export function useSettingsMigration(): MigrationState {
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for API key to be initialized before making any API calls
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
await waitForApiKeyInit();
|
||||
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Check if server has settings files
|
||||
@@ -189,13 +193,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 +231,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 +256,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 +264,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 +284,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 +305,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 +322,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,8 @@ export interface SaveImageResult {
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
quit?: () => Promise<void>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
openDirectory: () => Promise<DialogResult>;
|
||||
openFile: (options?: object) => Promise<DialogResult>;
|
||||
@@ -2670,6 +2691,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 +2809,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,283 @@ 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;
|
||||
let apiKeyInitPromise: Promise<void> | null = null;
|
||||
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* Wait for API key initialization to complete.
|
||||
* Returns immediately if already initialized.
|
||||
*/
|
||||
export const waitForApiKeyInit = (): Promise<void> => {
|
||||
if (apiKeyInitialized) return Promise.resolve();
|
||||
if (apiKeyInitPromise) return apiKeyInitPromise;
|
||||
// If not started yet, start it now
|
||||
return initApiKey();
|
||||
};
|
||||
|
||||
// Get session token for Web mode (returns cached value after login or token fetch)
|
||||
export const getSessionToken = (): string | null => cachedSessionToken;
|
||||
|
||||
// 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> => {
|
||||
// Return existing promise if already in progress
|
||||
if (apiKeyInitPromise) return apiKeyInitPromise;
|
||||
|
||||
// Return immediately if already initialized
|
||||
if (apiKeyInitialized) return;
|
||||
|
||||
// Create and store the promise so concurrent calls wait for the same initialization
|
||||
apiKeyInitPromise = (async () => {
|
||||
try {
|
||||
// Only Electron mode uses API key header auth
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
|
||||
try {
|
||||
cachedApiKey = await window.electronAPI.getApiKey();
|
||||
if (cachedApiKey) {
|
||||
console.log('[HTTP Client] Using API key from Electron');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// In web mode, authentication is handled via HTTP-only cookies
|
||||
console.log('[HTTP Client] Web mode - using cookie-based authentication');
|
||||
} finally {
|
||||
// Mark as initialized after completion, regardless of success or failure
|
||||
apiKeyInitialized = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return apiKeyInitPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the server is running in a containerized (sandbox) environment.
|
||||
* This endpoint is unauthenticated so it can be checked before login.
|
||||
*/
|
||||
export const checkSandboxEnvironment = async (): Promise<{
|
||||
isContainerized: boolean;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[HTTP Client] Failed to check sandbox environment');
|
||||
return { isContainerized: false, error: 'Failed to check environment' };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { isContainerized: data.isContainerized ?? false };
|
||||
} catch (error) {
|
||||
console.error('[HTTP Client] Sandbox environment check failed:', error);
|
||||
return { isContainerized: false, error: 'Network error' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EventType =
|
||||
@@ -76,7 +347,55 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrl();
|
||||
this.connectWebSocket();
|
||||
// Wait for API key initialization before connecting WebSocket
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
waitForApiKeyInit()
|
||||
.then(() => {
|
||||
this.connectWebSocket();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
||||
// Still attempt WebSocket connection - it may work with cookie auth
|
||||
this.connectWebSocket();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -86,8 +405,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,41 +503,64 @@ 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;
|
||||
}
|
||||
|
||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
@@ -264,14 +635,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
const result = await this.post<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
isAllowed?: boolean;
|
||||
error?: string;
|
||||
}>('/api/fs/validate-path', { filePath: path });
|
||||
|
||||
if (result.success && result.path) {
|
||||
if (result.success && result.path && result.isAllowed !== false) {
|
||||
return { canceled: false, filePaths: [result.path] };
|
||||
}
|
||||
|
||||
console.error('Invalid directory:', result.error);
|
||||
console.error('Invalid directory:', result.error || 'Path not allowed');
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
@@ -766,6 +1138,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
|
||||
@@ -1282,3 +1656,10 @@ export function getHttpApiClient(): HttpApiClient {
|
||||
}
|
||||
return httpApiClientInstance;
|
||||
}
|
||||
|
||||
// Start API key initialization immediately when this module is imported
|
||||
// This ensures the init promise is created early, even before React components mount
|
||||
// The actual async work happens in the background and won't block module loading
|
||||
initApiKey().catch((error) => {
|
||||
console.error('[HTTP Client] Failed to initialize API key:', error);
|
||||
});
|
||||
|
||||
@@ -3,14 +3,36 @@
|
||||
*
|
||||
* This version spawns the backend server and uses HTTP API for most operations.
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
*
|
||||
* SECURITY: All file system access uses centralized methods from @automaker/platform.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
import {
|
||||
findNodeExecutable,
|
||||
buildEnhancedPath,
|
||||
initAllowedPaths,
|
||||
isPathAllowed,
|
||||
getAllowedRootDirectory,
|
||||
// Electron userData operations
|
||||
setElectronUserDataPath,
|
||||
electronUserDataReadFileSync,
|
||||
electronUserDataWriteFileSync,
|
||||
electronUserDataExists,
|
||||
// Electron app bundle operations
|
||||
setElectronAppPaths,
|
||||
electronAppExists,
|
||||
electronAppReadFileSync,
|
||||
electronAppStatSync,
|
||||
electronAppStat,
|
||||
electronAppReadFile,
|
||||
// System path operations
|
||||
systemPathExists,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
@@ -59,8 +81,47 @@ 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 the relative path to API key file within userData
|
||||
*/
|
||||
const API_KEY_FILENAME = '.api-key';
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - load from file or generate new one.
|
||||
* This key is passed to the server for CSRF protection.
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
try {
|
||||
if (electronUserDataExists(API_KEY_FILENAME)) {
|
||||
const key = electronUserDataReadFileSync(API_KEY_FILENAME).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 {
|
||||
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
console.log('[Electron] Generated new API key');
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to save API key:', error);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
* Uses centralized electronApp methods for path validation.
|
||||
*/
|
||||
function getIconPath(): string | null {
|
||||
let iconFile: string;
|
||||
@@ -76,8 +137,13 @@ function getIconPath(): string | null {
|
||||
? path.join(__dirname, '../public', iconFile)
|
||||
: path.join(__dirname, '../dist/public', iconFile);
|
||||
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
try {
|
||||
if (!electronAppExists(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Electron] Icon check failed: ${iconPath}`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -85,20 +151,18 @@ function getIconPath(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to window bounds settings file
|
||||
* Relative path to window bounds settings file within userData
|
||||
*/
|
||||
function getWindowBoundsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'window-bounds.json');
|
||||
}
|
||||
const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
|
||||
|
||||
/**
|
||||
* Load saved window bounds from disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function loadWindowBounds(): WindowBounds | null {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
if (fs.existsSync(boundsPath)) {
|
||||
const data = fs.readFileSync(boundsPath, 'utf-8');
|
||||
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
|
||||
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
|
||||
const bounds = JSON.parse(data) as WindowBounds;
|
||||
// Validate the loaded data has required fields
|
||||
if (
|
||||
@@ -118,11 +182,11 @@ function loadWindowBounds(): WindowBounds | null {
|
||||
|
||||
/**
|
||||
* Save window bounds to disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function saveWindowBounds(bounds: WindowBounds): void {
|
||||
try {
|
||||
const boundsPath = getWindowBoundsPath();
|
||||
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
|
||||
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
|
||||
console.log('[Electron] Window bounds saved');
|
||||
} catch (error) {
|
||||
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
|
||||
@@ -200,6 +264,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
* Uses centralized electronApp methods for serving static files from app bundle.
|
||||
*/
|
||||
async function startStaticServer(): Promise<void> {
|
||||
const staticPath = path.join(__dirname, '../dist');
|
||||
@@ -212,20 +277,24 @@ async function startStaticServer(): Promise<void> {
|
||||
} else if (!path.extname(filePath)) {
|
||||
// For client-side routing, serve index.html for paths without extensions
|
||||
const possibleFile = filePath + '.html';
|
||||
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
|
||||
try {
|
||||
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (electronAppExists(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
} catch {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (fs.existsSync(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
}
|
||||
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
electronAppStat(filePath, (err, stats) => {
|
||||
if (err || !stats?.isFile()) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
electronAppReadFile(filePath, (error, content) => {
|
||||
if (error || !content) {
|
||||
response.writeHead(500);
|
||||
response.end('Server Error');
|
||||
return;
|
||||
@@ -267,6 +336,7 @@ async function startStaticServer(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
* Uses centralized methods for path validation.
|
||||
*/
|
||||
async function startServer(): Promise<void> {
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
@@ -277,8 +347,20 @@ async function startServer(): Promise<void> {
|
||||
const command = nodeResult.nodePath;
|
||||
|
||||
// Validate that the found Node executable actually exists
|
||||
if (command !== 'node' && !fs.existsSync(command)) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
// systemPathExists is used because node-finder returns system paths
|
||||
if (command !== 'node') {
|
||||
let exists: boolean;
|
||||
try {
|
||||
exists = systemPathExists(command);
|
||||
} catch (error) {
|
||||
const originalError = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
|
||||
);
|
||||
}
|
||||
if (!exists) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
}
|
||||
}
|
||||
|
||||
let args: string[];
|
||||
@@ -291,11 +373,22 @@ async function startServer(): Promise<void> {
|
||||
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
|
||||
|
||||
let tsxCliPath: string;
|
||||
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
} else {
|
||||
// Check for tsx in app bundle paths
|
||||
try {
|
||||
if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
} else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
} else {
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
@@ -310,7 +403,11 @@ async function startServer(): Promise<void> {
|
||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||
args = [serverPath];
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
try {
|
||||
if (!electronAppExists(serverPath)) {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
}
|
||||
@@ -319,6 +416,13 @@ async function startServer(): Promise<void> {
|
||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||
: path.join(__dirname, '../../server/node_modules');
|
||||
|
||||
// Server root directory - where .env file is located
|
||||
// In dev: apps/server (not apps/server/src)
|
||||
// In production: resources/server
|
||||
const serverRoot = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server')
|
||||
: path.join(__dirname, '../../server');
|
||||
|
||||
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||
if (enhancedPath !== process.env.PATH) {
|
||||
@@ -331,6 +435,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 && {
|
||||
@@ -340,10 +446,11 @@ async function startServer(): Promise<void> {
|
||||
|
||||
console.log('[Electron] Starting backend server...');
|
||||
console.log('[Electron] Server path:', serverPath);
|
||||
console.log('[Electron] Server root (cwd):', serverRoot);
|
||||
console.log('[Electron] NODE_PATH:', serverNodeModules);
|
||||
|
||||
serverProcess = spawn(command, args, {
|
||||
cwd: path.dirname(serverPath),
|
||||
cwd: serverRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -498,6 +605,28 @@ app.whenReady().then(async () => {
|
||||
console.warn('[Electron] Failed to set userData path:', (error as Error).message);
|
||||
}
|
||||
|
||||
// Initialize centralized path helpers for Electron
|
||||
// This must be done before any file operations
|
||||
setElectronUserDataPath(app.getPath('userData'));
|
||||
|
||||
// In development mode, allow access to the entire project root (for source files, node_modules, etc.)
|
||||
// In production, only allow access to the built app directory and resources
|
||||
if (isDev) {
|
||||
// __dirname is apps/ui/dist-electron, so go up 3 levels to get project root
|
||||
const projectRoot = path.join(__dirname, '../../..');
|
||||
setElectronAppPaths([__dirname, projectRoot]);
|
||||
} else {
|
||||
setElectronAppPaths(__dirname, process.resourcesPath);
|
||||
}
|
||||
console.log('[Electron] Initialized path security helpers');
|
||||
|
||||
// Initialize security settings for path validation
|
||||
// Set DATA_DIR before initializing so it's available for security checks
|
||||
process.env.DATA_DIR = app.getPath('userData');
|
||||
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
|
||||
// (it will be passed to server process, but we also need it in main process for dialog validation)
|
||||
initAllowedPaths();
|
||||
|
||||
if (process.platform === 'darwin' && app.dock) {
|
||||
const iconPath = getIconPath();
|
||||
if (iconPath) {
|
||||
@@ -509,6 +638,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) {
|
||||
@@ -549,9 +681,20 @@ app.on('window-all-closed', () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (serverProcess) {
|
||||
if (serverProcess && serverProcess.pid) {
|
||||
console.log('[Electron] Stopping server...');
|
||||
serverProcess.kill();
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
// Windows: use taskkill with /t to kill entire process tree
|
||||
// This prevents orphaned node processes when closing the app
|
||||
// Using execSync to ensure process is killed before app exits
|
||||
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
console.error('[Electron] Failed to kill server process:', (error as Error).message);
|
||||
}
|
||||
} else {
|
||||
serverProcess.kill('SIGTERM');
|
||||
}
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
@@ -574,6 +717,22 @@ ipcMain.handle('dialog:openDirectory', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
|
||||
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
if (!isPathAllowed(selectedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
|
||||
: 'The selected directory is not allowed.';
|
||||
|
||||
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
|
||||
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -666,6 +825,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) => {
|
||||
@@ -674,3 +838,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
|
||||
// Always use the smaller minimum width - horizontal scrolling handles any overflow
|
||||
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
|
||||
});
|
||||
|
||||
// Quit the application (used when user denies sandbox risk confirmation)
|
||||
ipcMain.handle('app:quit', () => {
|
||||
console.log('[Electron] Quitting application via IPC request');
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
@@ -47,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Window management
|
||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||
|
||||
// App control
|
||||
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
|
||||
});
|
||||
|
||||
console.log('[Preload] Electron API exposed (TypeScript)');
|
||||
|
||||
@@ -8,9 +8,21 @@ import {
|
||||
} from '@/contexts/file-browser-context';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||
import {
|
||||
initApiKey,
|
||||
isElectronMode,
|
||||
verifySession,
|
||||
checkSandboxEnvironment,
|
||||
} from '@/lib/http-api-client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||
|
||||
// Session storage key for sandbox risk acknowledgment
|
||||
const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged';
|
||||
const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied';
|
||||
|
||||
function RootLayoutContent() {
|
||||
const location = useLocation();
|
||||
@@ -22,8 +34,24 @@ function RootLayoutContent() {
|
||||
const [setupHydrated, setSetupHydrated] = useState(
|
||||
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
||||
);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Sandbox environment check state
|
||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => {
|
||||
// Check if user previously denied in this session
|
||||
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) {
|
||||
return 'denied';
|
||||
}
|
||||
// Check if user previously acknowledged in this session
|
||||
if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) {
|
||||
return 'confirmed';
|
||||
}
|
||||
return 'pending';
|
||||
});
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
@@ -70,6 +98,110 @@ function RootLayoutContent() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Check sandbox environment on mount
|
||||
useEffect(() => {
|
||||
// Skip if already decided
|
||||
if (sandboxStatus !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSandbox = async () => {
|
||||
try {
|
||||
const result = await checkSandboxEnvironment();
|
||||
|
||||
if (result.isContainerized) {
|
||||
// Running in a container, no warning needed
|
||||
setSandboxStatus('containerized');
|
||||
} else {
|
||||
// Not containerized, show warning dialog
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to check environment:', error);
|
||||
// On error, assume not containerized and show warning
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
};
|
||||
|
||||
checkSandbox();
|
||||
}, [sandboxStatus]);
|
||||
|
||||
// Handle sandbox risk confirmation
|
||||
const handleSandboxConfirm = useCallback(() => {
|
||||
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
|
||||
setSandboxStatus('confirmed');
|
||||
}, []);
|
||||
|
||||
// Handle sandbox risk denial
|
||||
const handleSandboxDeny = useCallback(async () => {
|
||||
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
|
||||
|
||||
if (isElectron()) {
|
||||
// In Electron mode, quit the application
|
||||
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
|
||||
try {
|
||||
const electronAPI = window.electronAPI;
|
||||
if (electronAPI?.quit) {
|
||||
await electronAPI.quit();
|
||||
} else {
|
||||
console.error('[Sandbox] quit() not available on electronAPI');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to quit app:', error);
|
||||
}
|
||||
} else {
|
||||
// In web mode, show rejection screen
|
||||
setSandboxStatus('denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize authentication
|
||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||
// - Web mode: Uses HTTP-only session cookie
|
||||
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,13 +279,63 @@ 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 rejection screen if user denied sandbox risk (web mode only)
|
||||
if (sandboxStatus === 'denied' && !isElectron()) {
|
||||
return <SandboxRejectionScreen />;
|
||||
}
|
||||
|
||||
// Show loading while checking sandbox environment
|
||||
if (sandboxStatus === 'pending') {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<div className="text-muted-foreground">Checking environment...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login page (full screen, no sidebar)
|
||||
if (isLoginRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
{/* Show sandbox dialog on top of login page if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
{/* Show sandbox dialog on top of setup page if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -175,6 +357,13 @@ function RootLayoutContent() {
|
||||
}`}
|
||||
/>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
|
||||
{/* Show sandbox dialog if needed */}
|
||||
<SandboxRiskDialog
|
||||
open={sandboxStatus === 'needs-confirmation'}
|
||||
onConfirm={handleSandboxConfirm}
|
||||
onDeny={handleSandboxDeny}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -172,10 +172,12 @@ export const useSetupStore = create<SetupState & SetupActions>()(
|
||||
}),
|
||||
{
|
||||
name: 'automaker-setup',
|
||||
version: 1, // Add version field for proper hydration (matches app-store pattern)
|
||||
partialize: (state) => ({
|
||||
isFirstRun: state.isFirstRun,
|
||||
setupComplete: state.setupComplete,
|
||||
skipClaudeSetup: state.skipClaudeSetup,
|
||||
claudeAuthStatus: state.claudeAuthStatus,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
2
apps/ui/src/types/electron.d.ts
vendored
2
apps/ui/src/types/electron.d.ts
vendored
@@ -464,6 +464,8 @@ export interface AutoModeAPI {
|
||||
|
||||
export interface ElectronAPI {
|
||||
ping: () => Promise<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
quit?: () => Promise<void>;
|
||||
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Dialog APIs
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user