mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
108 Commits
coderabbit
...
v0.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019793e047 | ||
|
|
a8a3711246 | ||
|
|
b867ca1407 | ||
|
|
75143c0792 | ||
|
|
f32f3e82b2 | ||
|
|
abe272ef4d | ||
|
|
6d4ab9cc13 | ||
|
|
98381441b9 | ||
|
|
eae60ab6b9 | ||
|
|
1d7b64cea8 | ||
|
|
6337e266c5 | ||
|
|
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 | ||
|
|
0e1e855cc5 | ||
|
|
69a847fe8c | ||
|
|
6f2402e16d | ||
|
|
bacd4f385d | ||
|
|
cc42b79fbc | ||
|
|
eaeb503ee7 | ||
|
|
d028932dc8 | ||
|
|
6bdac230df | ||
|
|
43728e451e | ||
|
|
b93b59951b | ||
|
|
b5a8ed229c | ||
|
|
97ae4b6362 | ||
|
|
5a1e53ca7c | ||
|
|
876d383936 | ||
|
|
96196f906f | ||
|
|
0ee9313441 | ||
|
|
496ace8a8e | ||
|
|
0a21c11a35 | ||
|
|
a526869f21 | ||
|
|
789b807542 | ||
|
|
35b3d3931e | ||
|
|
bad4393dda | ||
|
|
6012e8312b | ||
|
|
8f458e55e2 | ||
|
|
61881d99e2 | ||
|
|
3c719f05a1 | ||
|
|
9cba2e509a | ||
|
|
c61eaff525 | ||
|
|
ef0a96182a | ||
|
|
a680f3a9c1 | ||
|
|
ea6a39c6ab | ||
|
|
f0c2860dec | ||
|
|
1321a8bd4d | ||
|
|
85dfabec0a | ||
|
|
15dca79fb7 | ||
|
|
e9b366fa18 | ||
|
|
145dcf4b97 | ||
|
|
5f328a4c13 |
74
.claude/commands/gh-issue.md
Normal file
74
.claude/commands/gh-issue.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# GitHub Issue Fix Command
|
||||
|
||||
Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid.
|
||||
|
||||
## Usage
|
||||
|
||||
This command accepts a GitHub issue number as input (e.g., `123`).
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Get the issue number from the user**
|
||||
- The issue number should be provided as an argument to this command
|
||||
- If no number is provided, ask the user for it
|
||||
|
||||
2. **Fetch the GitHub issue**
|
||||
- Determine the current project path (check if there's a current project context)
|
||||
- Verify the project has a GitHub remote:
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
- Fetch the issue details using GitHub CLI:
|
||||
```bash
|
||||
gh issue view <ISSUE_NUMBER> --json number,title,state,author,createdAt,labels,url,body,assignees
|
||||
```
|
||||
- If the command fails, report the error and stop
|
||||
|
||||
3. **Verify the issue is real and valid**
|
||||
- Check that the issue exists (not 404)
|
||||
- Check the issue state:
|
||||
- If **closed**: Inform the user and ask if they still want to proceed
|
||||
- If **open**: Proceed with validation
|
||||
- Review the issue content:
|
||||
- Read the title and body to understand what needs to be fixed
|
||||
- Check labels for context (bug, enhancement, etc.)
|
||||
- Note any assignees or linked PRs
|
||||
|
||||
4. **Validate the issue**
|
||||
- Determine if this is a legitimate issue that needs fixing:
|
||||
- Is the description clear and actionable?
|
||||
- Does it describe a real problem or feature request?
|
||||
- Are there any obvious signs it's spam or invalid?
|
||||
- If the issue seems invalid or unclear:
|
||||
- Report findings to the user
|
||||
- Ask if they want to proceed anyway
|
||||
- Stop if user confirms it's not valid
|
||||
|
||||
5. **If the issue is valid, proceed to fix it**
|
||||
- Analyze what needs to be done based on the issue description
|
||||
- Check the current codebase state:
|
||||
- Run relevant tests to see current behavior
|
||||
- Check if the issue is already fixed
|
||||
- Look for related code that might need changes
|
||||
- Implement the fix:
|
||||
- Make necessary code changes
|
||||
- Update or add tests as needed
|
||||
- Ensure the fix addresses the issue description
|
||||
- Verify the fix:
|
||||
- Run tests to ensure nothing broke
|
||||
- If possible, manually verify the fix addresses the issue
|
||||
|
||||
6. **Report summary**
|
||||
- Issue number and title
|
||||
- Issue state (open/closed)
|
||||
- Whether the issue was validated as real
|
||||
- What was fixed (if anything)
|
||||
- Any tests that were updated or added
|
||||
- Next steps (if any)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop
|
||||
- If the project doesn't have a GitHub remote, report error and stop
|
||||
- If the issue number doesn't exist, report error and stop
|
||||
- If the issue is unclear or invalid, report findings and ask user before proceeding
|
||||
56
.claude/commands/release.md
Normal file
56
.claude/commands/release.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Release Command
|
||||
|
||||
Bump the package.json version (major, minor, or patch) and build the Electron app with the new version.
|
||||
|
||||
## Usage
|
||||
|
||||
This command accepts a version bump type as input:
|
||||
|
||||
- `patch` - Bump patch version (0.1.0 -> 0.1.1)
|
||||
- `minor` - Bump minor version (0.1.0 -> 0.2.0)
|
||||
- `major` - Bump major version (0.1.0 -> 1.0.0)
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Get the bump type from the user**
|
||||
- The bump type should be provided as an argument (patch, minor, or major)
|
||||
- If no type is provided, ask the user which type they want
|
||||
|
||||
2. **Bump the version**
|
||||
- Run the version bump script:
|
||||
```bash
|
||||
node apps/ui/scripts/bump-version.mjs <type>
|
||||
```
|
||||
- This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync)
|
||||
- Verify the version was updated correctly by checking the output
|
||||
|
||||
3. **Build the Electron app**
|
||||
- Run the electron build:
|
||||
```bash
|
||||
npm run build:electron --workspace=apps/ui
|
||||
```
|
||||
- The build process automatically:
|
||||
- Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`)
|
||||
- Injects the version into the app via Vite's `__APP_VERSION__` constant
|
||||
- Displays the version below the logo in the sidebar
|
||||
|
||||
4. **Verify the release**
|
||||
- Check that the build completed successfully
|
||||
- Confirm the version appears correctly in the built artifacts
|
||||
- The version will be displayed in the app UI below the logo
|
||||
|
||||
## Version Centralization
|
||||
|
||||
The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`:
|
||||
|
||||
- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName`
|
||||
- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`)
|
||||
- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints)
|
||||
- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string`
|
||||
|
||||
This ensures consistency across:
|
||||
|
||||
- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`)
|
||||
- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`)
|
||||
- Server health endpoints (`/` and `/detailed`)
|
||||
- Package metadata (both UI and server packages stay in sync)
|
||||
49
.claude/commands/validate-build.md
Normal file
49
.claude/commands/validate-build.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Project Build and Fix Command
|
||||
|
||||
Run all builds and intelligently fix any failures based on what changed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Run the build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This builds all packages and the UI application.
|
||||
|
||||
2. **If the build succeeds**, report success and stop.
|
||||
|
||||
3. **If the build fails**, analyze the failures:
|
||||
- Note which build step failed and the error messages
|
||||
- Check for TypeScript compilation errors, missing dependencies, or configuration issues
|
||||
- Run `git diff main` to see what code has changed
|
||||
|
||||
4. **Determine the nature of the failure**:
|
||||
- **If the failure is due to intentional changes** (new features, refactoring, dependency updates):
|
||||
- Fix any TypeScript type errors introduced by the changes
|
||||
- Update build configuration if needed (e.g., tsconfig.json, vite.config.mts)
|
||||
- Ensure all new dependencies are properly installed
|
||||
- Fix import paths or module resolution issues
|
||||
|
||||
- **If the failure appears to be a regression** (broken imports, missing files, configuration errors):
|
||||
- Fix the source code to restore the build
|
||||
- Check for accidentally deleted files or broken references
|
||||
- Verify build configuration files are correct
|
||||
|
||||
5. **Common build issues to check**:
|
||||
- **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports
|
||||
- **Missing dependencies**: Run `npm install` if packages are missing
|
||||
- **Import/export errors**: Fix incorrect import paths or missing exports
|
||||
- **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs
|
||||
- **Package build order**: Ensure `build:packages` completes before building apps
|
||||
|
||||
6. **How to decide if it's intentional vs regression**:
|
||||
- Look at the git diff and commit messages
|
||||
- If the change was deliberate and introduced new code that needs fixing → fix the new code
|
||||
- If the change broke existing functionality that should still build → fix the regression
|
||||
- When in doubt, ask the user
|
||||
|
||||
7. **After making fixes**, re-run the build to verify everything compiles successfully.
|
||||
|
||||
8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.).
|
||||
36
.claude/commands/validate-tests.md
Normal file
36
.claude/commands/validate-tests.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Project Test and Fix Command
|
||||
|
||||
Run all tests and intelligently fix any failures based on what changed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Run all tests**
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
2. **If all tests pass**, report success and stop.
|
||||
|
||||
3. **If any tests fail**, analyze the failures:
|
||||
- Note which tests failed and their error messages
|
||||
- Run `git diff main` to see what code has changed
|
||||
|
||||
4. **Determine the nature of the change**:
|
||||
- **If the logic change is intentional** (new feature, refactor, behavior change):
|
||||
- Update the failing tests to match the new expected behavior
|
||||
- The tests should reflect what the code NOW does correctly
|
||||
|
||||
- **If the logic change appears to be a bug** (regression, unintended side effect):
|
||||
- Fix the source code to restore the expected behavior
|
||||
- Do NOT modify the tests - they are catching a real bug
|
||||
|
||||
5. **How to decide if it's a bug vs intentional change**:
|
||||
- Look at the git diff and commit messages
|
||||
- If the change was deliberate and the test expectations are now outdated → update tests
|
||||
- If the change broke existing functionality that should still work → fix the code
|
||||
- When in doubt, ask the user
|
||||
|
||||
6. **After making fixes**, re-run the tests to verify everything passes.
|
||||
|
||||
7. **Report summary** of what was fixed (tests updated vs code fixed).
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"autoAllowBashIfSandboxed": true
|
||||
},
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits",
|
||||
"allow": [
|
||||
"Read(./**)",
|
||||
"Write(./**)",
|
||||
"Edit(./**)",
|
||||
"Glob(./**)",
|
||||
"Grep(./**)",
|
||||
"Bash(*)",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate"
|
||||
]
|
||||
}
|
||||
}
|
||||
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
name: Bug Report
|
||||
description: File a bug report to help us improve Automaker
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible.
|
||||
|
||||
- type: dropdown
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What operating system are you using?
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: run-mode
|
||||
attributes:
|
||||
label: Run Mode
|
||||
description: How are you running Automaker?
|
||||
options:
|
||||
- Electron (Desktop App)
|
||||
- Web (Browser)
|
||||
- Docker
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: What version of Automaker are you using? (e.g., 0.1.0)
|
||||
placeholder: '0.1.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: A clear and concise description of what actually happened.
|
||||
placeholder: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Drag and drop screenshots here or paste image URLs
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: If applicable, paste relevant logs or error messages.
|
||||
placeholder: Paste logs here...
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: Any additional information that might be helpful...
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues to ensure this bug hasn't been reported already
|
||||
required: true
|
||||
- label: I have provided all required information above
|
||||
required: true
|
||||
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
|
||||
172
CLAUDE.md
Normal file
172
CLAUDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Interactive launcher (choose web or electron)
|
||||
npm run dev:web # Web browser mode (localhost:3007)
|
||||
npm run dev:electron # Desktop app mode
|
||||
npm run dev:electron:debug # Desktop with DevTools open
|
||||
|
||||
# Building
|
||||
npm run build # Build web application
|
||||
npm run build:packages # Build all shared packages (required before other builds)
|
||||
npm run build:electron # Build desktop app for current platform
|
||||
npm run build:server # Build server only
|
||||
|
||||
# Testing
|
||||
npm run test # E2E tests (Playwright, headless)
|
||||
npm run test:headed # E2E tests with browser visible
|
||||
npm run test:server # Server unit tests (Vitest)
|
||||
npm run test:packages # All shared package tests
|
||||
npm run test:all # All tests (packages + server)
|
||||
|
||||
# Single test file
|
||||
npm run test:server -- tests/unit/specific.test.ts
|
||||
|
||||
# Linting and formatting
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier write
|
||||
npm run format:check # Prettier check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend (port 3007)
|
||||
│ └── server/ # Express + WebSocket backend (port 3008)
|
||||
└── libs/ # Shared packages (@automaker/*)
|
||||
├── types/ # Core TypeScript definitions (no dependencies)
|
||||
├── utils/ # Logging, errors, image processing, context loading
|
||||
├── prompts/ # AI prompt templates
|
||||
├── platform/ # Path management, security, process spawning
|
||||
├── model-resolver/ # Claude model alias resolution
|
||||
├── dependency-resolver/ # Feature dependency ordering
|
||||
└── git-utils/ # Git operations & worktree management
|
||||
```
|
||||
|
||||
### Package Dependency Chain
|
||||
|
||||
Packages can only depend on packages above them:
|
||||
|
||||
```
|
||||
@automaker/types (no dependencies)
|
||||
↓
|
||||
@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
|
||||
↓
|
||||
@automaker/git-utils
|
||||
↓
|
||||
@automaker/server, @automaker/ui
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
|
||||
- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
|
||||
- **Testing**: Playwright (E2E), Vitest (unit)
|
||||
|
||||
### Server Architecture
|
||||
|
||||
The server (`apps/server/src/`) follows a modular pattern:
|
||||
|
||||
- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
|
||||
- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
|
||||
- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
|
||||
- `lib/` - Utilities (events, auth, worktree metadata)
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
The UI (`apps/ui/src/`) uses:
|
||||
|
||||
- `routes/` - TanStack Router file-based routing
|
||||
- `components/views/` - Main view components (board, settings, terminal, etc.)
|
||||
- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
|
||||
- `hooks/` - Custom React hooks
|
||||
- `lib/` - Utilities and API client
|
||||
|
||||
## Data Storage
|
||||
|
||||
### Per-Project Data (`.automaker/`)
|
||||
|
||||
```
|
||||
.automaker/
|
||||
├── features/ # Feature JSON files and images
|
||||
│ └── {featureId}/
|
||||
│ ├── feature.json
|
||||
│ ├── agent-output.md
|
||||
│ └── images/
|
||||
├── context/ # Context files for AI agents (CLAUDE.md, etc.)
|
||||
├── settings.json # Project-specific settings
|
||||
├── spec.md # Project specification
|
||||
└── analysis.json # Project structure analysis
|
||||
```
|
||||
|
||||
### Global Data (`DATA_DIR`, default `./data`)
|
||||
|
||||
```
|
||||
data/
|
||||
├── settings.json # Global settings, profiles, shortcuts
|
||||
├── credentials.json # API keys
|
||||
├── sessions-metadata.json # Chat session metadata
|
||||
└── agent-sessions/ # Conversation histories
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Always import from shared packages, never from old paths:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import type { Feature, ExecuteOptions } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
import { getEnhancementPrompt } from '@automaker/prompts';
|
||||
import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { resolveDependencies } from '@automaker/dependency-resolver';
|
||||
import { getGitRepositoryDiffs } from '@automaker/git-utils';
|
||||
|
||||
// ❌ Never import from old paths
|
||||
import { Feature } from '../services/feature-loader'; // Wrong
|
||||
import { createLogger } from '../lib/logger'; // Wrong
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
|
||||
|
||||
### Git Worktree Isolation
|
||||
|
||||
Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
|
||||
|
||||
### Context Files
|
||||
|
||||
Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
|
||||
|
||||
### Model Resolution
|
||||
|
||||
Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
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;"]
|
||||
136
README.md
136
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="apps/ui/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||
<img src="apps/ui/public/readme_logo.svg" alt="Automaker Logo" height="80" />
|
||||
</p>
|
||||
|
||||
> **[!TIP]**
|
||||
@@ -81,22 +81,6 @@ Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthro
|
||||
|
||||
The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
|
||||
|
||||
---
|
||||
|
||||
> **[!CAUTION]**
|
||||
>
|
||||
> ## Security Disclaimer
|
||||
>
|
||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||
>
|
||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](./DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
## Community & Support
|
||||
|
||||
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||
@@ -223,14 +207,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)
|
||||
@@ -527,10 +608,27 @@ data/
|
||||
└── {sessionId}.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[!CAUTION]**
|
||||
>
|
||||
> ## Security Disclaimer
|
||||
>
|
||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||
>
|
||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](./DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
## Learn More
|
||||
|
||||
### Documentation
|
||||
|
||||
- [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"]
|
||||
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:test": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/",
|
||||
@@ -20,31 +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",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"morgan": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
"@automaker/platform": "1.0.0",
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.25.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.3"
|
||||
"ws": "8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/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';
|
||||
@@ -50,6 +54,10 @@ import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||
import { createMCPRoutes } from './routes/mcp/index.js';
|
||||
import { MCPTestService } from './services/mcp-test-service.js';
|
||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -87,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
|
||||
@@ -101,13 +109,47 @@ if (ENABLE_REQUEST_LOGGING) {
|
||||
})
|
||||
);
|
||||
}
|
||||
// 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 || '*',
|
||||
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:') ||
|
||||
origin.startsWith('http://[::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();
|
||||
@@ -119,6 +161,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -135,18 +178,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());
|
||||
@@ -162,6 +213,8 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -171,10 +224,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;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
@@ -136,6 +136,53 @@ function getBaseOptions(): Partial<Options> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP permission options result
|
||||
*/
|
||||
interface McpPermissionOptions {
|
||||
/** Whether tools should be restricted to a preset */
|
||||
shouldRestrictTools: boolean;
|
||||
/** Options to spread when MCP bypass is enabled */
|
||||
bypassOptions: Partial<Options>;
|
||||
/** Options to spread for MCP servers */
|
||||
mcpServerOptions: Partial<Options>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build MCP-related options based on configuration.
|
||||
* Centralizes the logic for determining permission modes and tool restrictions
|
||||
* when MCP servers are configured.
|
||||
*
|
||||
* @param config - The SDK options config
|
||||
* @returns Object with MCP permission settings to spread into final options
|
||||
*/
|
||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
|
||||
|
||||
// Determine if we should bypass permissions based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
return {
|
||||
shouldRestrictTools,
|
||||
// Only include bypass options when MCP is configured and auto-approve is enabled
|
||||
bypassOptions: shouldBypassPermissions
|
||||
? {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
// Required flag when using bypassPermissions mode
|
||||
allowDangerouslySkipPermissions: true,
|
||||
}
|
||||
: {},
|
||||
// Include MCP servers if configured
|
||||
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||
* When autoLoadClaudeMd is true:
|
||||
@@ -219,8 +266,25 @@ export interface CreateSdkOptionsConfig {
|
||||
|
||||
/** Enable sandbox mode for bash command isolation */
|
||||
enableSandboxMode?: boolean;
|
||||
|
||||
/** MCP servers to make available to the agent */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
|
||||
/** Auto-approve MCP tool calls without permission prompts */
|
||||
mcpAutoApproveTools?: boolean;
|
||||
|
||||
/** Allow unrestricted tools when MCP servers are enabled */
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
}
|
||||
|
||||
// Re-export MCP types from @automaker/types for convenience
|
||||
export type {
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create SDK options for spec generation
|
||||
*
|
||||
@@ -330,12 +394,18 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -344,6 +414,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,12 +435,18 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
@@ -378,6 +455,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,14 +478,27 @@ export function createCustomOptions(
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||
const effectiveAllowedTools = config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
: mcpOptions.shouldRestrictTools
|
||||
? [...TOOL_PRESETS.readOnly]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('default', config.model),
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
||||
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...claudeMdOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...mcpOptions.mcpServerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +4,16 @@
|
||||
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
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.
|
||||
@@ -20,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;
|
||||
}
|
||||
|
||||
@@ -28,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;
|
||||
@@ -37,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;
|
||||
}
|
||||
}
|
||||
@@ -58,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}`);
|
||||
const result = globalSettings.enableSandboxMode ?? false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -136,3 +146,161 @@ function formatContextFileEntry(file: ContextFileInfo): string {
|
||||
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
|
||||
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled MCP servers from global settings, converted to SDK format.
|
||||
* Returns an empty object if settings service is not available or no servers are configured.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP servers in SDK format (keyed by name)
|
||||
*/
|
||||
export async function getMCPServersFromSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<Record<string, McpServerConfig>> {
|
||||
if (!settingsService) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const mcpServers = globalSettings.mcpServers || [];
|
||||
|
||||
// Filter to only enabled servers and convert to SDK format
|
||||
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||
|
||||
if (enabledServers.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convert settings format to SDK format (keyed by name)
|
||||
const sdkServers: Record<string, McpServerConfig> = {};
|
||||
for (const server of enabledServers) {
|
||||
sdkServers[server.name] = convertToSdkFormat(server);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
|
||||
);
|
||||
|
||||
return sdkServers;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load MCP servers setting:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP permission settings from global settings.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP permission settings
|
||||
*/
|
||||
export async function getMCPPermissionSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
|
||||
|
||||
if (!settingsService) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = {
|
||||
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||
};
|
||||
logger.info(
|
||||
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
|
||||
* Validates required fields and throws informative errors if missing.
|
||||
*/
|
||||
function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
|
||||
if (server.type === 'sse') {
|
||||
if (!server.url) {
|
||||
throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
|
||||
}
|
||||
return {
|
||||
type: 'sse',
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
};
|
||||
}
|
||||
|
||||
if (server.type === 'http') {
|
||||
if (!server.url) {
|
||||
throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
|
||||
}
|
||||
return {
|
||||
type: 'http',
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to stdio
|
||||
if (!server.command) {
|
||||
throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
|
||||
}
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
33
apps/server/src/lib/version.ts
Normal file
33
apps/server/src/lib/version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Version utility - Reads version from package.json
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the version from package.json
|
||||
* Caches the result for performance
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
const version = packageJson.version || '0.0.0';
|
||||
cachedVersion = version;
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.warn('Failed to read version from package.json:', error);
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
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';
|
||||
@@ -36,16 +63,35 @@ export class ClaudeProvider extends BaseProvider {
|
||||
} = options;
|
||||
|
||||
// Build Claude SDK options
|
||||
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
|
||||
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
|
||||
// the provider is the final point where SDK options are constructed.
|
||||
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
const toolsToUse = allowedTools || defaultTools;
|
||||
|
||||
// Determine permission mode based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: toolsToUse,
|
||||
permissionMode: 'default',
|
||||
// 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 }),
|
||||
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||
// Required when using bypassPermissions mode
|
||||
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
@@ -55,6 +101,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
...(options.settingSources && { settingSources: options.settingSources }),
|
||||
// Forward sandbox configuration
|
||||
...(options.sandbox && { sandbox: options.sandbox }),
|
||||
// Forward MCP servers configuration
|
||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
@@ -88,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
/**
|
||||
* Shared types for AI model providers
|
||||
*
|
||||
* Re-exports types from @automaker/types for consistency across the codebase.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for a provider instance
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
apiKey?: string;
|
||||
cliPath?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a query via a provider
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
mcpServers?: Record<string, unknown>;
|
||||
abortController?: AbortController;
|
||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
||||
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
|
||||
}
|
||||
// Re-export all provider types from @automaker/types
|
||||
export type {
|
||||
ProviderConfig,
|
||||
ConversationMessage,
|
||||
ExecuteOptions,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
|
||||
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';
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getAuthStatus } from '../../../lib/auth.js';
|
||||
import { getVersion } from '../../../lib/version.js';
|
||||
|
||||
export function createDetailedHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
version: getVersion(),
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
dataDir: process.env.DATA_DIR || './data',
|
||||
|
||||
20
apps/server/src/routes/health/routes/environment.ts
Normal file
20
apps/server/src/routes/health/routes/environment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* GET /environment endpoint - Environment information including containerization status
|
||||
*
|
||||
* This endpoint is unauthenticated so the UI can check it on startup
|
||||
* before login to determine if sandbox risk warnings should be shown.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export interface EnvironmentResponse {
|
||||
isContainerized: boolean;
|
||||
}
|
||||
|
||||
export function createEnvironmentHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
isContainerized: process.env.IS_CONTAINERIZED === 'true',
|
||||
} satisfies EnvironmentResponse);
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getVersion } from '../../../lib/version.js';
|
||||
|
||||
export function createIndexHandler() {
|
||||
return (_req: Request, res: Response): void => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
version: getVersion(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
20
apps/server/src/routes/mcp/common.ts
Normal file
20
apps/server/src/routes/mcp/common.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Common utilities for MCP routes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with prefix
|
||||
*/
|
||||
export function logError(error: unknown, message: string): void {
|
||||
console.error(`[MCP] ${message}:`, error);
|
||||
}
|
||||
36
apps/server/src/routes/mcp/index.ts
Normal file
36
apps/server/src/routes/mcp/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* MCP routes - HTTP API for testing MCP servers
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Testing MCP server connections
|
||||
* - Listing available tools from MCP servers
|
||||
*
|
||||
* Mounted at /api/mcp in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { MCPTestService } from '../../services/mcp-test-service.js';
|
||||
import { createTestServerHandler } from './routes/test-server.js';
|
||||
import { createListToolsHandler } from './routes/list-tools.js';
|
||||
|
||||
/**
|
||||
* Create MCP router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /test - Test MCP server connection
|
||||
* - POST /tools - List tools from MCP server
|
||||
*
|
||||
* @param mcpTestService - Instance of MCPTestService for testing connections
|
||||
* @returns Express Router configured with all MCP endpoints
|
||||
*/
|
||||
export function createMCPRoutes(mcpTestService: MCPTestService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Test MCP server connection
|
||||
router.post('/test', createTestServerHandler(mcpTestService));
|
||||
|
||||
// List tools from MCP server
|
||||
router.post('/tools', createListToolsHandler(mcpTestService));
|
||||
|
||||
return router;
|
||||
}
|
||||
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* POST /api/mcp/tools - List tools for an MCP server
|
||||
*
|
||||
* Lists available tools for an MCP server.
|
||||
* Similar to test but focused on tool discovery.
|
||||
*
|
||||
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||
* Users must explicitly save a server config through the UI before testing.
|
||||
*
|
||||
* Request body:
|
||||
* { serverId: string } - Get tools by server ID from settings
|
||||
*
|
||||
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ListToolsRequest {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/mcp/tools
|
||||
*/
|
||||
export function createListToolsHandler(mcpTestService: MCPTestService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as ListToolsRequest;
|
||||
|
||||
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'serverId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mcpTestService.testServerById(body.serverId);
|
||||
|
||||
// Return only tool-related information
|
||||
res.json({
|
||||
success: result.success,
|
||||
tools: result.tools,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List tools failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/mcp/test - Test MCP server connection and list tools
|
||||
*
|
||||
* Tests connection to an MCP server and returns available tools.
|
||||
*
|
||||
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||
* Users must explicitly save a server config through the UI before testing.
|
||||
*
|
||||
* Request body:
|
||||
* { serverId: string } - Test server by ID from settings
|
||||
*
|
||||
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface TestServerRequest {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/mcp/test
|
||||
*/
|
||||
export function createTestServerHandler(mcpTestService: MCPTestService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body as TestServerRequest;
|
||||
|
||||
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'serverId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await mcpTestService.testServerById(body.serverId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Test server failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
21
apps/server/src/routes/pipeline/common.ts
Normal file
21
apps/server/src/routes/pipeline/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Common utilities for pipeline routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all pipeline endpoints.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for pipeline-related operations */
|
||||
export const logger = createLogger('Pipeline');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
77
apps/server/src/routes/pipeline/index.ts
Normal file
77
apps/server/src/routes/pipeline/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pipeline routes - HTTP API for pipeline configuration management
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Getting pipeline configuration
|
||||
* - Saving pipeline configuration
|
||||
* - Adding, updating, deleting, and reordering pipeline steps
|
||||
*
|
||||
* All endpoints use handler factories that receive the PipelineService instance.
|
||||
* Mounted at /api/pipeline in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { PipelineService } from '../../services/pipeline-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGetConfigHandler } from './routes/get-config.js';
|
||||
import { createSaveConfigHandler } from './routes/save-config.js';
|
||||
import { createAddStepHandler } from './routes/add-step.js';
|
||||
import { createUpdateStepHandler } from './routes/update-step.js';
|
||||
import { createDeleteStepHandler } from './routes/delete-step.js';
|
||||
import { createReorderStepsHandler } from './routes/reorder-steps.js';
|
||||
|
||||
/**
|
||||
* Create pipeline router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /config - Get pipeline configuration
|
||||
* - POST /config/save - Save entire pipeline configuration
|
||||
* - POST /steps/add - Add a new pipeline step
|
||||
* - POST /steps/update - Update an existing pipeline step
|
||||
* - POST /steps/delete - Delete a pipeline step
|
||||
* - POST /steps/reorder - Reorder pipeline steps
|
||||
*
|
||||
* @param pipelineService - Instance of PipelineService for file I/O
|
||||
* @returns Express Router configured with all pipeline endpoints
|
||||
*/
|
||||
export function createPipelineRoutes(pipelineService: PipelineService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get pipeline configuration
|
||||
router.post(
|
||||
'/config',
|
||||
validatePathParams('projectPath'),
|
||||
createGetConfigHandler(pipelineService)
|
||||
);
|
||||
|
||||
// Save entire pipeline configuration
|
||||
router.post(
|
||||
'/config/save',
|
||||
validatePathParams('projectPath'),
|
||||
createSaveConfigHandler(pipelineService)
|
||||
);
|
||||
|
||||
// Pipeline step operations
|
||||
router.post(
|
||||
'/steps/add',
|
||||
validatePathParams('projectPath'),
|
||||
createAddStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/update',
|
||||
validatePathParams('projectPath'),
|
||||
createUpdateStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteStepHandler(pipelineService)
|
||||
);
|
||||
router.post(
|
||||
'/steps/reorder',
|
||||
validatePathParams('projectPath'),
|
||||
createReorderStepsHandler(pipelineService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/add - Add a new pipeline step
|
||||
*
|
||||
* Adds a new step to the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, step: { name, order, instructions, colorClass } }
|
||||
* Response: { success: true, step: PipelineStep }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAddStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, step } = req.body as {
|
||||
projectPath: string;
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step) {
|
||||
res.status(400).json({ success: false, error: 'step is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step.name) {
|
||||
res.status(400).json({ success: false, error: 'step.name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.instructions === undefined) {
|
||||
res.status(400).json({ success: false, error: 'step.instructions is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = await pipelineService.addStep(projectPath, step);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
step: newStep,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Add pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/delete - Delete a pipeline step
|
||||
*
|
||||
* Removes a step from the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepId: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepId } = req.body as {
|
||||
projectPath: string;
|
||||
stepId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepId) {
|
||||
res.status(400).json({ success: false, error: 'stepId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.deleteStep(projectPath, stepId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Delete pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* POST /api/pipeline/config - Get pipeline configuration
|
||||
*
|
||||
* Returns the pipeline configuration for a project.
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, config: PipelineConfig }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetConfigHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get pipeline config failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/reorder - Reorder pipeline steps
|
||||
*
|
||||
* Reorders the steps in the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepIds: string[] }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createReorderStepsHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepIds } = req.body as {
|
||||
projectPath: string;
|
||||
stepIds: string[];
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepIds || !Array.isArray(stepIds)) {
|
||||
res.status(400).json({ success: false, error: 'stepIds array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.reorderSteps(projectPath, stepIds);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Reorder pipeline steps failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/pipeline/config/save - Save entire pipeline configuration
|
||||
*
|
||||
* Saves the complete pipeline configuration for a project.
|
||||
*
|
||||
* Request body: { projectPath: string, config: PipelineConfig }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSaveConfigHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, config } = req.body as {
|
||||
projectPath: string;
|
||||
config: PipelineConfig;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
res.status(400).json({ success: false, error: 'config is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pipelineService.savePipelineConfig(projectPath, config);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Save pipeline config failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/pipeline/steps/update - Update an existing pipeline step
|
||||
*
|
||||
* Updates a step in the pipeline configuration.
|
||||
*
|
||||
* Request body: { projectPath: string, stepId: string, updates: Partial<PipelineStep> }
|
||||
* Response: { success: true, step: PipelineStep }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PipelineService } from '../../../services/pipeline-service.js';
|
||||
import type { PipelineStep } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createUpdateStepHandler(pipelineService: PipelineService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, stepId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
stepId: string;
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stepId) {
|
||||
res.status(400).json({ success: false, error: 'stepId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
res.status(400).json({ success: false, error: 'updates is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
step: updatedStep,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Update pipeline step failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Common utilities for update routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Updates');
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
// ============================================================================
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env['ProgramFiles(x86)']) {
|
||||
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
||||
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
||||
'/home/linuxbrew/.linuxbrew/bin' // Linuxbrew
|
||||
);
|
||||
// pipx, other user installs - only add if HOME is defined
|
||||
if (process.env.HOME) {
|
||||
additionalPaths.push(`${process.env.HOME}/.local/bin`);
|
||||
}
|
||||
}
|
||||
|
||||
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
||||
.filter(Boolean)
|
||||
.join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Automaker installation path
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Locate the Automaker monorepo root directory.
|
||||
*
|
||||
* @returns Absolute path to the monorepo root directory (the directory containing the top-level `package.json`)
|
||||
*/
|
||||
export function getAutomakerRoot(): string {
|
||||
// In ESM, we use import.meta.url to get the current file path
|
||||
// This file is at: apps/server/src/routes/updates/common.ts
|
||||
// So we need to go up 5 levels to get to the monorepo root
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Go up from: updates -> routes -> src -> server -> apps -> root
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether Git is available on the system.
|
||||
*
|
||||
* @returns `true` if the `git` command is executable in the current environment, `false` otherwise.
|
||||
*/
|
||||
export async function isGitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git --version', { env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the given filesystem path is a Git repository.
|
||||
*
|
||||
* @param repoPath - Filesystem path to check
|
||||
* @returns `true` if the path is inside a Git working tree, `false` otherwise.
|
||||
*/
|
||||
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath, env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the full commit hash pointed to by HEAD in the given repository.
|
||||
*
|
||||
* @param repoPath - Filesystem path of the Git repository to query
|
||||
* @returns The full commit hash for HEAD as a trimmed string
|
||||
*/
|
||||
export async function getCurrentCommit(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the short commit hash of HEAD for the repository at the given path.
|
||||
*
|
||||
* @param repoPath - Filesystem path to the git repository
|
||||
* @returns The short commit hash for `HEAD`
|
||||
*/
|
||||
export async function getShortCommit(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the repository contains uncommitted local changes.
|
||||
*
|
||||
* @param repoPath - Filesystem path to the Git repository to check
|
||||
* @returns `true` if the repository has any uncommitted changes, `false` otherwise
|
||||
*/
|
||||
export async function hasLocalChanges(repoPath: string): Promise<boolean> {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a string is a well-formed git remote URL and contains no shell metacharacters.
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @returns `true` if `url` starts with a common git protocol (`https://`, `git@`, `git://`, `ssh://`) and does not contain shell metacharacters, `false` otherwise.
|
||||
*/
|
||||
export function isValidGitUrl(url: string): boolean {
|
||||
// Allow HTTPS, SSH, and git protocols
|
||||
const startsWithValidProtocol =
|
||||
url.startsWith('https://') ||
|
||||
url.startsWith('git@') ||
|
||||
url.startsWith('git://') ||
|
||||
url.startsWith('ssh://');
|
||||
|
||||
// Block shell metacharacters to prevent command injection
|
||||
const hasShellChars = /[;`|&<>()$!\\[\] ]/.test(url);
|
||||
|
||||
return startsWithValidProtocol && !hasShellChars;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Update routes - HTTP API for checking and applying updates
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Checking if updates are available from upstream
|
||||
* - Pulling updates from upstream
|
||||
* - Getting current installation info
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { createCheckHandler } from './routes/check.js';
|
||||
import { createPullHandler } from './routes/pull.js';
|
||||
import { createInfoHandler } from './routes/info.js';
|
||||
|
||||
/**
|
||||
* Create an Express Router that exposes API endpoints for update operations.
|
||||
*
|
||||
* @returns An Express Router with the routes:
|
||||
* - GET `/check` — checks for available updates
|
||||
* - POST `/pull` — pulls updates from upstream
|
||||
* - GET `/info` — returns current installation info
|
||||
*/
|
||||
export function createUpdatesRoutes(settingsService: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/updates/check - Check if updates are available
|
||||
router.get('/check', createCheckHandler(settingsService));
|
||||
|
||||
// POST /api/updates/pull - Pull updates from upstream
|
||||
router.post('/pull', createPullHandler(settingsService));
|
||||
|
||||
// GET /api/updates/info - Get current installation info
|
||||
router.get('/info', createInfoHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* GET /check endpoint - Check if updates are available
|
||||
*
|
||||
* Compares local version with the remote upstream version.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { UpdateCheckResult } from '@automaker/types';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
isValidGitUrl,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Create an Express handler for the update check endpoint that compares the local Git commit
|
||||
* against a configured upstream to determine whether an update is available.
|
||||
*
|
||||
* The handler validates Git availability and repository state, reads the upstream URL from
|
||||
* global settings (with a default), attempts to fetch the upstream main branch using a
|
||||
* temporary remote, and returns a structured result describing local and remote commits and
|
||||
* whether the remote is ahead.
|
||||
*
|
||||
* @param settingsService - Service used to read global settings (used to obtain `autoUpdate.upstreamUrl`)
|
||||
* @returns An Express request handler that responds with JSON. On success the response is
|
||||
* `{ success: true, result }` where `result` is an `UpdateCheckResult`. On error the response
|
||||
* is `{ success: false, error }`. If fetching the upstream fails the handler still responds
|
||||
* with `{ success: true, result }` where `result` indicates no update and includes an `error` message.
|
||||
*/
|
||||
export function createCheckHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Check if git is available
|
||||
if (!(await isGitAvailable())) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Git is not installed or not available in PATH',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if automaker directory is a git repo
|
||||
if (!(await isGitRepo(installPath))) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Automaker installation is not a git repository',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for upstream URL
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const sourceUrl =
|
||||
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
|
||||
|
||||
// Validate URL to prevent command injection
|
||||
if (!isValidGitUrl(sourceUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid upstream URL format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get local version
|
||||
const localVersion = await getCurrentCommit(installPath);
|
||||
const localVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Use a random remote name to avoid conflicts with concurrent checks
|
||||
const tempRemoteName = `automaker-update-check-${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
try {
|
||||
// Add temporary remote
|
||||
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Fetch from the temporary remote
|
||||
await execAsync(`git fetch ${tempRemoteName} main`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Get remote version
|
||||
const { stdout: remoteVersionOutput } = await execAsync(
|
||||
`git rev-parse ${tempRemoteName}/main`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
const remoteVersion = remoteVersionOutput.trim();
|
||||
|
||||
// Get short remote version
|
||||
const { stdout: remoteVersionShortOutput } = await execAsync(
|
||||
`git rev-parse --short ${tempRemoteName}/main`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
const remoteVersionShort = remoteVersionShortOutput.trim();
|
||||
|
||||
// Check if remote is ahead of local (update available)
|
||||
// git merge-base --is-ancestor <commit1> <commit2> returns 0 if commit1 is ancestor of commit2
|
||||
let updateAvailable = false;
|
||||
if (localVersion !== remoteVersion) {
|
||||
try {
|
||||
// Check if local is already an ancestor of remote (remote is ahead)
|
||||
await execAsync(`git merge-base --is-ancestor ${localVersion} ${remoteVersion}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
// If we get here (exit code 0), local is ancestor of remote, so update is available
|
||||
updateAvailable = true;
|
||||
} catch {
|
||||
// Exit code 1 means local is NOT an ancestor of remote
|
||||
// This means either local is ahead, or branches have diverged
|
||||
// In either case, we don't show "update available"
|
||||
updateAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
const result: UpdateCheckResult = {
|
||||
updateAvailable,
|
||||
localVersion,
|
||||
localVersionShort,
|
||||
remoteVersion,
|
||||
remoteVersionShort,
|
||||
sourceUrl,
|
||||
installPath,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (fetchError) {
|
||||
const errorMsg = getErrorMessage(fetchError);
|
||||
logError(fetchError, 'Failed to fetch from upstream');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
updateAvailable: false,
|
||||
localVersion,
|
||||
localVersionShort,
|
||||
remoteVersion: null,
|
||||
remoteVersionShort: null,
|
||||
sourceUrl,
|
||||
installPath,
|
||||
error: `Could not fetch from upstream: ${errorMsg}`,
|
||||
} satisfies UpdateCheckResult,
|
||||
});
|
||||
} finally {
|
||||
// Always clean up temp remote
|
||||
try {
|
||||
await execAsync(`git remote remove ${tempRemoteName}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Update check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* GET /info endpoint - Get current installation info
|
||||
*
|
||||
* Returns current version, branch, and configuration info.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { DEFAULT_AUTO_UPDATE_SETTINGS, type UpdateInfo } from '@automaker/types';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
hasLocalChanges,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Creates an Express handler that returns update information for the application installation.
|
||||
*
|
||||
* The produced handler responds with a JSON payload containing an UpdateInfo result describing
|
||||
* installation path, git-based version and branch data (when available), local change status,
|
||||
* and configured auto-update settings. On failure the handler responds with HTTP 500 and a JSON
|
||||
* error message.
|
||||
*
|
||||
* @returns An Express request handler that sends `{ success: true, result: UpdateInfo }` on success
|
||||
* or `{ success: false, error: string }` with HTTP 500 on error.
|
||||
*/
|
||||
export function createInfoHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Get settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const autoUpdateSettings = settings.autoUpdate || DEFAULT_AUTO_UPDATE_SETTINGS;
|
||||
|
||||
// Check if git is available
|
||||
const gitAvailable = await isGitAvailable();
|
||||
|
||||
if (!gitAvailable) {
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion: null,
|
||||
currentVersionShort: null,
|
||||
currentBranch: null,
|
||||
hasLocalChanges: false,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: false,
|
||||
gitAvailable: false,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a git repo
|
||||
const isRepo = await isGitRepo(installPath);
|
||||
|
||||
if (!isRepo) {
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion: null,
|
||||
currentVersionShort: null,
|
||||
currentBranch: null,
|
||||
hasLocalChanges: false,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: false,
|
||||
gitAvailable: true,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get git info
|
||||
const currentVersion = await getCurrentCommit(installPath);
|
||||
const currentVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
const currentBranch = branchOutput.trim();
|
||||
|
||||
// Check for local changes
|
||||
const localChanges = await hasLocalChanges(installPath);
|
||||
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion,
|
||||
currentVersionShort,
|
||||
currentBranch,
|
||||
hasLocalChanges: localChanges,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: true,
|
||||
gitAvailable: true,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Failed to get update info');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* POST /pull endpoint - Pull updates from upstream
|
||||
*
|
||||
* Executes git pull from the configured upstream repository.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { UpdatePullResult } from '@automaker/types';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
isValidGitUrl,
|
||||
hasLocalChanges,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Create an Express handler for POST /pull that updates the local Automaker installation by pulling from the configured upstream Git repository.
|
||||
*
|
||||
* The handler validates Git availability and that the install directory is a git repository, ensures there are no local uncommitted changes, validates the upstream URL from global settings, and performs a fast-forward-only pull using a temporary remote. It returns a JSON UpdatePullResult on success, or an error JSON with appropriate HTTP status codes for invalid input, merge conflicts, non-fast-forward divergence, or unexpected failures.
|
||||
*
|
||||
* @param settingsService - Service used to read global settings (used to obtain the upstream URL)
|
||||
* @returns An Express request handler that performs the safe fast-forward pull and sends a JSON response describing the result or error
|
||||
*/
|
||||
export function createPullHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Check if git is available
|
||||
if (!(await isGitAvailable())) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Git is not installed or not available in PATH',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if automaker directory is a git repo
|
||||
if (!(await isGitRepo(installPath))) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Automaker installation is not a git repository',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
if (await hasLocalChanges(installPath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'You have local uncommitted changes. Please commit or stash them before updating.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for upstream URL
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const sourceUrl =
|
||||
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
|
||||
|
||||
// Validate URL to prevent command injection
|
||||
if (!isValidGitUrl(sourceUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid upstream URL format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current version before pull
|
||||
const previousVersion = await getCurrentCommit(installPath);
|
||||
const previousVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Use a random remote name to avoid conflicts with concurrent pulls
|
||||
const tempRemoteName = `automaker-update-pull-${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
try {
|
||||
// Add temporary remote
|
||||
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Fetch first
|
||||
await execAsync(`git fetch ${tempRemoteName} main`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Merge the fetched changes
|
||||
const { stdout: mergeOutput } = await execAsync(
|
||||
`git merge ${tempRemoteName}/main --ff-only`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
|
||||
// Get new version after merge
|
||||
const newVersion = await getCurrentCommit(installPath);
|
||||
const newVersionShort = await getShortCommit(installPath);
|
||||
|
||||
const alreadyUpToDate =
|
||||
mergeOutput.includes('Already up to date') || previousVersion === newVersion;
|
||||
|
||||
const result: UpdatePullResult = {
|
||||
success: true,
|
||||
previousVersion,
|
||||
previousVersionShort,
|
||||
newVersion,
|
||||
newVersionShort,
|
||||
alreadyUpToDate,
|
||||
message: alreadyUpToDate
|
||||
? 'Already up to date'
|
||||
: `Updated from ${previousVersionShort} to ${newVersionShort}`,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (pullError) {
|
||||
const errorMsg = getErrorMessage(pullError);
|
||||
logError(pullError, 'Failed to pull updates');
|
||||
|
||||
// Check for common errors
|
||||
if (errorMsg.includes('not possible to fast-forward')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Cannot fast-forward merge. Your local branch has diverged from upstream. Please resolve manually.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorMsg.includes('CONFLICT')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Merge conflict detected. Please resolve conflicts manually.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Failed to pull updates: ${errorMsg}`,
|
||||
});
|
||||
} finally {
|
||||
// Always clean up temp remote
|
||||
try {
|
||||
await execAsync(`git remote remove ${tempRemoteName}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Update pull failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -158,8 +158,13 @@ export const logError = createLogError(logger);
|
||||
/**
|
||||
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
|
||||
* Returns true if an empty commit was created, false if the repo already had commits.
|
||||
* @param repoPath - Path to the git repository
|
||||
* @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL)
|
||||
*/
|
||||
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
export async function ensureInitialCommit(
|
||||
repoPath: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
||||
return false;
|
||||
@@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, {
|
||||
cwd: repoPath,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`);
|
||||
return true;
|
||||
|
||||
@@ -100,7 +100,14 @@ export function createCreateHandler() {
|
||||
}
|
||||
|
||||
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
|
||||
await ensureInitialCommit(projectPath);
|
||||
// Pass git identity env vars so commits work without global git config
|
||||
const gitEnv = {
|
||||
GIT_AUTHOR_NAME: 'Automaker',
|
||||
GIT_AUTHOR_EMAIL: 'automaker@localhost',
|
||||
GIT_COMMITTER_NAME: 'Automaker',
|
||||
GIT_COMMITTER_EMAIL: 'automaker@localhost',
|
||||
};
|
||||
await ensureInitialCommit(projectPath, gitEnv);
|
||||
|
||||
// First, check if git already has a worktree for this branch (anywhere)
|
||||
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
|
||||
|
||||
@@ -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';
|
||||
@@ -21,6 +22,9 @@ import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -73,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');
|
||||
@@ -146,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');
|
||||
}
|
||||
|
||||
@@ -173,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +232,12 @@ export class AgentService {
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
@@ -238,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;
|
||||
@@ -252,6 +263,9 @@ export class AgentService {
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -275,6 +289,9 @@ export class AgentService {
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
@@ -377,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;
|
||||
@@ -471,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,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,
|
||||
@@ -767,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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { ExecuteOptions, Feature } from '@automaker/types';
|
||||
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
@@ -32,10 +32,14 @@ import {
|
||||
} from '../lib/sdk-options.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { pipelineService, PipelineService } from './pipeline-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -64,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
|
||||
@@ -342,6 +190,10 @@ interface AutoModeConfig {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
// Constants for consecutive failure tracking
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||
|
||||
export class AutoModeService {
|
||||
private events: EventEmitter;
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
@@ -352,12 +204,89 @@ export class AutoModeService {
|
||||
private config: AutoModeConfig | null = null;
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private settingsService: SettingsService | null = null;
|
||||
// Track consecutive failures to detect quota/API issues
|
||||
private consecutiveFailures: { timestamp: number; error: string }[] = [];
|
||||
private pausedDueToFailures = false;
|
||||
|
||||
constructor(events: EventEmitter, settingsService?: SettingsService) {
|
||||
this.events = events;
|
||||
this.settingsService = settingsService ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
*/
|
||||
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Add this failure
|
||||
this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
||||
|
||||
// Remove old failures outside the window
|
||||
this.consecutiveFailures = this.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the threshold
|
||||
if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
||||
return true; // Should pause
|
||||
}
|
||||
|
||||
// Also immediately pause for known quota/rate limit errors
|
||||
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion.
|
||||
* This will pause the auto loop to prevent repeated failures.
|
||||
*/
|
||||
private signalShouldPause(errorInfo: { type: string; message: string }): void {
|
||||
if (this.pausedDueToFailures) {
|
||||
return; // Already paused
|
||||
}
|
||||
|
||||
this.pausedDueToFailures = true;
|
||||
const failureCount = this.consecutiveFailures.length;
|
||||
console.log(
|
||||
`[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
||||
errorType: errorInfo.type,
|
||||
originalError: errorInfo.message,
|
||||
failureCount,
|
||||
projectPath: this.config?.projectPath,
|
||||
});
|
||||
|
||||
// Stop the auto loop
|
||||
this.stopAutoLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode)
|
||||
*/
|
||||
private resetFailureTracking(): void {
|
||||
this.consecutiveFailures = [];
|
||||
this.pausedDueToFailures = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count
|
||||
*/
|
||||
private recordSuccess(): void {
|
||||
this.consecutiveFailures = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop - continuously picks and executes pending features
|
||||
*/
|
||||
@@ -366,6 +295,9 @@ export class AutoModeService {
|
||||
throw new Error('Auto mode is already running');
|
||||
}
|
||||
|
||||
// Reset failure tracking when user manually starts auto mode
|
||||
this.resetFailureTracking();
|
||||
|
||||
this.autoLoopRunning = true;
|
||||
this.autoLoopAbortController = new AbortController();
|
||||
this.config = {
|
||||
@@ -590,7 +522,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
|
||||
@@ -631,12 +563,32 @@ export class AutoModeService {
|
||||
}
|
||||
);
|
||||
|
||||
// Check for pipeline steps and execute them
|
||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||
|
||||
if (sortedSteps.length > 0) {
|
||||
// Execute pipeline steps sequentially
|
||||
await this.executePipelineSteps(
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
sortedSteps,
|
||||
workDir,
|
||||
abortController,
|
||||
autoLoadClaudeMd
|
||||
);
|
||||
}
|
||||
|
||||
// Determine final status based on testing mode:
|
||||
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
|
||||
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -664,6 +616,21 @@ export class AutoModeService {
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Track this failure and check if we should pause auto mode
|
||||
// This handles both specific quota/rate limit errors AND generic failures
|
||||
// that may indicate quota exhaustion (SDK doesn't always return useful errors)
|
||||
const shouldPause = this.trackFailureAndCheckPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
if (shouldPause) {
|
||||
this.signalShouldPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
|
||||
@@ -674,6 +641,143 @@ export class AutoModeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute pipeline steps sequentially after initial feature implementation
|
||||
*/
|
||||
private async executePipelineSteps(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
feature: Feature,
|
||||
steps: PipelineStep[],
|
||||
workDir: string,
|
||||
abortController: AbortController,
|
||||
autoLoadClaudeMd: boolean
|
||||
): Promise<void> {
|
||||
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||
|
||||
// Load context files once
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
});
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
// Load previous agent output for context continuity
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
let previousContext = '';
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
// No previous context
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const pipelineStatus = `pipeline_${step.id}`;
|
||||
|
||||
// Update feature status to current pipeline step
|
||||
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
this.emitAutoModeEvent('pipeline_step_started', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Build prompt for this pipeline step
|
||||
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
|
||||
|
||||
// Get model from feature
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
|
||||
// Run the agent for this pipeline step
|
||||
await this.runAgent(
|
||||
workDir,
|
||||
featureId,
|
||||
prompt,
|
||||
abortController,
|
||||
projectPath,
|
||||
undefined, // no images for pipeline steps
|
||||
model,
|
||||
{
|
||||
projectPath,
|
||||
planningMode: 'skip', // Pipeline steps don't need planning
|
||||
requirePlanApproval: false,
|
||||
previousContent: previousContext,
|
||||
systemPrompt: contextFilesPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
}
|
||||
);
|
||||
|
||||
// Load updated context for next step
|
||||
try {
|
||||
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
// No context update
|
||||
}
|
||||
|
||||
this.emitAutoModeEvent('pipeline_step_complete', {
|
||||
featureId,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: i,
|
||||
totalSteps: steps.length,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for a pipeline step
|
||||
*/
|
||||
private buildPipelineStepPrompt(
|
||||
step: PipelineStep,
|
||||
feature: Feature,
|
||||
previousContext: string
|
||||
): string {
|
||||
let prompt = `## Pipeline Step: ${step.name}
|
||||
|
||||
This is an automated pipeline step following the initial feature implementation.
|
||||
|
||||
### Feature Context
|
||||
${this.buildFeaturePrompt(feature)}
|
||||
|
||||
`;
|
||||
|
||||
if (previousContext) {
|
||||
prompt += `### Previous Work
|
||||
The following is the output from the previous work on this feature:
|
||||
|
||||
${previousContext}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `### Pipeline Step Instructions
|
||||
${step.instructions}
|
||||
|
||||
### Task
|
||||
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific feature
|
||||
*/
|
||||
@@ -687,6 +791,11 @@ export class AutoModeService {
|
||||
this.cancelPlanApproval(featureId);
|
||||
|
||||
running.abortController.abort();
|
||||
|
||||
// Remove from running features immediately to allow resume
|
||||
// The abort signal will still propagate to stop any ongoing execution
|
||||
this.runningFeatures.delete(featureId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -924,6 +1033,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -939,6 +1051,19 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Track this failure and check if we should pause auto mode
|
||||
const shouldPause = this.trackFailureAndCheckPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
if (shouldPause) {
|
||||
this.signalShouldPause({
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
@@ -1217,18 +1342,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1602,20 +1752,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 '';
|
||||
}
|
||||
@@ -1841,6 +2000,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
// Load enableSandboxMode setting (global setting only)
|
||||
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Build SDK options using centralized configuration for feature implementation
|
||||
const sdkOptions = createAutoModeOptions({
|
||||
cwd: workDir,
|
||||
@@ -1848,6 +2013,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -1889,10 +2057,15 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Execute via provider
|
||||
console.log(`[AutoMode] Starting stream for feature ${featureId}...`);
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
console.log(`[AutoMode] Stream created, starting to iterate...`);
|
||||
// Initialize with previous content if this is a follow-up, with a separator
|
||||
let responseText = previousContent
|
||||
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
||||
@@ -1930,6 +2103,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
};
|
||||
|
||||
streamLoop: for await (const msg of stream) {
|
||||
console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || '');
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
@@ -2116,6 +2290,9 @@ After generating the revised spec, output:
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
let revisionText = '';
|
||||
@@ -2253,6 +2430,9 @@ After generating the revised spec, output:
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
let taskOutput = '';
|
||||
@@ -2342,6 +2522,9 @@ Implement all the changes described in the plan above.`;
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
for await (const msg of continuationStream) {
|
||||
@@ -2376,6 +2559,9 @@ Implement all the changes described in the plan above.`;
|
||||
|
||||
// Only emit progress for non-marker text (marker was already handled above)
|
||||
if (!specDetected) {
|
||||
console.log(
|
||||
`[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}`
|
||||
);
|
||||
this.emitAutoModeEvent('auto_mode_progress', {
|
||||
featureId,
|
||||
content: block.text,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
208
apps/server/src/services/mcp-test-service.ts
Normal file
208
apps/server/src/services/mcp-test-service.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* MCP Test Service
|
||||
*
|
||||
* Provides functionality to test MCP server connections and list available tools.
|
||||
* Supports stdio, SSE, and HTTP transport types.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
export interface MCPTestResult {
|
||||
success: boolean;
|
||||
tools?: MCPToolInfo[];
|
||||
error?: string;
|
||||
connectionTime?: number;
|
||||
serverInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Test Service for testing server connections and listing tools
|
||||
*/
|
||||
export class MCPTestService {
|
||||
private settingsService: SettingsService;
|
||||
|
||||
constructor(settingsService: SettingsService) {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to an MCP server and list its tools
|
||||
*/
|
||||
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
||||
const startTime = Date.now();
|
||||
let client: Client | null = null;
|
||||
|
||||
try {
|
||||
client = new Client({
|
||||
name: 'automaker-mcp-test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Create transport based on server type
|
||||
const transport = await this.createTransport(serverConfig);
|
||||
|
||||
// Connect with timeout
|
||||
await Promise.race([
|
||||
client.connect(transport),
|
||||
this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'),
|
||||
]);
|
||||
|
||||
// List tools with timeout
|
||||
const toolsResult = await Promise.race([
|
||||
client.listTools(),
|
||||
this.timeout<{
|
||||
tools: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}>;
|
||||
}>(DEFAULT_TIMEOUT, 'List tools timeout'),
|
||||
]);
|
||||
|
||||
const connectionTime = Date.now() - startTime;
|
||||
|
||||
// Convert tools to MCPToolInfo format
|
||||
const tools: MCPToolInfo[] = (toolsResult.tools || []).map(
|
||||
(tool: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tools,
|
||||
connectionTime,
|
||||
serverInfo: {
|
||||
name: serverConfig.name,
|
||||
version: undefined, // Could be extracted from server info if available
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const connectionTime = Date.now() - startTime;
|
||||
return {
|
||||
success: false,
|
||||
error: this.getErrorMessage(error),
|
||||
connectionTime,
|
||||
};
|
||||
} finally {
|
||||
// Clean up client connection
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test server by ID (looks up config from settings)
|
||||
*/
|
||||
async testServerById(serverId: string): Promise<MCPTestResult> {
|
||||
try {
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId);
|
||||
|
||||
if (!serverConfig) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Server with ID "${serverId}" not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return this.testServer(serverConfig);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appropriate transport based on server type
|
||||
*/
|
||||
private async createTransport(
|
||||
config: MCPServerConfig
|
||||
): Promise<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
|
||||
if (config.type === 'sse') {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required for SSE transport');
|
||||
}
|
||||
// Use eventSourceInit workaround for SSE headers (SDK bug workaround)
|
||||
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436
|
||||
const headers = config.headers;
|
||||
return new SSEClientTransport(new URL(config.url), {
|
||||
requestInit: headers ? { headers } : undefined,
|
||||
eventSourceInit: headers
|
||||
? {
|
||||
fetch: (url: string | URL | Request, init?: RequestInit) => {
|
||||
const fetchHeaders = new Headers(init?.headers || {});
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
fetchHeaders.set(key, value);
|
||||
}
|
||||
return fetch(url, { ...init, headers: fetchHeaders });
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.type === 'http') {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required for HTTP transport');
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers
|
||||
? {
|
||||
headers: config.headers,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Default to stdio
|
||||
if (!config.command) {
|
||||
throw new Error('Command is required for stdio transport');
|
||||
}
|
||||
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout promise
|
||||
*/
|
||||
private timeout<T>(ms: number, message: string): Promise<T> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
private getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
320
apps/server/src/services/pipeline-service.ts
Normal file
320
apps/server/src/services/pipeline-service.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Pipeline Service - Handles reading/writing pipeline configuration
|
||||
*
|
||||
* Provides persistent storage for:
|
||||
* - Pipeline configuration ({projectPath}/.automaker/pipeline.json)
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('PipelineService');
|
||||
|
||||
// Default empty pipeline config
|
||||
const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for pipeline steps
|
||||
*/
|
||||
function generateStepId(): string {
|
||||
return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pipeline config file path for a project
|
||||
*/
|
||||
function getPipelineConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'pipeline.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* PipelineService - Manages pipeline configuration for workflow automation
|
||||
*
|
||||
* Handles reading and writing pipeline config to JSON files with atomic operations.
|
||||
* Pipeline steps define custom columns that appear between "in_progress" and
|
||||
* "waiting_approval/verified" columns in the kanban board.
|
||||
*/
|
||||
export class PipelineService {
|
||||
/**
|
||||
* Get pipeline configuration for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @returns Promise resolving to PipelineConfig (empty steps array if no config exists)
|
||||
*/
|
||||
async getPipelineConfig(projectPath: string): Promise<PipelineConfig> {
|
||||
const configPath = getPipelineConfigPath(projectPath);
|
||||
const config = await readJsonFile<PipelineConfig>(configPath, DEFAULT_PIPELINE_CONFIG);
|
||||
|
||||
// Ensure version is set
|
||||
return {
|
||||
...DEFAULT_PIPELINE_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save entire pipeline configuration
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param config - Complete PipelineConfig to save
|
||||
*/
|
||||
async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise<void> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const configPath = getPipelineConfigPath(projectPath);
|
||||
await atomicWriteJson(configPath, config);
|
||||
logger.info(`Pipeline config saved for project: ${projectPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param step - Step data (without id, createdAt, updatedAt)
|
||||
* @returns Promise resolving to the created PipelineStep
|
||||
*/
|
||||
async addStep(
|
||||
projectPath: string,
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<PipelineStep> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newStep: PipelineStep = {
|
||||
...step,
|
||||
id: generateStepId(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
config.steps.push(newStep);
|
||||
|
||||
// Normalize order values
|
||||
config.steps.sort((a, b) => a.order - b.order);
|
||||
config.steps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`);
|
||||
|
||||
return newStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to update
|
||||
* @param updates - Partial step data to merge
|
||||
*/
|
||||
async updateStep(
|
||||
projectPath: string,
|
||||
stepId: string,
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
|
||||
): Promise<PipelineStep> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Pipeline step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
config.steps[stepIndex] = {
|
||||
...config.steps[stepIndex],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step updated: ${stepId}`);
|
||||
|
||||
return config.steps[stepIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pipeline step
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to delete
|
||||
*/
|
||||
async deleteStep(projectPath: string, stepId: string): Promise<void> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Pipeline step not found: ${stepId}`);
|
||||
}
|
||||
|
||||
config.steps.splice(stepIndex, 1);
|
||||
|
||||
// Normalize order values after deletion
|
||||
config.steps.forEach((s, index) => {
|
||||
s.order = index;
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline step deleted: ${stepId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder pipeline steps
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepIds - Array of step IDs in the desired order
|
||||
*/
|
||||
async reorderSteps(projectPath: string, stepIds: string[]): Promise<void> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
|
||||
// Validate all step IDs exist
|
||||
const existingIds = new Set(config.steps.map((s) => s.id));
|
||||
for (const id of stepIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new Error(`Pipeline step not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
|
||||
|
||||
// Reorder steps based on stepIds array
|
||||
config.steps = stepIds.map((id, index) => {
|
||||
const step = stepMap.get(id)!;
|
||||
return { ...step, order: index, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
await this.savePipelineConfig(projectPath, config);
|
||||
logger.info(`Pipeline steps reordered`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next status in the pipeline flow
|
||||
*
|
||||
* Determines what status a feature should transition to based on current status.
|
||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||
*
|
||||
* @param currentStatus - Current feature status
|
||||
* @param config - Pipeline configuration (or null if no pipeline)
|
||||
* @param skipTests - Whether to skip tests (affects final status)
|
||||
* @returns The next status in the pipeline flow
|
||||
*/
|
||||
getNextStatus(
|
||||
currentStatus: FeatureStatusWithPipeline,
|
||||
config: PipelineConfig | null,
|
||||
skipTests: boolean
|
||||
): FeatureStatusWithPipeline {
|
||||
const steps = config?.steps || [];
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
// If no pipeline steps, use original logic
|
||||
if (sortedSteps.length === 0) {
|
||||
if (currentStatus === 'in_progress') {
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// Coming from in_progress -> go to first pipeline step
|
||||
if (currentStatus === 'in_progress') {
|
||||
return `pipeline_${sortedSteps[0].id}`;
|
||||
}
|
||||
|
||||
// Coming from a pipeline step -> go to next step or final status
|
||||
if (currentStatus.startsWith('pipeline_')) {
|
||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Step not found, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
if (currentIndex < sortedSteps.length - 1) {
|
||||
// Go to next step
|
||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||
}
|
||||
|
||||
// Last step completed, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
// For other statuses, don't change
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific pipeline step by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to the project
|
||||
* @param stepId - ID of the step to retrieve
|
||||
* @returns The pipeline step or null if not found
|
||||
*/
|
||||
async getStep(projectPath: string, stepId: string): Promise<PipelineStep | null> {
|
||||
const config = await this.getPipelineConfig(projectPath);
|
||||
return config.steps.find((s) => s.id === stepId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status is a pipeline status
|
||||
*/
|
||||
isPipelineStatus(status: FeatureStatusWithPipeline): boolean {
|
||||
return status.startsWith('pipeline_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract step ID from a pipeline status
|
||||
*/
|
||||
getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null {
|
||||
if (!this.isPipelineStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
return status.replace('pipeline_', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pipelineService = new PipelineService();
|
||||
@@ -124,6 +124,8 @@ export class SettingsService {
|
||||
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
|
||||
* compatibility during schema migrations.
|
||||
*
|
||||
* Also applies version-based migrations for breaking changes.
|
||||
*
|
||||
* @returns Promise resolving to complete GlobalSettings object
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
@@ -131,7 +133,7 @@ export class SettingsService {
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
@@ -139,6 +141,32 @@ export class SettingsService {
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
};
|
||||
|
||||
// Version-based migrations
|
||||
const storedVersion = settings.version || 1;
|
||||
let needsSave = false;
|
||||
|
||||
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
|
||||
// Sandbox mode can cause issues on some systems, so we're disabling it by default
|
||||
if (storedVersion < 2) {
|
||||
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
|
||||
result.enableSandboxMode = false;
|
||||
result.version = SETTINGS_VERSION;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Save migrated settings if needed
|
||||
if (needsSave) {
|
||||
try {
|
||||
await ensureDataDir(this.dataDir);
|
||||
await atomicWriteJson(settingsPath, result);
|
||||
logger.info('Settings migration complete');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save migrated settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
import * as pty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// secureFs is used for user-controllable paths (working directory validation)
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
|
||||
import {
|
||||
systemPathExists,
|
||||
systemPathReadFileSync,
|
||||
getWslVersionPath,
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
@@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
* Uses getShellPaths() to iterate through allowed shell paths
|
||||
*/
|
||||
detectShell(): { shell: string; args: string[] } {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Check if running in WSL
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
// In WSL, prefer the user's configured shell or bash
|
||||
const userShell = process.env.SHELL || '/bin/bash';
|
||||
if (fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to first available POSIX shell
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
}
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'win32': {
|
||||
// Windows: prefer PowerShell, fall back to cmd
|
||||
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
||||
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
|
||||
|
||||
if (fs.existsSync(pwshCore)) {
|
||||
return { shell: pwshCore, args: [] };
|
||||
// For all platforms: first try user's shell if set
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(pwsh)) {
|
||||
return { shell: pwsh, args: [] };
|
||||
}
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
|
||||
case 'darwin': {
|
||||
// macOS: prefer user's shell, then zsh, then bash
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/zsh')) {
|
||||
return { shell: '/bin/zsh', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
|
||||
case 'linux':
|
||||
default: {
|
||||
// Linux: prefer user's shell, then bash, then sh
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && fs.existsSync(userShell)) {
|
||||
return { shell: userShell, args: ['--login'] };
|
||||
}
|
||||
if (fs.existsSync('/bin/bash')) {
|
||||
return { shell: '/bin/bash', args: ['--login'] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through allowed shell paths and return first existing one
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallbacks based on platform
|
||||
if (platform === 'win32') {
|
||||
return { shell: 'cmd.exe', args: [] };
|
||||
}
|
||||
return { shell: '/bin/sh', args: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,8 +168,9 @@ export class TerminalService extends EventEmitter {
|
||||
isWSL(): boolean {
|
||||
try {
|
||||
// Check /proc/version for Microsoft/WSL indicators
|
||||
if (fs.existsSync('/proc/version')) {
|
||||
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
||||
const wslVersionPath = getWslVersionPath();
|
||||
if (systemPathExists(wslVersionPath)) {
|
||||
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
|
||||
return version.includes('microsoft') || version.includes('wsl');
|
||||
}
|
||||
// Check for WSL environment variable
|
||||
@@ -157,8 +204,9 @@ export class TerminalService extends EventEmitter {
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
* Includes basic sanitization against null bytes and path normalization
|
||||
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
|
||||
*/
|
||||
private resolveWorkingDirectory(requestedCwd?: string): string {
|
||||
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// If no cwd requested, use home
|
||||
@@ -187,15 +235,19 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
// This prevents terminals from being opened in directories outside the allowed workspace
|
||||
try {
|
||||
const stat = fs.statSync(cwd);
|
||||
if (stat.isDirectory()) {
|
||||
const statResult = await secureFs.stat(cwd);
|
||||
if (statResult.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||
console.warn(
|
||||
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
|
||||
);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -228,7 +280,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Create a new terminal session
|
||||
* Returns null if the maximum session limit has been reached
|
||||
*/
|
||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
@@ -241,12 +293,23 @@ export class TerminalService extends EventEmitter {
|
||||
const shell = options.shell || detectedShell;
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
const cwd = await this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with some useful defaults
|
||||
// These settings ensure consistent terminal behavior across platforms
|
||||
// First, create a clean copy of process.env excluding Automaker-specific variables
|
||||
// that could pollute user shells (e.g., PORT would affect Next.js/other dev servers)
|
||||
const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH'];
|
||||
const cleanEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !automakerEnvVars.includes(key)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'automaker-terminal',
|
||||
|
||||
@@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync('git init', { cwd: tmpDir });
|
||||
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
|
||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||
|
||||
// Use environment variables instead of git config to avoid affecting user's git config
|
||||
// These env vars override git config without modifying it
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test User',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Test User',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
};
|
||||
|
||||
// Create initial commit
|
||||
await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n');
|
||||
await execAsync('git add .', { cwd: tmpDir });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync('git branch -M main', { cwd: tmpDir });
|
||||
|
||||
@@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => {
|
||||
async function initRepoWithoutCommit() {
|
||||
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
|
||||
await execAsync('git init', { cwd: repoPath });
|
||||
await execAsync('git config user.email "test@example.com"', {
|
||||
cwd: repoPath,
|
||||
});
|
||||
await execAsync('git config user.name "Test User"', { cwd: repoPath });
|
||||
// Don't set git config - use environment variables in commit operations instead
|
||||
// to avoid affecting user's git config
|
||||
// Intentionally skip creating an initial commit
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
378
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
378
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
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.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is null', async () => {
|
||||
const result = await getMCPServersFromSettings(null);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when settingsService is undefined', async () => {
|
||||
const result = await getMCPServersFromSettings(undefined);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when no MCP servers configured', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when mcpServers is undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert enabled stdio server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'test-server',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { NODE_ENV: 'test' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'test-server': {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { NODE_ENV: 'test' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert enabled SSE server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'sse-server',
|
||||
type: 'sse',
|
||||
url: 'http://localhost:3000/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'sse-server': {
|
||||
type: 'sse',
|
||||
url: 'http://localhost:3000/sse',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert enabled HTTP server to SDK format', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'http-server',
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/api',
|
||||
headers: { 'X-API-Key': 'secret' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
'http-server': {
|
||||
type: 'http',
|
||||
url: 'http://localhost:3000/api',
|
||||
headers: { 'X-API-Key': 'secret' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out disabled servers', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'enabled-server',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'disabled-server',
|
||||
type: 'stdio',
|
||||
command: 'python',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result['enabled-server']).toBeDefined();
|
||||
expect(result['disabled-server']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat servers without enabled field as enabled', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'implicit-enabled',
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
// enabled field not set
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result['implicit-enabled']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple enabled servers', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{ id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true },
|
||||
{ id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true },
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result['server1']).toBeDefined();
|
||||
expect(result['server2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty object and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({});
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should throw error for SSE server without URL', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-sse',
|
||||
type: 'sse',
|
||||
enabled: true,
|
||||
// url missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// The error is caught and logged, returns empty
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error for HTTP server without URL', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-http',
|
||||
type: 'http',
|
||||
enabled: true,
|
||||
// url missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error for stdio server without command', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bad-stdio',
|
||||
type: 'stdio',
|
||||
enabled: true,
|
||||
// command missing
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should default to stdio type when type is not specified', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpServers: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'no-type',
|
||||
command: 'node',
|
||||
enabled: true,
|
||||
// type not specified, should default to stdio
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||
expect(result['no-type']).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMCPPermissionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is null', async () => {
|
||||
const result = await getMCPPermissionSettings(null);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is undefined', async () => {
|
||||
const result = await getMCPPermissionSettings(undefined);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return settings from service', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to true when settings are undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed settings', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should use custom log prefix', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
await getMCPPermissionSettings(mockSettingsService, '[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();
|
||||
});
|
||||
|
||||
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js';
|
||||
import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js';
|
||||
import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js';
|
||||
import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js';
|
||||
import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js';
|
||||
import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js';
|
||||
import type { PipelineService } from '@/services/pipeline-service.js';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
|
||||
describe('pipeline routes', () => {
|
||||
let mockPipelineService: PipelineService;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPipelineService = {
|
||||
getPipelineConfig: vi.fn(),
|
||||
savePipelineConfig: vi.fn(),
|
||||
addStep: vi.fn(),
|
||||
updateStep: vi.fn(),
|
||||
deleteStep: vi.fn(),
|
||||
reorderSteps: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('get-config', () => {
|
||||
it('should return pipeline config successfully', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config);
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project');
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = {};
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Read failed');
|
||||
vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createGetConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Read failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('save-config', () => {
|
||||
it('should save pipeline config successfully', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', config };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { config: { version: 1, steps: [] } };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if config is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'config is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Save failed');
|
||||
vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
config: { version: 1, steps: [] },
|
||||
};
|
||||
|
||||
const handler = createSaveConfigHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Save failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add-step', () => {
|
||||
it('should add step successfully', async () => {
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
};
|
||||
|
||||
const newStep: PipelineStep = {
|
||||
...stepData,
|
||||
id: 'step1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep);
|
||||
req.body = { projectPath: '/test/project', step: stepData };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
step: newStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step.name is missing', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { order: 0, instructions: 'Do', colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step.name is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if step.instructions is missing', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { name: 'Step', order: 0, colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'step.instructions is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Add failed');
|
||||
vi.mocked(mockPipelineService.addStep).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' },
|
||||
};
|
||||
|
||||
const handler = createAddStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Add failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update-step', () => {
|
||||
it('should update step successfully', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Name',
|
||||
instructions: 'Updated instructions',
|
||||
};
|
||||
|
||||
const updatedStep: PipelineStep = {
|
||||
id: 'step1',
|
||||
name: 'Updated Name',
|
||||
order: 0,
|
||||
instructions: 'Updated instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1', updates };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.updateStep).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'step1',
|
||||
updates
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
step: updatedStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepId: 'step1', updates: { name: 'New' } };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepId is missing', async () => {
|
||||
req.body = { projectPath: '/test/project', updates: { name: 'New' } };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepId is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if updates is missing', async () => {
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'updates is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if updates is empty object', async () => {
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} };
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'updates is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Update failed');
|
||||
vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error);
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
stepId: 'step1',
|
||||
updates: { name: 'New' },
|
||||
};
|
||||
|
||||
const handler = createUpdateStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Update failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete-step', () => {
|
||||
it('should delete step successfully', async () => {
|
||||
vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1');
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepId is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepId is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Delete failed');
|
||||
vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project', stepId: 'step1' };
|
||||
|
||||
const handler = createDeleteStepHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Delete failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder-steps', () => {
|
||||
it('should reorder steps successfully', async () => {
|
||||
vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined);
|
||||
req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [
|
||||
'step2',
|
||||
'step1',
|
||||
'step3',
|
||||
]);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = { stepIds: ['step1', 'step2'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepIds is missing', async () => {
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepIds array is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if stepIds is not an array', async () => {
|
||||
req.body = { projectPath: '/test/project', stepIds: 'not-an-array' };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'stepIds array is required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const error = new Error('Reorder failed');
|
||||
vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error);
|
||||
req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] };
|
||||
|
||||
const handler = createReorderStepsHandler(mockPipelineService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Reorder failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
@@ -0,0 +1,860 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PipelineService } from '@/services/pipeline-service.js';
|
||||
import type { PipelineConfig, PipelineStep } from '@automaker/types';
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ensureAutomakerDir
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
ensureAutomakerDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { ensureAutomakerDir } from '@automaker/platform';
|
||||
|
||||
describe('pipeline-service.ts', () => {
|
||||
let testProjectDir: string;
|
||||
let pipelineService: PipelineService;
|
||||
|
||||
beforeEach(async () => {
|
||||
testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`);
|
||||
await fs.mkdir(testProjectDir, { recursive: true });
|
||||
pipelineService = new PipelineService();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testProjectDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('getPipelineConfig', () => {
|
||||
it('should return default config when file does not exist', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(config).toEqual({
|
||||
version: 1,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should read and return existing config', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8');
|
||||
expect(config).toEqual(existingConfig);
|
||||
});
|
||||
|
||||
it('should merge with defaults for missing properties', async () => {
|
||||
const partialConfig = {
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
expect(config.version).toBe(1);
|
||||
expect(config.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle read errors gracefully', async () => {
|
||||
const error = new Error('Read error');
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
|
||||
const config = await pipelineService.getPipelineConfig(testProjectDir);
|
||||
|
||||
// Should return default config on error
|
||||
expect(config).toEqual({
|
||||
version: 1,
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePipelineConfig', () => {
|
||||
it('should save config to file', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Test Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.savePipelineConfig(testProjectDir, config);
|
||||
|
||||
expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir);
|
||||
expect(secureFs.writeFile).toHaveBeenCalled();
|
||||
expect(secureFs.rename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use atomic write pattern', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.savePipelineConfig(testProjectDir, config);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const tempPath = writeCall[0] as string;
|
||||
expect(tempPath).toContain('.tmp.');
|
||||
expect(tempPath).toContain('pipeline.json');
|
||||
});
|
||||
|
||||
it('should clean up temp file on write error', async () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
vi.mocked(secureFs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow(
|
||||
'Write failed'
|
||||
);
|
||||
|
||||
expect(secureFs.unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addStep', () => {
|
||||
it('should add a new step to config', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 0,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
};
|
||||
|
||||
const newStep = await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
expect(newStep.name).toBe('New Step');
|
||||
expect(newStep.id).toMatch(/^step_/);
|
||||
expect(newStep.createdAt).toBeDefined();
|
||||
expect(newStep.updatedAt).toBeDefined();
|
||||
expect(newStep.createdAt).toBe(newStep.updatedAt);
|
||||
});
|
||||
|
||||
it('should normalize order values after adding step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 5, // Out of order
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 10, // Out of order
|
||||
instructions: 'Do something',
|
||||
colorClass: 'red',
|
||||
};
|
||||
|
||||
await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
});
|
||||
|
||||
it('should sort steps by order before normalizing', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 2,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 0,
|
||||
instructions: 'Do something else',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const stepData = {
|
||||
name: 'New Step',
|
||||
order: 1,
|
||||
instructions: 'Do something',
|
||||
colorClass: 'red',
|
||||
};
|
||||
|
||||
await pipelineService.addStep(testProjectDir, stepData);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
// Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2)
|
||||
expect(savedConfig.steps[0].id).toBe('step2');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
expect(savedConfig.steps[2].id).toBe('step1');
|
||||
expect(savedConfig.steps[2].order).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStep', () => {
|
||||
it('should update an existing step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Old Name',
|
||||
order: 0,
|
||||
instructions: 'Old instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const updates = {
|
||||
name: 'New Name',
|
||||
instructions: 'New instructions',
|
||||
};
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates);
|
||||
|
||||
expect(updatedStep.name).toBe('New Name');
|
||||
expect(updatedStep.instructions).toBe('New instructions');
|
||||
expect(updatedStep.id).toBe('step1');
|
||||
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(
|
||||
pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' })
|
||||
).rejects.toThrow('Pipeline step not found: nonexistent');
|
||||
});
|
||||
|
||||
it('should preserve createdAt when updating', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', {
|
||||
name: 'Updated',
|
||||
});
|
||||
|
||||
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteStep', () => {
|
||||
it('should delete an existing step', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.deleteStep(testProjectDir, 'step1');
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps).toHaveLength(1);
|
||||
expect(savedConfig.steps[0].id).toBe('step2');
|
||||
expect(savedConfig.steps[0].order).toBe(0); // Normalized
|
||||
});
|
||||
|
||||
it('should throw error if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow(
|
||||
'Pipeline step not found: nonexistent'
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize order values after deletion', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 5, // Out of order
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 10, // Out of order
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.deleteStep(testProjectDir, 'step2');
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps).toHaveLength(2);
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderSteps', () => {
|
||||
it('should reorder steps according to stepIds array', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].id).toBe('step3');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
expect(savedConfig.steps[1].id).toBe('step1');
|
||||
expect(savedConfig.steps[1].order).toBe(1);
|
||||
expect(savedConfig.steps[2].id).toBe('step2');
|
||||
expect(savedConfig.steps[2].order).toBe(2);
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp for reordered steps', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should throw error if step ID not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
await expect(
|
||||
pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent'])
|
||||
).rejects.toThrow('Pipeline step not found: nonexistent');
|
||||
});
|
||||
|
||||
it('should allow partial reordering (filtering steps)', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await pipelineService.reorderSteps(testProjectDir, ['step1']);
|
||||
|
||||
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
|
||||
// Should only keep step1, effectively filtering out step2
|
||||
expect(savedConfig.steps).toHaveLength(1);
|
||||
expect(savedConfig.steps[0].id).toBe('step1');
|
||||
expect(savedConfig.steps[0].order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextStatus', () => {
|
||||
it('should return waiting_approval when no pipeline and skipTests is true', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', null, true);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should return verified when no pipeline and skipTests is false', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', null, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should return first pipeline step when coming from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should go to next pipeline step when in middle of pipeline', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step2');
|
||||
});
|
||||
|
||||
it('should go to final status when completing last pipeline step', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should go to waiting_approval when completing last step with skipTests', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should handle invalid pipeline step ID gracefully', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should preserve other statuses unchanged', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog');
|
||||
expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe(
|
||||
'waiting_approval'
|
||||
);
|
||||
expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified');
|
||||
expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed');
|
||||
});
|
||||
|
||||
it('should sort steps by order when determining next status', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStep', () => {
|
||||
it('should return step by ID', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const step = await pipelineService.getStep(testProjectDir, 'step1');
|
||||
|
||||
expect(step).not.toBeNull();
|
||||
expect(step?.id).toBe('step1');
|
||||
expect(step?.name).toBe('Step 1');
|
||||
});
|
||||
|
||||
it('should return null if step not found', async () => {
|
||||
const existingConfig: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
|
||||
|
||||
const step = await pipelineService.getStep(testProjectDir, 'nonexistent');
|
||||
|
||||
expect(step).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPipelineStatus', () => {
|
||||
it('should return true for pipeline statuses', () => {
|
||||
expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true);
|
||||
expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-pipeline statuses', () => {
|
||||
expect(pipelineService.isPipelineStatus('in_progress')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('verified')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('backlog')).toBe(false);
|
||||
expect(pipelineService.isPipelineStatus('completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepIdFromStatus', () => {
|
||||
it('should extract step ID from pipeline status', () => {
|
||||
expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1');
|
||||
expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should return null for non-pipeline statuses', () => {
|
||||
expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull();
|
||||
expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull();
|
||||
expect(pipelineService.getStepIdFromStatus('verified')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;"]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.2",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
@@ -10,6 +10,9 @@
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
38
apps/ui/public/readme_logo.svg
Normal file
38
apps/ui/public/readme_logo.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1456" height="330" viewBox="0 0 1456 330" role="img" aria-label="Automaker Logo">
|
||||
<defs>
|
||||
<!-- Brand Gradient -->
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6B5BFF" />
|
||||
<stop offset="100%" stop-color="#2EC7FF" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Shadow filter -->
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Optional subtle background to ensure contrast on all GitHub themes -->
|
||||
<rect width="1456" height="330" rx="20" fill="#09090b" />
|
||||
|
||||
<!-- Logo Icon (Left Side) -->
|
||||
<g transform="translate(80, 65)">
|
||||
<!-- Rounded square background -->
|
||||
<rect width="200" height="200" rx="50" fill="url(#bg)" />
|
||||
|
||||
<!-- Icon paths -->
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)" transform="translate(100, 100)">
|
||||
<!-- Left bracket < -->
|
||||
<path d="M-30 -30 L-55 0 L-30 30" />
|
||||
<!-- Slash / -->
|
||||
<path d="M15 -45 L-15 45" />
|
||||
<!-- Right bracket > -->
|
||||
<path d="M30 -30 L55 0 L30 30" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Text Section -->
|
||||
<text x="320" y="215" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="160" font-weight="800" letter-spacing="-4" fill="#FFFFFF">
|
||||
automaker<tspan fill="#6B5BFF">.</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
92
apps/ui/scripts/bump-version.mjs
Executable file
92
apps/ui/scripts/bump-version.mjs
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bumps the version in apps/ui/package.json and apps/server/package.json
|
||||
* Usage: node scripts/bump-version.mjs [major|minor|patch]
|
||||
* Example: node scripts/bump-version.mjs patch
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const bumpType = process.argv[2]?.toLowerCase();
|
||||
|
||||
if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) {
|
||||
console.error('Error: Bump type argument is required');
|
||||
console.error('Usage: node scripts/bump-version.mjs [major|minor|patch]');
|
||||
console.error('Example: node scripts/bump-version.mjs patch');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const uiPackageJsonPath = join(__dirname, '..', 'package.json');
|
||||
const serverPackageJsonPath = join(__dirname, '..', '..', 'server', 'package.json');
|
||||
|
||||
function bumpVersion(packageJsonPath, packageName) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const oldVersion = packageJson.version;
|
||||
|
||||
// Parse version
|
||||
const versionParts = oldVersion.split('.').map(Number);
|
||||
if (versionParts.length !== 3) {
|
||||
console.error(`Error: Invalid version format in ${packageName}: ${oldVersion}`);
|
||||
console.error('Expected format: X.Y.Z (e.g., 1.2.3)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Bump version
|
||||
let [major, minor, patch] = versionParts;
|
||||
|
||||
switch (bumpType) {
|
||||
case 'major':
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
break;
|
||||
case 'patch':
|
||||
patch += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const newVersion = `${major}.${minor}.${patch}`;
|
||||
packageJson.version = newVersion;
|
||||
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
|
||||
|
||||
return newVersion;
|
||||
} catch (error) {
|
||||
console.error(`Error bumping version in ${packageName}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Bump UI package version
|
||||
const uiOldVersion = JSON.parse(readFileSync(uiPackageJsonPath, 'utf8')).version;
|
||||
const uiNewVersion = bumpVersion(uiPackageJsonPath, '@automaker/ui');
|
||||
|
||||
// Bump server package version (sync with UI)
|
||||
const serverOldVersion = JSON.parse(readFileSync(serverPackageJsonPath, 'utf8')).version;
|
||||
const serverNewVersion = bumpVersion(serverPackageJsonPath, '@automaker/server');
|
||||
|
||||
// Verify versions match
|
||||
if (uiNewVersion !== serverNewVersion) {
|
||||
console.error(`Error: Version mismatch! UI: ${uiNewVersion}, Server: ${serverNewVersion}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ Bumped version from ${uiOldVersion} to ${uiNewVersion} (${bumpType})`);
|
||||
console.log(`📦 Updated @automaker/ui: ${uiOldVersion} -> ${uiNewVersion}`);
|
||||
console.log(`📦 Updated @automaker/server: ${serverOldVersion} -> ${serverNewVersion}`);
|
||||
console.log(`📦 Version is now: ${uiNewVersion}`);
|
||||
} catch (error) {
|
||||
console.error(`Error bumping version: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
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) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -62,7 +62,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
// Update preview image when background settings change
|
||||
useEffect(() => {
|
||||
if (currentProject && backgroundSettings.imagePath) {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
// Add cache-busting query parameter to force browser to reload image
|
||||
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
|
||||
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ interface AutomakerLogoProps {
|
||||
}
|
||||
|
||||
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -17,7 +19,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{!sidebarOpen ? (
|
||||
<div className="relative flex items-center justify-center rounded-lg">
|
||||
<div className="relative flex flex-col items-center justify-center rounded-lg gap-0.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
@@ -61,54 +63,62 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
<div className={cn('flex flex-col', 'hidden lg:flex')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="automaker"
|
||||
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-expanded"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#iconShadow-expanded)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
|
||||
v{appVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getServerUrlSync } from '@/lib/http-api-client';
|
||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||
import {
|
||||
sanitizeFilename,
|
||||
@@ -93,7 +94,7 @@ export function DescriptionImageDropZone({
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback(
|
||||
(imagePath: string): string => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
|
||||
const projectPath = currentProject?.path || '';
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
},
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Update Notifier Component
|
||||
*
|
||||
* Responsible for displaying toast notifications related to updates.
|
||||
* Subscribes to the updates store and reacts to state changes.
|
||||
*
|
||||
* This component handles the UI notifications, keeping them separate
|
||||
* from the business logic in the store.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useUpdatesStore } from '@/store/updates-store';
|
||||
import { useUpdatePolling } from '@/hooks/use-update-polling';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getRepoDisplayName } from '@/lib/utils';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UpdateNotifierProps {
|
||||
/** Custom handler for update available (for testing/DI) */
|
||||
onUpdateAvailable?: (remoteVersion: string) => void;
|
||||
|
||||
/** Custom handler for update installed (for testing/DI) */
|
||||
onUpdateInstalled?: (newVersion: string, alreadyUpToDate: boolean) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Displays persistent toasts for available and installed application updates.
|
||||
*
|
||||
* Shows a persistent "Update Available" toast when a new remote version is detected and,
|
||||
* after initiating an update, shows success toasts for either "Already up to date!" or
|
||||
* "Update installed!" with actions to restart now or later.
|
||||
*
|
||||
* @param onUpdateAvailable - Optional callback invoked with `remoteVersion` when an update is detected; providing this prevents the default availability toast.
|
||||
* @param onUpdateInstalled - Optional callback invoked with `(newVersion, alreadyUpToDate)` after attempting to install updates; providing this prevents the default installation toasts.
|
||||
* @returns Null (this component renders no visible UI; it manages global toast notifications).
|
||||
*/
|
||||
export function UpdateNotifier({ onUpdateAvailable, onUpdateInstalled }: UpdateNotifierProps = {}) {
|
||||
// Store state
|
||||
const { updateAvailable, remoteVersionShort, pullUpdates, isPulling } = useUpdatesStore();
|
||||
|
||||
const { autoUpdate } = useAppStore();
|
||||
|
||||
// Start polling
|
||||
useUpdatePolling();
|
||||
|
||||
// Track shown toasts to avoid duplicates
|
||||
const shownToastForCommitRef = useRef<string | null>(null);
|
||||
const toastIdRef = useRef<string | number | null>(null);
|
||||
|
||||
// Handle "Update Now" click
|
||||
const handleUpdateNow = useCallback(async () => {
|
||||
const result = await pullUpdates();
|
||||
|
||||
if (result) {
|
||||
// Dismiss the "update available" toast
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
toastIdRef.current = null;
|
||||
}
|
||||
|
||||
// Call custom handler if provided
|
||||
if (onUpdateInstalled) {
|
||||
onUpdateInstalled(result.newVersionShort, result.alreadyUpToDate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show appropriate toast based on result
|
||||
if (result.alreadyUpToDate) {
|
||||
toast.success('Already up to date!');
|
||||
} else {
|
||||
toast.success('Update installed!', {
|
||||
description: result.message,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Restart Now',
|
||||
onClick: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: 'Later',
|
||||
onClick: () => {
|
||||
// Just dismiss - user will restart manually later
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [pullUpdates, onUpdateInstalled]);
|
||||
|
||||
// Show toast when update becomes available
|
||||
useEffect(() => {
|
||||
if (!updateAvailable || !remoteVersionShort) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show toast if we've already shown it for this version
|
||||
if (shownToastForCommitRef.current === remoteVersionShort) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownToastForCommitRef.current = remoteVersionShort;
|
||||
|
||||
// Call custom handler if provided
|
||||
if (onUpdateAvailable) {
|
||||
onUpdateAvailable(remoteVersionShort);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss any existing toast
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
}
|
||||
|
||||
// Extract repo name for display
|
||||
const repoName = getRepoDisplayName(autoUpdate.upstreamUrl);
|
||||
|
||||
// Show persistent toast with update button
|
||||
toastIdRef.current = toast.info('Update Available', {
|
||||
description: `New version (${remoteVersionShort}) available from ${repoName}`,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: isPulling ? 'Updating...' : 'Update Now',
|
||||
onClick: handleUpdateNow,
|
||||
},
|
||||
cancel: {
|
||||
label: 'Later',
|
||||
onClick: () => {
|
||||
// Dismiss toast - won't show again for this version until a new version appears
|
||||
shownToastForCommitRef.current = remoteVersionShort;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [
|
||||
updateAvailable,
|
||||
remoteVersionShort,
|
||||
autoUpdate.upstreamUrl,
|
||||
isPulling,
|
||||
handleUpdateNow,
|
||||
onUpdateAvailable,
|
||||
]);
|
||||
|
||||
// Clean up toast on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset shown toast when update is no longer available
|
||||
useEffect(() => {
|
||||
if (!updateAvailable) {
|
||||
shownToastForCommitRef.current = null;
|
||||
if (toastIdRef.current) {
|
||||
toast.dismiss(toastIdRef.current);
|
||||
toastIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [updateAvailable]);
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user