forked from claude-did-this/claude-hub
Compare commits
76 Commits
feat/optim
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cad59dc677 | |||
| cf71cd7104 | |||
| 752cc3a0fc | |||
| 8f55bfac35 | |||
| fea55b9d94 | |||
| b4527b8d2f | |||
| 65dd97e46b | |||
| b6d5f0f399 | |||
| 00cfc5ffbb | |||
|
|
3c8aebced8 | ||
|
|
c067efa13e | ||
|
|
65a590784c | ||
|
|
9a8187d72a | ||
|
|
42201732c1 | ||
|
|
be941b2149 | ||
|
|
a423786200 | ||
|
|
ea812f5b8f | ||
|
|
346199ebbd | ||
|
|
8da021bb00 | ||
|
|
8926d0026d | ||
|
|
dd5e6e6146 | ||
|
|
bf2a517264 | ||
|
|
348d4acaf8 | ||
|
|
f0edb5695f | ||
|
|
152788abec | ||
|
|
c235334223 | ||
|
|
9e5b3c3d20 | ||
|
|
bf1c42f5ca | ||
|
|
f765e2ac3e | ||
|
|
14785b2e64 | ||
|
|
faa60f4f55 | ||
|
|
4ece2969b3 | ||
|
|
295c182351 | ||
|
|
af851491e8 | ||
|
|
31efbbc2bb | ||
|
|
2e5fa7aa26 | ||
|
|
caad85d7a0 | ||
|
|
acf44b1c63 | ||
|
|
e463f2e5c5 | ||
|
|
150626b171 | ||
|
|
b028502a82 | ||
|
|
12e4589169 | ||
|
|
53d77c2856 | ||
|
|
df756e15ae | ||
|
|
f7399f8ad1 | ||
|
|
6b05644731 | ||
|
|
c837f36463 | ||
|
|
67e90c4b87 | ||
|
|
bddfc70f20 | ||
|
|
ddd5f97f8a | ||
|
|
cb1329d512 | ||
|
|
6cfbc0721c | ||
|
|
f5f7520588 | ||
|
|
41903540ea | ||
|
|
b23c5b1942 | ||
|
|
f42017f2a5 | ||
|
|
1c4cc39209 | ||
|
|
a40da0267e | ||
|
|
0035b7cac8 | ||
|
|
62ee5f4917 | ||
|
|
6b319fa511 | ||
|
|
e7f19d8307 | ||
|
|
a71cdcad40 | ||
|
|
cee3cd29f6 | ||
|
|
bac1583b46 | ||
|
|
e095826e02 | ||
|
|
426ac442e2 | ||
|
|
25b90a5d7c | ||
|
|
a45b039777 | ||
|
|
0169f338b0 | ||
|
|
d284bd6b33 | ||
|
|
cb5a6bf529 | ||
|
|
886544b1ad | ||
|
|
bda604bfdc | ||
|
|
f27009af37 | ||
|
|
57608e021b |
@@ -1,19 +1,20 @@
|
||||
codecov:
|
||||
require_ci_to_pass: false
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
threshold: 5%
|
||||
base: auto
|
||||
# Only check coverage on main branch
|
||||
if_ci_failed: error
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
target: 50% # Lower diff coverage threshold - many changes are config/setup
|
||||
threshold: 15% # Allow 15% variance for diff coverage
|
||||
base: auto
|
||||
# Only check coverage on main branch
|
||||
if_ci_failed: error
|
||||
@@ -25,4 +26,4 @@ comment:
|
||||
|
||||
github_checks:
|
||||
# Disable check suites to prevent hanging on non-main branches
|
||||
annotations: false
|
||||
annotations: false
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
test-results
|
||||
*.log
|
||||
logs
|
||||
|
||||
# Development
|
||||
.husky
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
CLAUDE.local.md
|
||||
secrets
|
||||
k8s
|
||||
docs
|
||||
test
|
||||
*.test.js
|
||||
*.spec.js
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
!CLAUDE.md
|
||||
!README.dockerhub.md
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
!.github/workflows
|
||||
|
||||
# Secrets
|
||||
secrets
|
||||
CLAUDE.local.md
|
||||
|
||||
# Kubernetes
|
||||
k8s
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
!docker-compose.test.yml
|
||||
Dockerfile*
|
||||
!Dockerfile
|
||||
!Dockerfile.claudecode
|
||||
.dockerignore
|
||||
|
||||
# Scripts - exclude all by default for security, then explicitly include needed runtime scripts
|
||||
*.sh
|
||||
!scripts/runtime/*.sh
|
||||
!scripts/runtime/*.sh
|
||||
|
||||
# Test files (keep for test stage)
|
||||
# Removed test exclusion to allow test stage to access tests
|
||||
|
||||
# Build artifacts
|
||||
*.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
.buildx-cache*
|
||||
tmp
|
||||
temp
|
||||
77
.env.example
77
.env.example
@@ -2,9 +2,36 @@
|
||||
NODE_ENV=development
|
||||
PORT=3002
|
||||
|
||||
# GitHub Webhook Settings
|
||||
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
GITHUB_TOKEN=ghp_your_github_token_here
|
||||
# Trust Proxy Configuration
|
||||
# Set to 'true' when running behind reverse proxies (nginx, cloudflare, etc.)
|
||||
# This allows proper handling of X-Forwarded-For headers for rate limiting
|
||||
TRUST_PROXY=false
|
||||
|
||||
# ============================
|
||||
# SECRETS CONFIGURATION
|
||||
# ============================
|
||||
# The application supports two methods for providing secrets:
|
||||
#
|
||||
# 1. Environment Variables (shown below) - Convenient for development
|
||||
# 2. Secret Files - More secure for production
|
||||
#
|
||||
# If both are provided, SECRET FILES TAKE PRIORITY over environment variables.
|
||||
#
|
||||
# For file-based secrets, the app looks for files at:
|
||||
# - /run/secrets/gitea_token (or path in GITEA_TOKEN_FILE)
|
||||
# - /run/secrets/anthropic_api_key (or path in ANTHROPIC_API_KEY_FILE)
|
||||
# - /run/secrets/gitea_webhook_secret (or path in GITEA_WEBHOOK_SECRET_FILE)
|
||||
#
|
||||
# To use file-based secrets in development:
|
||||
# 1. Create a secrets directory: mkdir secrets
|
||||
# 2. Add secret files: echo "your-secret" > secrets/gitea_token.txt
|
||||
# 3. Mount in docker-compose or use GITEA_TOKEN_FILE=/path/to/secret
|
||||
# ============================
|
||||
|
||||
# Gitea Webhook Settings
|
||||
GITEA_API_URL=https://your-gitea-instance.com/api/v1
|
||||
GITEA_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
GITEA_TOKEN=your_gitea_token_here
|
||||
|
||||
# Bot Configuration (REQUIRED)
|
||||
BOT_USERNAME=@ClaudeBot
|
||||
@@ -14,21 +41,35 @@ BOT_EMAIL=claude@example.com
|
||||
AUTHORIZED_USERS=admin,username2,username3
|
||||
DEFAULT_AUTHORIZED_USER=admin
|
||||
|
||||
# Default GitHub Configuration for CLI
|
||||
DEFAULT_GITHUB_OWNER=your-org
|
||||
DEFAULT_GITHUB_USER=your-username
|
||||
# Default Gitea Configuration for CLI
|
||||
DEFAULT_GITEA_OWNER=your-org
|
||||
DEFAULT_GITEA_USER=your-username
|
||||
DEFAULT_BRANCH=main
|
||||
|
||||
# Claude API Settings
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Claude Hub Directory
|
||||
# Directory where Claude Hub stores configuration, authentication, and database files (default: ~/.claude-hub)
|
||||
CLAUDE_HUB_DIR=/home/user/.claude-hub
|
||||
|
||||
# Container Settings
|
||||
CLAUDE_USE_CONTAINERS=1
|
||||
CLAUDE_CONTAINER_IMAGE=claudecode:latest
|
||||
CLAUDE_CONTAINER_PRIVILEGED=false
|
||||
REPO_CACHE_DIR=/tmp/repo-cache
|
||||
REPO_CACHE_MAX_AGE_MS=3600000
|
||||
CONTAINER_LIFETIME_MS=7200000 # Container execution timeout in milliseconds (default: 2 hours)
|
||||
|
||||
# Claude Code Timeout Settings (for unattended mode)
|
||||
BASH_DEFAULT_TIMEOUT_MS=600000 # Default timeout for bash commands (10 minutes)
|
||||
BASH_MAX_TIMEOUT_MS=1200000 # Maximum timeout Claude can set (20 minutes)
|
||||
|
||||
# Container Resource Limits
|
||||
CLAUDE_CONTAINER_CPU_SHARES=1024
|
||||
CLAUDE_CONTAINER_MEMORY_LIMIT=2g
|
||||
CLAUDE_CONTAINER_PIDS_LIMIT=256
|
||||
|
||||
# AWS Bedrock Credentials for Claude (if using Bedrock)
|
||||
AWS_ACCESS_KEY_ID=your_aws_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key
|
||||
@@ -40,18 +81,13 @@ ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0
|
||||
# USE_AWS_PROFILE=true
|
||||
# AWS_PROFILE=claude-webhook
|
||||
|
||||
# Discord Chatbot Configuration
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_discord_public_key
|
||||
DISCORD_APPLICATION_ID=your_discord_application_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2,admin
|
||||
DISCORD_BOT_MENTION=claude
|
||||
|
||||
# Container Capabilities (optional)
|
||||
CLAUDE_CONTAINER_CAP_NET_RAW=true
|
||||
CLAUDE_CONTAINER_CAP_SYS_TIME=false
|
||||
CLAUDE_CONTAINER_CAP_DAC_OVERRIDE=true
|
||||
CLAUDE_CONTAINER_CAP_AUDIT_WRITE=true
|
||||
CLAUDE_CONTAINER_CAP_SYS_ADMIN=false
|
||||
|
||||
# PR Review Configuration
|
||||
PR_REVIEW_WAIT_FOR_ALL_CHECKS=true
|
||||
@@ -61,4 +97,19 @@ PR_REVIEW_MAX_WAIT_MS=1800000
|
||||
PR_REVIEW_CONDITIONAL_TIMEOUT_MS=300000
|
||||
|
||||
# Test Configuration
|
||||
TEST_REPO_FULL_NAME=owner/repo
|
||||
TEST_REPO_FULL_NAME=owner/repo
|
||||
|
||||
# Security Configuration (optional)
|
||||
# DISABLE_LOG_REDACTION=false # WARNING: Only enable for debugging, exposes sensitive data in logs
|
||||
|
||||
# File-based Secrets (optional, takes priority over environment variables)
|
||||
# GITEA_TOKEN_FILE=/run/secrets/gitea_token
|
||||
# ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key
|
||||
# GITEA_WEBHOOK_SECRET_FILE=/run/secrets/gitea_webhook_secret
|
||||
|
||||
# Authentication Methods (optional)
|
||||
# CLAUDE_AUTH_HOST_DIR=/path/to/claude/auth # For setup container authentication
|
||||
|
||||
# CLI Configuration (optional)
|
||||
# API_URL=http://localhost:3003 # Default API URL for CLI tool
|
||||
# WEBHOOK_URL=http://localhost:3002/api/webhooks/gitea # Webhook endpoint URL
|
||||
|
||||
25
.env.quickstart
Normal file
25
.env.quickstart
Normal file
@@ -0,0 +1,25 @@
|
||||
# Claude GitHub Webhook - Quick Start Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
#
|
||||
# cp .env.quickstart .env
|
||||
#
|
||||
# Only the essentials to get up and running in 10 minutes
|
||||
|
||||
# GitHub Configuration (Required)
|
||||
GITHUB_TOKEN=ghp_your_github_token_here
|
||||
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
|
||||
# Bot Identity (Required)
|
||||
BOT_USERNAME=@YourBotName
|
||||
BOT_EMAIL=bot@example.com
|
||||
|
||||
# Security - Who can use the bot
|
||||
AUTHORIZED_USERS=your-github-username
|
||||
DEFAULT_AUTHORIZED_USER=your-github-username
|
||||
|
||||
# Port (default: 3002)
|
||||
PORT=3002
|
||||
|
||||
# That's it! The setup script will handle Claude authentication.
|
||||
# Run: ./scripts/setup/setup-claude-interactive.sh
|
||||
|
||||
28
.github/CLAUDE.md
vendored
28
.github/CLAUDE.md
vendored
@@ -212,27 +212,17 @@ deploy:
|
||||
6. **No duplicate workflows**: Use reusable workflows for common tasks
|
||||
7. **No missing permissions**: Always specify required permissions
|
||||
|
||||
## Workflow Types
|
||||
## Workflow Types (Simplified)
|
||||
|
||||
### 1. CI Workflow (`ci.yml`)
|
||||
- Runs on every PR and push
|
||||
- Tests, linting, security scans
|
||||
- No deployments or publishing
|
||||
### 1. Pull Request (`pull-request.yml`)
|
||||
- Fast feedback loop
|
||||
- Lint, unit tests, basic security
|
||||
- Docker build only if relevant files changed
|
||||
|
||||
### 2. Deploy Workflow (`deploy.yml`)
|
||||
- Runs on main branch and tags only
|
||||
- Builds and deploys applications
|
||||
- Includes staging and production environments
|
||||
|
||||
### 3. Security Workflow (`security.yml`)
|
||||
- Runs on schedule and PRs
|
||||
- Comprehensive security scanning
|
||||
- Blocks merging on critical issues
|
||||
|
||||
### 4. Release Workflow (`release.yml`)
|
||||
- Runs on version tags only
|
||||
- Creates GitHub releases
|
||||
- Publishes to package registries
|
||||
### 2. Main Pipeline (`main.yml`)
|
||||
- Complete testing and deployment
|
||||
- Coverage reporting, security scans
|
||||
- Docker builds and publishing
|
||||
|
||||
## Checklist for New Workflows
|
||||
|
||||
|
||||
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@@ -9,9 +9,9 @@ updates:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
reviewers:
|
||||
- "intelligence-assist"
|
||||
- "MCPClaude"
|
||||
assignees:
|
||||
- "intelligence-assist"
|
||||
- "MCPClaude"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Enable version updates for Docker
|
||||
@@ -23,9 +23,9 @@ updates:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
reviewers:
|
||||
- "intelligence-assist"
|
||||
- "MCPClaude"
|
||||
assignees:
|
||||
- "intelligence-assist"
|
||||
- "MCPClaude"
|
||||
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
@@ -36,6 +36,6 @@ updates:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
reviewers:
|
||||
- "intelligence-assist"
|
||||
- "MCPClaude"
|
||||
assignees:
|
||||
- "intelligence-assist"
|
||||
- "MCPClaude"
|
||||
290
.github/workflows/ci.yml
vendored
290
.github/workflows/ci.yml
vendored
@@ -1,290 +0,0 @@
|
||||
name: CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Lint job - fast and independent
|
||||
lint:
|
||||
name: Lint & Format Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint:check || echo "No lint script found, skipping"
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check || echo "No format script found, skipping"
|
||||
|
||||
# Unit tests - fastest test suite
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
NODE_ENV: test
|
||||
BOT_USERNAME: '@TestBot'
|
||||
GITHUB_WEBHOOK_SECRET: 'test-secret'
|
||||
GITHUB_TOKEN: 'test-token'
|
||||
|
||||
# Integration tests - moderate complexity
|
||||
test-integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration || echo "No integration tests found, skipping"
|
||||
env:
|
||||
NODE_ENV: test
|
||||
BOT_USERNAME: '@TestBot'
|
||||
GITHUB_WEBHOOK_SECRET: 'test-secret'
|
||||
GITHUB_TOKEN: 'test-token'
|
||||
|
||||
|
||||
# Coverage generation - depends on unit tests
|
||||
coverage:
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-unit]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Generate test coverage
|
||||
run: npm run test:ci
|
||||
env:
|
||||
NODE_ENV: test
|
||||
BOT_USERNAME: '@TestBot'
|
||||
GITHUB_WEBHOOK_SECRET: 'test-secret'
|
||||
GITHUB_TOKEN: 'test-token'
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: intelligence-assist/claude-hub
|
||||
|
||||
# Security scans - run on GitHub for faster execution
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=moderate
|
||||
|
||||
- name: Run security scan with Snyk
|
||||
uses: snyk/actions/node@master
|
||||
continue-on-error: true
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
args: --severity-threshold=high
|
||||
|
||||
# Check if Docker-related files changed
|
||||
changes:
|
||||
name: Detect Changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docker: ${{ steps.changes.outputs.docker }}
|
||||
src: ${{ steps.changes.outputs.src }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
docker:
|
||||
- 'Dockerfile*'
|
||||
- 'scripts/**'
|
||||
- '.dockerignore'
|
||||
- 'claude-config*'
|
||||
src:
|
||||
- 'src/**'
|
||||
- 'package*.json'
|
||||
|
||||
# Docker builds - only when relevant files change
|
||||
docker:
|
||||
name: Docker Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on main branch or version tags, not on PRs
|
||||
if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && github.event_name != 'pull_request' && (needs.changes.outputs.docker == 'true' || needs.changes.outputs.src == 'true')
|
||||
# Only need unit tests to pass for Docker builds
|
||||
needs: [test-unit, lint, changes]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Start build profiling
|
||||
run: |
|
||||
echo "BUILD_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
echo "🏗️ Docker build started at $(date)"
|
||||
|
||||
- name: Set up Docker layer caching
|
||||
run: |
|
||||
# Create cache mount directories
|
||||
mkdir -p /tmp/.buildx-cache-main /tmp/.buildx-cache-claude
|
||||
|
||||
- name: Build main Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: claude-github-webhook:test
|
||||
cache-from: |
|
||||
type=gha,scope=main
|
||||
type=local,src=/tmp/.buildx-cache-main
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=main
|
||||
type=local,dest=/tmp/.buildx-cache-main-new,mode=max
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
- name: Build Claude Code Docker image (parallel)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.claudecode
|
||||
push: false
|
||||
load: true
|
||||
tags: claude-code-runner:test
|
||||
cache-from: |
|
||||
type=gha,scope=claudecode
|
||||
type=local,src=/tmp/.buildx-cache-claude
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=claudecode
|
||||
type=local,dest=/tmp/.buildx-cache-claude-new,mode=max
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
- name: Rotate build caches
|
||||
run: |
|
||||
# Rotate caches to avoid size limits
|
||||
rm -rf /tmp/.buildx-cache-main /tmp/.buildx-cache-claude
|
||||
mv /tmp/.buildx-cache-main-new /tmp/.buildx-cache-main 2>/dev/null || true
|
||||
mv /tmp/.buildx-cache-claude-new /tmp/.buildx-cache-claude 2>/dev/null || true
|
||||
|
||||
- name: Profile build performance
|
||||
run: |
|
||||
BUILD_END_TIME=$(date +%s)
|
||||
BUILD_DURATION=$((BUILD_END_TIME - BUILD_START_TIME))
|
||||
echo "🏁 Docker build completed at $(date)"
|
||||
echo "⏱️ Total build time: ${BUILD_DURATION} seconds"
|
||||
|
||||
# Check image sizes
|
||||
echo "📦 Image sizes:"
|
||||
docker images | grep -E "(claude-github-webhook|claude-code-runner):test" || true
|
||||
|
||||
# Show cache usage
|
||||
echo "💾 Cache statistics:"
|
||||
du -sh /tmp/.buildx-cache-* 2>/dev/null || echo "No local caches found"
|
||||
|
||||
# Performance summary
|
||||
if [ $BUILD_DURATION -lt 120 ]; then
|
||||
echo "✅ Fast build (< 2 minutes)"
|
||||
elif [ $BUILD_DURATION -lt 300 ]; then
|
||||
echo "⚠️ Moderate build (2-5 minutes)"
|
||||
else
|
||||
echo "🐌 Slow build (> 5 minutes) - consider optimization"
|
||||
fi
|
||||
|
||||
- name: Test Docker containers
|
||||
run: |
|
||||
# Test main container starts correctly
|
||||
docker run --name test-webhook -d -p 3003:3002 \
|
||||
-e NODE_ENV=test \
|
||||
-e BOT_USERNAME=@TestBot \
|
||||
-e GITHUB_WEBHOOK_SECRET=test-secret \
|
||||
-e GITHUB_TOKEN=test-token \
|
||||
claude-github-webhook:test
|
||||
|
||||
# Wait for container to start
|
||||
sleep 10
|
||||
|
||||
# Test health endpoint
|
||||
curl -f http://localhost:3003/health || exit 1
|
||||
|
||||
# Cleanup
|
||||
docker stop test-webhook
|
||||
docker rm test-webhook
|
||||
40
.github/workflows/cli-tests.yml
vendored
Normal file
40
.github/workflows/cli-tests.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: CLI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
|
||||
jobs:
|
||||
cli-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: cli/package-lock.json
|
||||
|
||||
- name: Install CLI dependencies
|
||||
working-directory: ./cli
|
||||
run: npm ci
|
||||
|
||||
- name: TypeScript compilation check
|
||||
working-directory: ./cli
|
||||
run: npm run build
|
||||
|
||||
- name: Run all CLI tests (skipping Docker tests)
|
||||
working-directory: ./cli
|
||||
run: npm run test:all
|
||||
env:
|
||||
NODE_ENV: test
|
||||
SKIP_DOCKER_TESTS: "true"
|
||||
|
||||
- name: Generate test coverage report
|
||||
working-directory: ./cli
|
||||
run: npm run test:coverage
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -154,7 +154,7 @@ jobs:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
# ============================================
|
||||
# CD Jobs - Run on self-hosted runners
|
||||
# CD Jobs - Run on GitHub-hosted runners
|
||||
# ============================================
|
||||
|
||||
deploy-staging:
|
||||
|
||||
92
.github/workflows/docker-publish.yml
vendored
92
.github/workflows/docker-publish.yml
vendored
@@ -7,13 +7,10 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'Dockerfile*'
|
||||
- 'package*.json'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- 'src/**'
|
||||
- 'scripts/**'
|
||||
- 'claude-config*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
env:
|
||||
DOCKER_HUB_USERNAME: ${{ vars.DOCKER_HUB_USERNAME || 'cheffromspace' }}
|
||||
@@ -22,14 +19,31 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Always use GitHub-hosted runners
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Runner Information
|
||||
run: |
|
||||
echo "Running on: ${{ runner.name }}"
|
||||
echo "Runner OS: ${{ runner.os }}"
|
||||
echo "Runner labels: ${{ join(runner.labels, ', ') }}"
|
||||
|
||||
- name: Clean workspace (fix coverage permissions)
|
||||
run: |
|
||||
# Fix any existing coverage file permissions before checkout
|
||||
sudo find . -name "coverage" -type d -exec chmod -R 755 {} \; 2>/dev/null || true
|
||||
sudo rm -rf coverage 2>/dev/null || true
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -47,29 +61,47 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# For semantic version tags (v0.1.0 -> 0.1.0, 0.1, 0, latest)
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Latest tag for version tags
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
# Nightly tag for main branch pushes
|
||||
type=raw,value=nightly,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
# Build and test in container for PRs
|
||||
- name: Build and test Docker image (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
# Build the test stage
|
||||
docker build --target test -t ${{ env.IMAGE_NAME }}:test-${{ github.sha }} -f Dockerfile .
|
||||
|
||||
# Run tests in container (using default CMD from Dockerfile which runs unit tests only)
|
||||
docker run --rm \
|
||||
-e CI=true \
|
||||
-e NODE_ENV=test \
|
||||
-v ${{ github.workspace }}/coverage:/app/coverage \
|
||||
${{ env.IMAGE_NAME }}:test-${{ github.sha }}
|
||||
|
||||
# Build production image for smoke test
|
||||
docker build --target production -t ${{ env.IMAGE_NAME }}:pr-${{ github.event.number }} -f Dockerfile .
|
||||
|
||||
# Smoke test
|
||||
docker run --rm ${{ env.IMAGE_NAME }}:pr-${{ github.event.number }} \
|
||||
test -f /app/scripts/runtime/startup.sh && echo "✓ Startup script exists"
|
||||
|
||||
# Build and push for main branch
|
||||
- name: Build and push Docker image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: |
|
||||
type=gha,scope=publish-main
|
||||
type=local,src=/tmp/.buildx-cache-main
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=publish-main
|
||||
type=local,dest=/tmp/.buildx-cache-main-new,mode=max
|
||||
target: production
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update Docker Hub Description
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
@@ -81,18 +113,26 @@ jobs:
|
||||
readme-filepath: ./README.dockerhub.md
|
||||
short-description: ${{ github.event.repository.description }}
|
||||
|
||||
# Additional job to build and push the Claude Code container
|
||||
# Build claudecode separately
|
||||
build-claudecode:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run when not a pull request
|
||||
if: github.event_name != 'pull_request'
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Clean workspace (fix coverage permissions)
|
||||
run: |
|
||||
# Fix any existing coverage file permissions before checkout
|
||||
sudo find . -name "coverage" -type d -exec chmod -R 755 {} \; 2>/dev/null || true
|
||||
sudo rm -rf coverage 2>/dev/null || true
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -112,9 +152,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Latest tag for version tags
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
# Nightly tag for main branch pushes
|
||||
type=raw,value=nightly,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Claude Code Docker image
|
||||
@@ -126,9 +164,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-claudecode.outputs.tags }}
|
||||
labels: ${{ steps.meta-claudecode.outputs.labels }}
|
||||
cache-from: |
|
||||
type=gha,scope=publish-claudecode
|
||||
type=local,src=/tmp/.buildx-cache-claude
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=publish-claudecode
|
||||
type=local,dest=/tmp/.buildx-cache-claude-new,mode=max
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Note: Fallback job removed since we're always using GitHub-hosted runners
|
||||
72
.github/workflows/main.yml
vendored
Normal file
72
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Main Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint:check
|
||||
- name: Install CLI dependencies
|
||||
working-directory: ./cli
|
||||
run: npm ci
|
||||
- name: Generate combined coverage
|
||||
run: ./scripts/combine-coverage.js
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
directory: ./coverage-combined
|
||||
fail_ci_if_error: true
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: ./scripts/security/credential-audit.sh
|
||||
- uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
head: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
extra_args: --debug --only-verified
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, security]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
346
.github/workflows/pr.yml
vendored
346
.github/workflows/pr.yml
vendored
@@ -1,346 +0,0 @@
|
||||
name: Pull Request CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
|
||||
jobs:
|
||||
# Lint job - fast and independent
|
||||
lint:
|
||||
name: Lint & Format Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint:check || echo "No lint script found, skipping"
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check || echo "No format script found, skipping"
|
||||
|
||||
# Unit tests - fastest test suite
|
||||
test-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
NODE_ENV: test
|
||||
BOT_USERNAME: '@TestBot'
|
||||
GITHUB_WEBHOOK_SECRET: 'test-secret'
|
||||
GITHUB_TOKEN: 'test-token'
|
||||
|
||||
# Coverage generation for PR feedback
|
||||
coverage:
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-unit]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Generate test coverage
|
||||
run: npm run test:ci
|
||||
env:
|
||||
NODE_ENV: test
|
||||
BOT_USERNAME: '@TestBot'
|
||||
GITHUB_WEBHOOK_SECRET: 'test-secret'
|
||||
GITHUB_TOKEN: 'test-token'
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: intelligence-assist/claude-hub
|
||||
|
||||
# Integration tests - moderate complexity
|
||||
test-integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration || echo "No integration tests found, skipping"
|
||||
env:
|
||||
NODE_ENV: test
|
||||
BOT_USERNAME: '@TestBot'
|
||||
GITHUB_WEBHOOK_SECRET: 'test-secret'
|
||||
GITHUB_TOKEN: 'test-token'
|
||||
|
||||
# Security scans for PRs
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for secret scanning
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --prefer-offline --no-audit
|
||||
|
||||
- name: Run npm audit
|
||||
run: |
|
||||
npm audit --audit-level=moderate || {
|
||||
echo "::warning::npm audit found vulnerabilities"
|
||||
exit 0 # Don't fail the build, but warn
|
||||
}
|
||||
|
||||
- name: Check for known vulnerabilities
|
||||
run: npm run security:audit || echo "::warning::Security audit script failed"
|
||||
|
||||
- name: Run credential audit script
|
||||
run: |
|
||||
if [ -f "./scripts/security/credential-audit.sh" ]; then
|
||||
./scripts/security/credential-audit.sh || {
|
||||
echo "::error::Credential audit failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo "::warning::Credential audit script not found"
|
||||
fi
|
||||
|
||||
- name: TruffleHog Secret Scan
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
extra_args: --debug --only-verified
|
||||
|
||||
- name: Check for high-risk files
|
||||
run: |
|
||||
# Check for files that commonly contain secrets
|
||||
risk_files=$(find . -type f \( \
|
||||
-name "*.pem" -o \
|
||||
-name "*.key" -o \
|
||||
-name "*.p12" -o \
|
||||
-name "*.pfx" -o \
|
||||
-name "*secret*" -o \
|
||||
-name "*password*" -o \
|
||||
-name "*credential*" \
|
||||
\) -not -path "*/node_modules/*" -not -path "*/.git/*" | head -20)
|
||||
|
||||
if [ -n "$risk_files" ]; then
|
||||
echo "⚠️ Found potentially sensitive files:"
|
||||
echo "$risk_files"
|
||||
echo "::warning::High-risk files detected. Please ensure they don't contain secrets."
|
||||
fi
|
||||
|
||||
# CodeQL analysis for PRs
|
||||
codeql:
|
||||
name: CodeQL Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:javascript"
|
||||
|
||||
# Check if Docker-related files changed
|
||||
changes:
|
||||
name: Detect Changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docker: ${{ steps.changes.outputs.docker }}
|
||||
src: ${{ steps.changes.outputs.src }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
docker:
|
||||
- 'Dockerfile*'
|
||||
- 'scripts/**'
|
||||
- '.dockerignore'
|
||||
- 'claude-config*'
|
||||
src:
|
||||
- 'src/**'
|
||||
- 'package*.json'
|
||||
|
||||
# Docker build test for PRs (build only, don't push)
|
||||
docker-build:
|
||||
name: Docker Build Test
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.changes.outputs.docker == 'true' || needs.changes.outputs.src == 'true'
|
||||
needs: [test-unit, lint, changes, security, codeql]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build main Docker image (test only)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: claude-github-webhook:pr-test
|
||||
cache-from: type=gha,scope=pr-main
|
||||
cache-to: type=gha,mode=max,scope=pr-main
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build Claude Code Docker image (test only)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.claudecode
|
||||
push: false
|
||||
load: true
|
||||
tags: claude-code-runner:pr-test
|
||||
cache-from: type=gha,scope=pr-claudecode
|
||||
cache-to: type=gha,mode=max,scope=pr-claudecode
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Test Docker containers
|
||||
run: |
|
||||
# Test main container starts correctly
|
||||
docker run --name test-webhook -d -p 3003:3002 \
|
||||
-e NODE_ENV=test \
|
||||
-e BOT_USERNAME=@TestBot \
|
||||
-e GITHUB_WEBHOOK_SECRET=test-secret \
|
||||
-e GITHUB_TOKEN=test-token \
|
||||
claude-github-webhook:pr-test
|
||||
|
||||
# Wait for container to start
|
||||
sleep 10
|
||||
|
||||
# Test health endpoint
|
||||
curl -f http://localhost:3003/health || exit 1
|
||||
|
||||
# Cleanup
|
||||
docker stop test-webhook
|
||||
docker rm test-webhook
|
||||
|
||||
- name: Docker security scan
|
||||
if: needs.changes.outputs.docker == 'true'
|
||||
run: |
|
||||
# Run Hadolint on Dockerfile
|
||||
docker run --rm -i hadolint/hadolint < Dockerfile || echo "::warning::Dockerfile linting issues found"
|
||||
|
||||
# Run Trivy scan on built image
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v $HOME/Library/Caches:/root/.cache/ \
|
||||
aquasec/trivy:latest image --exit-code 0 --severity HIGH,CRITICAL \
|
||||
claude-github-webhook:pr-test || echo "::warning::Security vulnerabilities found"
|
||||
|
||||
# Summary job that all others depend on
|
||||
pr-summary:
|
||||
name: PR Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test-unit, coverage, test-integration, security, codeql, docker-build]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Check job statuses
|
||||
run: |
|
||||
echo "## Pull Request CI Summary"
|
||||
echo "- Lint & Format: ${{ needs.lint.result }}"
|
||||
echo "- Unit Tests: ${{ needs.test-unit.result }}"
|
||||
echo "- Test Coverage: ${{ needs.coverage.result }}"
|
||||
echo "- Integration Tests: ${{ needs.test-integration.result }}"
|
||||
echo "- Security Scan: ${{ needs.security.result }}"
|
||||
echo "- CodeQL Analysis: ${{ needs.codeql.result }}"
|
||||
echo "- Docker Build: ${{ needs.docker-build.result }}"
|
||||
|
||||
# Check for any failures
|
||||
if [[ "${{ needs.lint.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.test-unit.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.coverage.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.test-integration.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.security.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.codeql.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.docker-build.result }}" == "failure" ]]; then
|
||||
echo "::error::One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All CI checks passed!"
|
||||
57
.github/workflows/pull-request.yml
vendored
Normal file
57
.github/workflows/pull-request.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run format:check
|
||||
- run: npm run lint:check
|
||||
- run: npm run typecheck
|
||||
- name: Install CLI dependencies
|
||||
working-directory: ./cli
|
||||
run: npm ci
|
||||
- name: Generate combined coverage
|
||||
run: ./scripts/combine-coverage.js
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
directory: ./coverage-combined
|
||||
fail_ci_if_error: true
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: ./scripts/security/credential-audit.sh
|
||||
- uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
extra_args: --debug --only-verified
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files, 'Dockerfile') || contains(github.event.pull_request.changed_files, 'src/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: test:latest
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -6,6 +6,7 @@ node_modules/
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.template
|
||||
!.env.quickstart
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -77,11 +78,14 @@ config
|
||||
auth.json
|
||||
service-account.json
|
||||
|
||||
# Claude authentication output
|
||||
.claude-hub/
|
||||
claude-config/
|
||||
claude-config*
|
||||
|
||||
# Docker secrets
|
||||
secrets/
|
||||
|
||||
# Benchmark results
|
||||
benchmark_results_*.json
|
||||
|
||||
# Temporary and backup files
|
||||
*.backup
|
||||
@@ -92,4 +96,4 @@ benchmark_results_*.json
|
||||
# Root level clutter prevention
|
||||
/test-*.js
|
||||
/PR_SUMMARY.md
|
||||
/*-proposal.md
|
||||
/*-proposal.md
|
||||
|
||||
25
.husky/pre-commit
Executable file
25
.husky/pre-commit
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🎨 Running Prettier check..."
|
||||
if ! npm run format:check; then
|
||||
echo "❌ Prettier formatting issues found!"
|
||||
echo "💡 Run 'npm run format' to fix formatting issues, then commit again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔍 Running ESLint check..."
|
||||
if ! npm run lint:check; then
|
||||
echo "❌ ESLint issues found!"
|
||||
echo "💡 Run 'npm run lint' to fix linting issues, then commit again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📝 Running TypeScript check..."
|
||||
if ! npm run typecheck; then
|
||||
echo "❌ TypeScript errors found!"
|
||||
echo "💡 Fix TypeScript errors, then commit again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All pre-commit checks passed!"
|
||||
@@ -1,39 +1,37 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
- id: check-json
|
||||
- id: check-merge-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: check-yaml
|
||||
- id: detect-private-key
|
||||
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
rev: v1.4.0
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args: ['--baseline', '.secrets.baseline']
|
||||
exclude: node_modules/
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.18.1
|
||||
exclude: package-lock.json
|
||||
|
||||
- repo: https://github.com/zricethezav/gitleaks
|
||||
rev: v8.21.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/thoughtworks/talisman
|
||||
rev: v1.32.0
|
||||
hooks:
|
||||
- id: talisman-commit
|
||||
entry: cmd --githook pre-commit
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: env-file-check
|
||||
name: Check for .env files
|
||||
entry: bash -c 'if find . -name ".env*" -not -path "./node_modules/*" -not -name ".env.example" | grep -q .; then echo "Found .env files that may contain secrets"; exit 1; fi'
|
||||
- id: eslint
|
||||
name: eslint
|
||||
entry: npm run lint:check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: credential-scan
|
||||
name: Scan for hardcoded credentials
|
||||
entry: bash -c 'if grep -r "sk-\|ghp_\|AKIA\|xox[boas]\|AIza[0-9A-Za-z\\-_]\{35\}" --exclude-dir=node_modules --exclude-dir=.git .; then echo "Found potential hardcoded credentials"; exit 1; fi'
|
||||
files: \.(js|ts)$
|
||||
|
||||
- id: prettier
|
||||
name: prettier
|
||||
entry: npm run format:check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: \.(js|ts|json|md)$
|
||||
20
BREAKING_CHANGES.md
Normal file
20
BREAKING_CHANGES.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Breaking Changes
|
||||
|
||||
## PR #181 - Enhanced Session Validation and API Documentation
|
||||
|
||||
### Event Pattern Change
|
||||
- **Changed**: Session handler event pattern changed from `session` to `session*`
|
||||
- **Impact**: Any integrations listening for specific session events may need to update their event filtering logic
|
||||
- **Migration**: Update event listeners to use wildcard pattern matching or specific event names (e.g., `session.create`, `session.start`)
|
||||
|
||||
### Volume Naming Pattern
|
||||
- **Changed**: Volume naming pattern in SessionManager changed to use a more consistent format
|
||||
- **Previous**: Various inconsistent naming patterns
|
||||
- **New**: Standardized naming with session ID prefixes
|
||||
- **Impact**: Existing volumes created with old naming patterns may not be recognized
|
||||
- **Migration**: Existing sessions may need to be recreated or volumes renamed to match new pattern
|
||||
|
||||
### API Validation
|
||||
- **Added**: Strict UUID validation for session dependencies
|
||||
- **Impact**: Sessions with invalid dependency IDs will now be rejected
|
||||
- **Migration**: Ensure all dependency IDs are valid UUIDs before creating sessions
|
||||
161
CLAUDE.md
161
CLAUDE.md
@@ -2,14 +2,14 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Claude GitHub Webhook
|
||||
## Claude Gitea Webhook
|
||||
|
||||
This repository contains a webhook service that integrates Claude with GitHub, allowing Claude to respond to mentions in GitHub comments and help with repository tasks. When someone mentions the configured bot username (configured via environment variables) in a GitHub issue or PR comment, the system processes the command with Claude Code and returns a helpful response.
|
||||
This repository contains a webhook service that integrates Claude with Gitea, allowing Claude to respond to mentions in Gitea comments, help with repository tasks, and automatically respond to CI failures. When someone mentions the configured bot username (configured via environment variables) in a Gitea issue or PR comment, the system processes the command with Claude Code and returns a helpful response.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
- `/docs/complete-workflow.md` - Comprehensive workflow documentation
|
||||
- `/docs/github-workflow.md` - GitHub-specific integration details
|
||||
- `/docs/gitea-workflow.md` - Gitea-specific integration details
|
||||
- `/docs/container-setup.md` - Docker container configuration
|
||||
- `/docs/container-limitations.md` - Container execution constraints
|
||||
- `/docs/aws-authentication-best-practices.md` - AWS credential management
|
||||
@@ -46,7 +46,7 @@ This repository contains a webhook service that integrates Claude with GitHub, a
|
||||
- **View logs**: `docker compose logs -f webhook`
|
||||
- **Restart**: `docker compose restart webhook`
|
||||
- Build Claude container: `./build-claude-container.sh`
|
||||
- Build Claude Code container: `./scripts/build/build-claudecode.sh`
|
||||
- Build Claude Code container: `./scripts/build/build.sh claudecode`
|
||||
- Update production image: `./update-production-image.sh`
|
||||
|
||||
### AWS Credential Management
|
||||
@@ -56,8 +56,8 @@ This repository contains a webhook service that integrates Claude with GitHub, a
|
||||
- Setup Claude authentication: `./scripts/setup/setup-claude-auth.sh`
|
||||
|
||||
### Testing Utilities
|
||||
- Test Claude API directly: `node test/test-claude-api.js owner/repo`
|
||||
- Test with container execution: `node test/test-claude-api.js owner/repo container "Your command here"`
|
||||
- Test Claude webhook API (async): `node test/test-claude-api.js owner/repo async "Your command here"`
|
||||
- Check session status: `node test/test-claude-api.js status <sessionId>`
|
||||
- Test outgoing webhook: `node test/test-outgoing-webhook.js`
|
||||
- Test pre-commit hooks: `pre-commit run --all-files`
|
||||
- Test AWS credential provider: `node test/test-aws-credential-provider.js`
|
||||
@@ -71,15 +71,22 @@ This repository contains a webhook service that integrates Claude with GitHub, a
|
||||
- Fix security vulnerabilities: `npm run security:fix`
|
||||
- All CI tests: `npm run test:ci` (includes coverage)
|
||||
|
||||
### Pre-commit Hooks
|
||||
The project uses Husky for Git pre-commit hooks to ensure code quality:
|
||||
- **ESLint**: Checks code for linting errors
|
||||
- **Prettier**: Validates code formatting
|
||||
- **TypeScript**: Runs type checking
|
||||
- **Setup**: Hooks are automatically installed via `npm run prepare`
|
||||
- **Manual run**: Execute `.husky/pre-commit` to test locally
|
||||
|
||||
### End-to-End Testing
|
||||
Use the demo repository for testing auto-tagging and webhook functionality:
|
||||
- Demo repository: `https://github.com/intelligence-assist/demo-repository`
|
||||
- Test auto-tagging: `./cli/webhook-cli.js --repo "intelligence-assist/demo-repository" --command "Auto-tag this issue" --issue 1 --url "http://localhost:8082"`
|
||||
- Test with specific issue content: Create a new issue in the demo repository to trigger auto-tagging webhook
|
||||
Use a test repository for testing auto-tagging and webhook functionality:
|
||||
- Test auto-tagging: `./cli/webhook-cli.js --repo "owner/test-repo" --command "Auto-tag this issue" --issue 1 --url "http://localhost:8082"`
|
||||
- Test with specific issue content: Create a new issue in a test repository to trigger auto-tagging webhook
|
||||
- Verify labels are applied based on issue content analysis
|
||||
|
||||
### Label Management
|
||||
- Setup repository labels: `GITHUB_TOKEN=your_token node scripts/utils/setup-repository-labels.js owner/repo`
|
||||
- Setup repository labels: `GITEA_TOKEN=your_token node scripts/utils/setup-repository-labels.js owner/repo`
|
||||
|
||||
### CLI Commands
|
||||
- Basic usage: `./cli/claude-webhook myrepo "Your command for Claude"`
|
||||
@@ -89,51 +96,87 @@ Use the demo repository for testing auto-tagging and webhook functionality:
|
||||
- Advanced usage: `node cli/webhook-cli.js --repo myrepo --command "Your command" --verbose`
|
||||
- Secure mode: `node cli/webhook-cli-secure.js` (uses AWS profile authentication)
|
||||
|
||||
### Claude Authentication Options
|
||||
|
||||
This service supports three authentication methods:
|
||||
|
||||
- **Setup Container**: Personal subscription authentication - [Setup Container Guide](./docs/setup-container-guide.md)
|
||||
- **ANTHROPIC_API_KEY**: Direct API key authentication - [Authentication Guide](./docs/claude-authentication-guide.md)
|
||||
- **AWS Bedrock**: Enterprise AWS integration - [Authentication Guide](./docs/claude-authentication-guide.md)
|
||||
|
||||
#### Quick Start: Setup Container
|
||||
For personal subscription users:
|
||||
|
||||
```bash
|
||||
# 1. Run interactive authentication setup
|
||||
./scripts/setup/setup-claude-interactive.sh
|
||||
|
||||
# 2. In container: authenticate with your subscription
|
||||
claude --dangerously-skip-permissions # Follow authentication flow
|
||||
exit # Save authentication
|
||||
|
||||
# 3. Test captured authentication
|
||||
./scripts/setup/test-claude-auth.sh
|
||||
|
||||
# 4. Use in production
|
||||
cp -r ${CLAUDE_HUB_DIR:-~/.claude-hub}/* ~/.claude/
|
||||
```
|
||||
|
||||
📖 **See [Complete Authentication Guide](./docs/claude-authentication-guide.md) for all methods**
|
||||
|
||||
## Features
|
||||
|
||||
### Auto-Tagging
|
||||
The system automatically analyzes new issues and applies appropriate labels using a secure, minimal-permission approach:
|
||||
|
||||
**Security Features:**
|
||||
- **Minimal Tool Access**: Uses only `Read` and `GitHub` tools (no file editing or bash execution)
|
||||
- **Minimal Tool Access**: Uses only `Read` and `Gitea` API tools (no file editing or bash execution)
|
||||
- **Dedicated Container**: Runs in specialized container with restricted entrypoint script
|
||||
- **CLI-Based**: Uses `gh` CLI commands directly instead of JSON parsing for better reliability
|
||||
- **API-Based**: Uses Gitea REST API directly for reliable label management
|
||||
|
||||
**Label Categories:**
|
||||
- **Priority**: critical, high, medium, low
|
||||
- **Type**: bug, feature, enhancement, documentation, question, security
|
||||
- **Type**: bug, feature, enhancement, documentation, question, security
|
||||
- **Complexity**: trivial, simple, moderate, complex
|
||||
- **Component**: api, frontend, backend, database, auth, webhook, docker
|
||||
|
||||
**Process Flow:**
|
||||
1. New issue triggers `issues.opened` webhook
|
||||
1. New issue triggers `issues.opened` webhook from Gitea
|
||||
2. Dedicated Claude container starts with `claudecode-tagging-entrypoint.sh`
|
||||
3. Claude analyzes issue content using minimal tools
|
||||
4. Labels applied directly via `gh issue edit --add-label` commands
|
||||
4. Labels applied via Gitea REST API
|
||||
5. No comments posted (silent operation)
|
||||
6. Fallback to keyword-based labeling if CLI approach fails
|
||||
6. Fallback to keyword-based labeling if API approach fails
|
||||
|
||||
### Automated PR Review
|
||||
The system automatically triggers comprehensive PR reviews when all checks pass:
|
||||
- **Trigger**: `check_suite` webhook event with `conclusion: 'success'`
|
||||
- **Scope**: Reviews all PRs associated with the successful check suite
|
||||
- **Trigger**: `pull_request` webhook events
|
||||
- **Scope**: Reviews all PRs as requested
|
||||
- **Process**: Claude performs security, logic, performance, and code quality analysis
|
||||
- **Output**: Detailed review comments, line-specific feedback, and approval/change requests
|
||||
- **Integration**: Uses GitHub CLI (`gh`) commands for seamless review workflow
|
||||
- **Integration**: Uses Gitea REST API for seamless review workflow
|
||||
|
||||
### CI Failure Response
|
||||
The system can automatically respond to CI/CD workflow failures:
|
||||
- **Trigger**: `workflow_run` or `workflow_job` webhook events with `conclusion: 'failure'`
|
||||
- **Process**: Claude analyzes the failure logs and attempts to diagnose the issue
|
||||
- **Output**: Creates a fix PR with proposed changes or comments with analysis
|
||||
- **Integration**: Uses Gitea Actions API to fetch workflow logs
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Components
|
||||
1. **Express Server** (`src/index.ts`): Main application entry point that sets up middleware, routes, and error handling
|
||||
2. **Routes**:
|
||||
- GitHub Webhook: `/api/webhooks/github` - Processes GitHub webhook events
|
||||
- Webhook Router: `/api/webhooks/:provider` - Processes webhook events from configured providers
|
||||
- Claude API: `/api/claude` - Direct API access to Claude
|
||||
- Health Check: `/health` - Service status monitoring
|
||||
3. **Controllers**:
|
||||
- `githubController.ts` - Handles webhook verification and processing
|
||||
3. **Providers**:
|
||||
- `providers/gitea/` - Gitea webhook handling and API client
|
||||
- `providers/claude/` - Claude orchestration and session management
|
||||
4. **Services**:
|
||||
- `claudeService.ts` - Interfaces with Claude Code CLI
|
||||
- `githubService.ts` - Handles GitHub API interactions
|
||||
- `providers/gitea/GiteaApiClient.ts` - Handles Gitea REST API interactions
|
||||
5. **Utilities**:
|
||||
- `logger.ts` - Logging functionality with redaction capability
|
||||
- `awsCredentialProvider.ts` - Secure AWS credential management
|
||||
@@ -143,8 +186,9 @@ The system automatically triggers comprehensive PR reviews when all checks pass:
|
||||
The system uses different execution modes based on operation type:
|
||||
|
||||
**Operation Types:**
|
||||
- **Auto-tagging**: Minimal permissions (`Read`, `GitHub` tools only)
|
||||
- **Auto-tagging**: Minimal permissions (`Read` tool only, uses Gitea API)
|
||||
- **PR Review**: Standard permissions (full tool set)
|
||||
- **CI Failure Fix**: Standard permissions (full tool set for code fixes)
|
||||
- **Default**: Standard permissions (full tool set)
|
||||
|
||||
**Security Features:**
|
||||
@@ -154,8 +198,8 @@ The system uses different execution modes based on operation type:
|
||||
- **Container Isolation**: Docker containers with minimal required capabilities
|
||||
|
||||
**Container Entrypoints:**
|
||||
- `claudecode-tagging-entrypoint.sh`: Minimal tools for auto-tagging (`--allowedTools Read,GitHub`)
|
||||
- `claudecode-entrypoint.sh`: Full tools for general operations (`--allowedTools Bash,Create,Edit,Read,Write,GitHub`)
|
||||
- `claudecode-tagging-entrypoint.sh`: Minimal tools for auto-tagging (`--allowedTools Read`)
|
||||
- `claudecode-entrypoint.sh`: Full tools for general operations (`--allowedTools Bash,Create,Edit,Read,Write`)
|
||||
|
||||
**DevContainer Configuration:**
|
||||
The repository includes a `.devcontainer` configuration for development:
|
||||
@@ -166,11 +210,13 @@ The repository includes a `.devcontainer` configuration for development:
|
||||
- Automatic firewall initialization via post-create command
|
||||
|
||||
### Workflow
|
||||
1. GitHub comment with bot mention (configured via BOT_USERNAME) triggers a webhook event
|
||||
2. Express server receives the webhook at `/api/webhooks/github`
|
||||
3. Service extracts the command and processes it with Claude in a Docker container
|
||||
4. Claude analyzes the repository and responds to the command
|
||||
5. Response is returned via the webhook HTTP response
|
||||
1. Gitea comment with bot mention (configured via BOT_USERNAME) triggers a webhook event
|
||||
2. Express server receives the webhook at `/api/webhooks/gitea`
|
||||
3. GiteaWebhookProvider verifies the signature and parses the event
|
||||
4. Appropriate handler processes the event (issue, PR, workflow failure, etc.)
|
||||
5. Service extracts the command and processes it with Claude in a Docker container
|
||||
6. Claude analyzes the repository and responds to the command
|
||||
7. Response is posted back to Gitea via the REST API
|
||||
|
||||
## AWS Authentication
|
||||
The service supports multiple AWS authentication methods, with a focus on security:
|
||||
@@ -182,7 +228,7 @@ The service supports multiple AWS authentication methods, with a focus on securi
|
||||
The `awsCredentialProvider.ts` utility handles credential retrieval and rotation.
|
||||
|
||||
## Security Features
|
||||
- Webhook signature verification using HMAC
|
||||
- Webhook signature verification using HMAC-SHA256 (`x-gitea-signature` header)
|
||||
- Credential scanning in pre-commit hooks
|
||||
- Container isolation for Claude execution
|
||||
- AWS profile-based authentication
|
||||
@@ -193,26 +239,55 @@ The `awsCredentialProvider.ts` utility handles credential retrieval and rotation
|
||||
## Configuration
|
||||
- Environment variables are loaded from `.env` file
|
||||
- AWS Bedrock credentials for Claude access
|
||||
- GitHub tokens and webhook secrets
|
||||
- Gitea tokens and webhook secrets
|
||||
- Container execution settings
|
||||
- Webhook URL and port configuration
|
||||
|
||||
### Required Environment Variables
|
||||
- `BOT_USERNAME`: GitHub username that the bot responds to (e.g., `@ClaudeBot`)
|
||||
- `DEFAULT_AUTHORIZED_USER`: Default GitHub username authorized to use the bot (if AUTHORIZED_USERS is not set)
|
||||
- `AUTHORIZED_USERS`: Comma-separated list of GitHub usernames authorized to use the bot
|
||||
- `BOT_USERNAME`: Username that the bot responds to (e.g., `@ClaudeBot`)
|
||||
- `DEFAULT_AUTHORIZED_USER`: Default username authorized to use the bot (if AUTHORIZED_USERS is not set)
|
||||
- `AUTHORIZED_USERS`: Comma-separated list of usernames authorized to use the bot
|
||||
- `BOT_EMAIL`: Email address used for git commits made by the bot
|
||||
- `GITHUB_WEBHOOK_SECRET`: Secret for validating GitHub webhook payloads
|
||||
- `GITHUB_TOKEN`: GitHub token for API access
|
||||
- `GITEA_API_URL`: Gitea API base URL (e.g., `https://git.example.com/api/v1`)
|
||||
- `GITEA_WEBHOOK_SECRET`: Secret for validating Gitea webhook payloads
|
||||
- `GITEA_TOKEN`: Gitea personal access token for API access
|
||||
- `ANTHROPIC_API_KEY`: Anthropic API key for Claude access
|
||||
|
||||
### Optional Environment Variables
|
||||
- `PR_REVIEW_WAIT_FOR_ALL_CHECKS`: Set to `"true"` to wait for all meaningful check suites to complete successfully before triggering PR review (default: `"true"`). Uses smart logic to handle conditional jobs and skipped checks, preventing duplicate reviews from different check suites.
|
||||
- `PR_REVIEW_TRIGGER_WORKFLOW`: Name of a specific GitHub Actions workflow that should trigger PR reviews (e.g., `"Pull Request CI"`). Only used if `PR_REVIEW_WAIT_FOR_ALL_CHECKS` is `"false"`.
|
||||
- `PR_REVIEW_DEBOUNCE_MS`: Delay in milliseconds before checking all check suites status (default: `"5000"`). This accounts for GitHub's eventual consistency.
|
||||
- `PR_REVIEW_MAX_WAIT_MS`: Maximum time to wait for stale in-progress check suites before considering them failed (default: `"1800000"` = 30 minutes).
|
||||
- `PR_REVIEW_WAIT_FOR_ALL_CHECKS`: Set to `"true"` to wait for all workflow runs to complete successfully before triggering PR review (default: `"true"`).
|
||||
- `PR_REVIEW_TRIGGER_WORKFLOW`: Name of a specific Gitea Actions workflow that should trigger PR reviews (e.g., `"Pull Request CI"`). Only used if `PR_REVIEW_WAIT_FOR_ALL_CHECKS` is `"false"`.
|
||||
- `PR_REVIEW_DEBOUNCE_MS`: Delay in milliseconds before checking workflow status (default: `"5000"`).
|
||||
- `PR_REVIEW_MAX_WAIT_MS`: Maximum time to wait for in-progress workflows before considering them failed (default: `"1800000"` = 30 minutes).
|
||||
- `PR_REVIEW_CONDITIONAL_TIMEOUT_MS`: Time to wait for conditional jobs that never start before skipping them (default: `"300000"` = 5 minutes).
|
||||
|
||||
### Gitea Webhook Setup
|
||||
|
||||
To configure Gitea to send webhooks to this service:
|
||||
|
||||
1. Go to your repository's **Settings** → **Webhooks** → **Add Webhook** → **Gitea**
|
||||
2. Configure the webhook:
|
||||
- **Target URL**: `https://your-claude-hub-domain/api/webhooks/gitea`
|
||||
- **HTTP Method**: POST
|
||||
- **Content Type**: application/json
|
||||
- **Secret**: Match the `GITEA_WEBHOOK_SECRET` environment variable
|
||||
3. Select events to trigger the webhook:
|
||||
- **Issues**: For issue auto-tagging
|
||||
- **Issue Comment**: For bot mentions in issue comments
|
||||
- **Pull Request**: For PR events
|
||||
- **Pull Request Comment**: For bot mentions in PR comments
|
||||
- **Workflow Run**: For CI failure detection (Gitea Actions)
|
||||
- **Workflow Job**: For job-level CI failure detection
|
||||
4. Save the webhook and test with the "Test Delivery" button
|
||||
|
||||
### Gitea Personal Access Token
|
||||
|
||||
Create a personal access token with these scopes:
|
||||
- `read:repository` - Read repository content
|
||||
- `write:issue` - Create/edit issues and comments
|
||||
- `write:repository` - Push commits, create branches/PRs
|
||||
|
||||
Generate at: `https://your-gitea-instance/user/settings/applications`
|
||||
|
||||
## TypeScript Infrastructure
|
||||
The project is configured with TypeScript for enhanced type safety and developer experience:
|
||||
|
||||
|
||||
166
Dockerfile
166
Dockerfile
@@ -1,49 +1,14 @@
|
||||
FROM node:24-slim
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Set shell with pipefail option for better error handling
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Install git, Claude Code, Docker, and required dependencies with pinned versions and --no-install-recommends
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git=1:2.39.5-0+deb12u2 \
|
||||
curl=7.88.1-10+deb12u12 \
|
||||
python3=3.11.2-1+b1 \
|
||||
python3-pip=23.0.1+dfsg-1 \
|
||||
python3-venv=3.11.2-1+b1 \
|
||||
expect=5.45.4-2+b1 \
|
||||
ca-certificates=20230311 \
|
||||
gnupg=2.2.40-1.1 \
|
||||
lsb-release=12.0-1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Docker CLI (not the daemon, just the client) with consolidated RUN and pinned versions
|
||||
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli=5:27.* \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude Code (latest version)
|
||||
# hadolint ignore=DL3016
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create docker group first, then create a non-root user for running the application
|
||||
RUN groupadd -g 999 docker 2>/dev/null || true \
|
||||
&& useradd -m -u 1001 -s /bin/bash claudeuser \
|
||||
&& usermod -aG docker claudeuser 2>/dev/null || true
|
||||
|
||||
# Create claude config directory and copy config
|
||||
RUN mkdir -p /home/claudeuser/.config/claude
|
||||
COPY claude-config.json /home/claudeuser/.config/claude/config.json
|
||||
# Build stage - compile TypeScript and prepare production files
|
||||
FROM node:24-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY babel.config.js ./
|
||||
# Copy package files first for better caching
|
||||
COPY package*.json tsconfig.json babel.config.js ./
|
||||
|
||||
# Install all dependencies (including dev for build)
|
||||
# Install all dependencies (including dev)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
@@ -52,27 +17,128 @@ COPY src/ ./src/
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Remove dev dependencies to reduce image size
|
||||
RUN npm prune --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy remaining application files
|
||||
COPY . .
|
||||
|
||||
# Consolidate permission changes into a single RUN instruction
|
||||
# Production dependency stage - smaller layer for dependencies
|
||||
FROM node:24-slim AS prod-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Test stage - includes dev dependencies and test files
|
||||
FROM node:24-slim AS test
|
||||
|
||||
# Set shell with pipefail option
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install all dependencies
|
||||
COPY package*.json tsconfig*.json babel.config.js jest.config.js ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and test files
|
||||
COPY src/ ./src/
|
||||
COPY test/ ./test/
|
||||
COPY scripts/ ./scripts/
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Set test environment
|
||||
ENV NODE_ENV=test
|
||||
|
||||
# Run only unit tests in Docker builds (skip integration tests that require Docker)
|
||||
CMD ["npm", "run", "test:unit"]
|
||||
|
||||
# Production stage - minimal runtime image
|
||||
FROM node:24-slim AS production
|
||||
|
||||
# Set shell with pipefail option for better error handling
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
expect \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
lsb-release \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Docker CLI (not the daemon, just the client)
|
||||
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create docker group first, then create a non-root user for running the application
|
||||
# Note: GID 281 matches Unraid's docker group for socket access
|
||||
RUN groupadd -g 281 docker 2>/dev/null || true \
|
||||
&& useradd -m -u 1001 -s /bin/bash claudeuser \
|
||||
&& usermod -aG docker claudeuser 2>/dev/null || true
|
||||
|
||||
# Create necessary directories and set permissions while still root
|
||||
RUN mkdir -p /home/claudeuser/.npm-global \
|
||||
&& mkdir -p /home/claudeuser/.config/claude \
|
||||
&& chown -R claudeuser:claudeuser /home/claudeuser/.npm-global /home/claudeuser/.config
|
||||
|
||||
# Configure npm to use the user directory for global packages
|
||||
ENV NPM_CONFIG_PREFIX=/home/claudeuser/.npm-global
|
||||
ENV PATH=/home/claudeuser/.npm-global/bin:$PATH
|
||||
|
||||
# Switch to non-root user and install Claude Code
|
||||
USER claudeuser
|
||||
|
||||
# Install Claude Code (latest version) as non-root user
|
||||
# hadolint ignore=DL3016
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Switch back to root for remaining setup
|
||||
USER root
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy production dependencies from prod-deps stage
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copy configuration and runtime files
|
||||
COPY package*.json tsconfig.json babel.config.js ./
|
||||
COPY claude-config.json /home/claudeuser/.config/claude/config.json
|
||||
COPY scripts/ ./scripts/
|
||||
COPY docs/ ./docs/
|
||||
COPY cli/ ./cli/
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R claudeuser:claudeuser /home/claudeuser/.config /app \
|
||||
&& chmod +x /app/scripts/runtime/startup.sh
|
||||
|
||||
# Note: Docker socket will be mounted at runtime, no need to create it here
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 3002
|
||||
|
||||
# Set default environment variables
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3002
|
||||
PORT=3002 \
|
||||
NPM_CONFIG_PREFIX=/home/claudeuser/.npm-global \
|
||||
PATH=/home/claudeuser/.npm-global/bin:$PATH
|
||||
|
||||
# Stay as root user to run Docker commands
|
||||
# (The container will need to run with Docker socket mounted)
|
||||
# Switch to non-root user for running the application
|
||||
# Docker commands will work via docker group membership when socket is mounted
|
||||
USER claudeuser
|
||||
|
||||
# Run the startup script
|
||||
CMD ["bash", "/app/scripts/runtime/startup.sh"]
|
||||
108
Dockerfile.claude-setup
Normal file
108
Dockerfile.claude-setup
Normal file
@@ -0,0 +1,108 @@
|
||||
FROM node:24
|
||||
|
||||
# Install dependencies for interactive session
|
||||
RUN apt update && apt install -y \
|
||||
git \
|
||||
sudo \
|
||||
zsh \
|
||||
curl \
|
||||
vim \
|
||||
nano \
|
||||
gh \
|
||||
rsync
|
||||
|
||||
# Set up npm global directory
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R node:node /usr/local/share
|
||||
|
||||
# Switch to node user for npm install
|
||||
USER node
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Switch back to root for setup
|
||||
USER root
|
||||
|
||||
# Create authentication workspace
|
||||
RUN mkdir -p /auth-setup && chown -R node:node /auth-setup
|
||||
|
||||
# Set up interactive shell environment
|
||||
ENV SHELL /bin/zsh
|
||||
WORKDIR /auth-setup
|
||||
|
||||
# Create setup script
|
||||
COPY <<'EOF' /setup-claude-auth.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔧 Claude Authentication Setup"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
echo "This will help you connect Claude to your account."
|
||||
echo ""
|
||||
echo "Quick setup - just run this command:"
|
||||
echo ""
|
||||
echo " claude --dangerously-skip-permissions && exit"
|
||||
echo ""
|
||||
echo "This will authenticate Claude and save your setup automatically."
|
||||
echo ""
|
||||
|
||||
# Function to copy authentication state
|
||||
copy_auth_state() {
|
||||
if [ -d "/home/node/.claude" ] && [ -d "/auth-output" ]; then
|
||||
echo "💾 Saving your authentication..."
|
||||
# Copy authentication files, excluding todos
|
||||
rsync -a --exclude='todos/' /home/node/.claude/ /auth-output/ 2>/dev/null || \
|
||||
cp -r /home/node/.claude/. /auth-output/ 2>/dev/null || true
|
||||
echo "✅ Authentication saved successfully!"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up signal handling to capture state on exit
|
||||
trap copy_auth_state EXIT
|
||||
|
||||
# Create .claude directory for node user
|
||||
sudo -u node mkdir -p /home/node/.claude
|
||||
|
||||
echo "🔐 Starting interactive shell as 'node' user..."
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Check if we should run automatically
|
||||
if [ "$1" = "--auto" ]; then
|
||||
echo "Running authentication automatically..."
|
||||
echo ""
|
||||
sudo -u node bash -c '
|
||||
export HOME=/home/node
|
||||
export PATH=/usr/local/share/npm-global/bin:$PATH
|
||||
cd /home/node
|
||||
claude --dangerously-skip-permissions
|
||||
exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Authentication command failed with exit code $exit_code"
|
||||
exit $exit_code
|
||||
fi
|
||||
'
|
||||
else
|
||||
# Switch to node user and start interactive shell
|
||||
sudo -u node bash -c '
|
||||
export HOME=/home/node
|
||||
export PATH=/usr/local/share/npm-global/bin:$PATH
|
||||
cd /home/node
|
||||
echo "Ready! Run this command to authenticate and exit:"
|
||||
echo ""
|
||||
echo " claude --dangerously-skip-permissions && exit"
|
||||
echo ""
|
||||
exec bash -i
|
||||
'
|
||||
fi
|
||||
EOF
|
||||
|
||||
RUN chmod +x /setup-claude-auth.sh
|
||||
|
||||
# Set entrypoint to setup script
|
||||
ENTRYPOINT ["/bin/bash", "/setup-claude-auth.sh"]
|
||||
@@ -44,10 +44,11 @@ RUN npm install -g @anthropic-ai/claude-code
|
||||
# Switch back to root
|
||||
USER root
|
||||
|
||||
# Copy the pre-authenticated Claude config to BOTH root and node user
|
||||
COPY claude-config /root/.claude
|
||||
COPY claude-config /home/node/.claude
|
||||
RUN chown -R node:node /home/node/.claude
|
||||
# Copy the pre-authenticated Claude config to BOTH root and node user (only for production builds)
|
||||
# For regular builds, this will be empty directories that Claude can authenticate into
|
||||
# COPY claude-config /root/.claude
|
||||
# COPY claude-config /home/node/.claude
|
||||
# RUN chown -R node:node /home/node/.claude
|
||||
|
||||
# Copy the rest of the setup
|
||||
WORKDIR /workspace
|
||||
@@ -72,12 +73,12 @@ RUN chmod +x /usr/local/bin/init-firewall.sh && \
|
||||
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
|
||||
chmod 0440 /etc/sudoers.d/node-firewall
|
||||
|
||||
# Create scripts directory and copy entrypoint scripts
|
||||
# Create scripts directory and copy unified entrypoint script
|
||||
RUN mkdir -p /scripts/runtime
|
||||
COPY scripts/runtime/claudecode-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY scripts/runtime/claudecode-tagging-entrypoint.sh /scripts/runtime/claudecode-tagging-entrypoint.sh
|
||||
COPY scripts/runtime/claudecode-entrypoint.sh /scripts/runtime/claudecode-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh && \
|
||||
chmod +x /scripts/runtime/claudecode-tagging-entrypoint.sh
|
||||
chmod +x /scripts/runtime/claudecode-entrypoint.sh
|
||||
|
||||
# Set the default shell to bash
|
||||
ENV SHELL /bin/zsh
|
||||
|
||||
141
QUICKSTART.md
Normal file
141
QUICKSTART.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 🚀 Quick Start Guide
|
||||
|
||||
Get Claude responding to your GitHub issues in minutes using Cloudflare Tunnel.
|
||||
|
||||
## Prerequisites
|
||||
- GitHub account
|
||||
- Docker installed
|
||||
- Claude.ai account with Max plan (5x or 20x)
|
||||
- Cloudflare account (free tier works)
|
||||
|
||||
## Step 1: Create a GitHub Bot Account
|
||||
|
||||
1. Sign out of GitHub and create a new account for your bot (e.g., `YourProjectBot`)
|
||||
2. Sign in to your bot account and create a [Personal Access Token](https://github.com/settings/tokens) with `repo` and `write` permissions
|
||||
3. Add the bot account as a collaborator to your repositories from your main account
|
||||
|
||||
## Step 2: Clone and Configure
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/claude-did-this/claude-hub.git
|
||||
cd claude-hub
|
||||
|
||||
# Copy the quickstart environment file
|
||||
cp .env.quickstart .env
|
||||
|
||||
# Edit .env with your values
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required values:
|
||||
- `GITHUB_TOKEN`: Your bot account's GitHub Personal Access Token
|
||||
- `GITHUB_WEBHOOK_SECRET`: Generate with `openssl rand -hex 32`
|
||||
- `BOT_USERNAME`: Your bot's GitHub username (e.g., `@YourProjectBot`)
|
||||
- `BOT_EMAIL`: Your bot's email
|
||||
- `AUTHORIZED_USERS`: Comma-separated GitHub usernames who can use the bot
|
||||
|
||||
## Step 3: Authenticate Claude
|
||||
|
||||
```bash
|
||||
# Run the interactive setup
|
||||
./scripts/setup/setup-claude-interactive.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Open your browser for Claude.ai authentication
|
||||
2. Save your credentials securely
|
||||
3. Confirm everything is working
|
||||
|
||||
## Step 4: Start the Service
|
||||
|
||||
```bash
|
||||
# Start the webhook service
|
||||
docker compose up -d
|
||||
|
||||
# Check it's running
|
||||
docker compose logs -f webhook
|
||||
```
|
||||
|
||||
## Step 5: Install Cloudflare Tunnel
|
||||
|
||||
### Option A: Ubuntu/Debian
|
||||
```bash
|
||||
# Add cloudflare gpg key
|
||||
sudo mkdir -p --mode=0755 /usr/share/keyrings
|
||||
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
|
||||
|
||||
# Add this repo to your apt repositories
|
||||
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared focal main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||
|
||||
# Install cloudflared
|
||||
sudo apt-get update && sudo apt-get install cloudflared
|
||||
```
|
||||
|
||||
### Option B: Direct Download
|
||||
```bash
|
||||
# Download the latest cloudflared binary
|
||||
wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
|
||||
sudo dpkg -i cloudflared-linux-amd64.deb
|
||||
```
|
||||
|
||||
### Option C: Using snap
|
||||
```bash
|
||||
sudo snap install cloudflared
|
||||
```
|
||||
|
||||
## Step 6: Create Tunnel
|
||||
|
||||
```bash
|
||||
# Create a tunnel to your local service
|
||||
cloudflared tunnel --url http://localhost:3002
|
||||
```
|
||||
|
||||
Copy the generated URL (like `https://abc123.trycloudflare.com`)
|
||||
|
||||
## Step 7: Configure GitHub Webhook
|
||||
|
||||
1. Go to your repository → Settings → Webhooks
|
||||
2. Click "Add webhook"
|
||||
3. **Payload URL**: Your Cloudflare URL + `/api/webhooks/github`
|
||||
- Example: `https://abc123.trycloudflare.com/api/webhooks/github`
|
||||
4. **Content type**: `application/json`
|
||||
5. **Secret**: Same value as `GITHUB_WEBHOOK_SECRET` in your .env
|
||||
6. **Events**: Select "Let me select individual events"
|
||||
- Check: Issues, Issue comments, Pull requests, Pull request reviews
|
||||
|
||||
## 🎉 You're Done!
|
||||
|
||||
Test it in your own repository by creating an issue and mentioning your bot:
|
||||
|
||||
```
|
||||
@YourProjectBot Can you help me understand this codebase?
|
||||
```
|
||||
|
||||
**Note:** Your bot will only respond in repositories where you've configured the webhook and to users listed in `AUTHORIZED_USERS`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Production Deployment**: Set up a permanent Cloudflare Tunnel with `cloudflared service install`
|
||||
- **Advanced Features**: Check `.env.example` for PR auto-review, auto-tagging, and more
|
||||
- **Multiple Repos**: Add the same webhook to any repo where you want bot assistance
|
||||
|
||||
## Community & Support
|
||||
|
||||
[](https://discord.gg/yb7hwQjTFg)
|
||||
[](https://claude-did-this.com/claude-hub/overview)
|
||||
|
||||
Join our Discord server for help, updates, and to share your experience!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Bot not responding?**
|
||||
- Check logs: `docker compose logs webhook`
|
||||
- Verify webhook delivery in GitHub → Settings → Webhooks → Recent Deliveries
|
||||
- Ensure the commenting user is in `AUTHORIZED_USERS`
|
||||
|
||||
**Authentication issues?**
|
||||
- Re-run: `./scripts/setup/setup-claude-interactive.sh`
|
||||
- Ensure you have an active Claude.ai Max plan (5x or 20x)
|
||||
|
||||
**Need help?** Ask in our [Discord server](https://discord.gg/yb7hwQjTFg) or check the [full documentation](https://claude-did-this.com/claude-hub/overview)!
|
||||
174
README.md
174
README.md
@@ -1,14 +1,17 @@
|
||||
# Claude GitHub Webhook
|
||||
|
||||
[](https://github.com/intelligence-assist/claude-hub/actions/workflows/ci.yml)
|
||||
[](https://github.com/intelligence-assist/claude-hub/actions/workflows/security.yml)
|
||||
[](https://discord.com/widget?id=1377708770209304676&theme=dark)
|
||||
[](https://github.com/claude-did-this/claude-hub/actions/workflows/main.yml)
|
||||
[](https://github.com/claude-did-this/claude-hub/actions/workflows/security.yml)
|
||||
[](test/README.md)
|
||||
[](https://codecov.io/gh/intelligence-assist/claude-hub)
|
||||
[](https://github.com/intelligence-assist/claude-hub/releases)
|
||||
[](https://codecov.io/gh/claude-did-this/claude-hub)
|
||||
[](https://github.com/claude-did-this/claude-hub/releases)
|
||||
[](https://hub.docker.com/r/intelligenceassist/claude-hub)
|
||||
[](package.json)
|
||||
[](LICENSE)
|
||||
|
||||
🚀 **[Quick Start Guide](./QUICKSTART.md)** | 💬 **[Discord](https://discord.com/widget?id=1377708770209304676&theme=dark)** | 📚 **[Documentation](https://claude-did-this.com/claude-hub/overview)** | 📖 **[Complete Setup](./docs/complete-workflow.md)** | 🔐 **[Authentication](./docs/claude-authentication-guide.md)**
|
||||
|
||||

|
||||
|
||||
Deploy Claude Code as a fully autonomous GitHub bot. Create your own bot account, mention it in any issue or PR, and watch AI-powered development happen end-to-end. Claude can implement complete features, review code, merge PRs, wait for CI builds, and run for hours autonomously until tasks are completed. Production-ready microservice with container isolation, automated workflows, and intelligent project management.
|
||||
@@ -25,6 +28,29 @@ Deploy Claude Code as a fully autonomous GitHub bot. Create your own bot account
|
||||
|
||||
Claude autonomously handles complete development workflows. It analyzes your entire repository, implements features from scratch, conducts thorough code reviews, manages pull requests, monitors CI/CD pipelines, and responds to automated feedback - all without human intervention. No context switching. No manual oversight required. Just seamless autonomous development where you work.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
**Follow our [10-minute Quick Start Guide](./QUICKSTART.md)** to get Claude responding to your GitHub issues using Cloudflare Tunnel - no domain or complex setup required!
|
||||
|
||||
```bash
|
||||
# 1. Clone and configure
|
||||
git clone https://github.com/claude-did-this/claude-hub.git
|
||||
cd claude-hub
|
||||
cp .env.quickstart .env
|
||||
nano .env # Add your GitHub token and bot details
|
||||
|
||||
# 2. Authenticate Claude (uses your Claude.ai Max subscription)
|
||||
./scripts/setup/setup-claude-interactive.sh
|
||||
|
||||
# 3. Start the service
|
||||
docker compose up -d
|
||||
|
||||
# 4. Create a tunnel (see quickstart guide for details)
|
||||
cloudflared tunnel --url http://localhost:3002
|
||||
```
|
||||
|
||||
That's it! Your bot is ready to use. See the **[complete quickstart guide](./QUICKSTART.md)** for detailed instructions and webhook setup.
|
||||
|
||||
## Autonomous Workflow Capabilities
|
||||
|
||||
### End-to-End Development 🚀
|
||||
@@ -64,51 +90,13 @@ Claude autonomously handles complete development workflows. It analyzes your ent
|
||||
- Container isolation with minimal permissions
|
||||
- Fine-grained GitHub token scoping
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker Image (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull intelligenceassist/claude-hub:latest
|
||||
|
||||
# Run with environment variables
|
||||
docker run -d \
|
||||
--name claude-webhook \
|
||||
-p 8082:3002 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e GITHUB_TOKEN=your_github_token \
|
||||
-e GITHUB_WEBHOOK_SECRET=your_webhook_secret \
|
||||
-e ANTHROPIC_API_KEY=your_anthropic_key \
|
||||
-e BOT_USERNAME=@YourBotName \
|
||||
-e AUTHORIZED_USERS=user1,user2 \
|
||||
intelligenceassist/claude-hub:latest
|
||||
|
||||
# Or use Docker Compose
|
||||
wget https://raw.githubusercontent.com/intelligence-assist/claude-hub/main/docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 2: From Source
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/intelligence-assist/claude-hub.git
|
||||
cd claude-hub
|
||||
./scripts/setup/setup-secure-credentials.sh
|
||||
|
||||
# Launch with Docker Compose
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Service runs on `http://localhost:8082` by default.
|
||||
|
||||
## Bot Account Setup
|
||||
|
||||
**Current Setup**: You need to create your own GitHub bot account:
|
||||
|
||||
1. **Create a dedicated GitHub account** for your bot (e.g., `MyProjectBot`)
|
||||
2. **Generate a Personal Access Token** with repository permissions
|
||||
2. **Generate a Personal Access Token** from the bot account with repository permissions
|
||||
3. **Configure the bot username** in your environment variables
|
||||
4. **Add the bot account** as a collaborator to your repositories
|
||||
|
||||
@@ -122,9 +110,18 @@ Service runs on `http://localhost:8082` by default.
|
||||
# Core settings
|
||||
BOT_USERNAME=YourBotName # GitHub bot account username (create your own bot account)
|
||||
GITHUB_WEBHOOK_SECRET=<generated> # Webhook validation
|
||||
GITHUB_TOKEN=<fine-grained-pat> # Repository access (from your bot account)
|
||||
GITHUB_TOKEN=<fine-grained-pat> # Repository access (PAT from your bot account)
|
||||
|
||||
# AWS Bedrock (recommended)
|
||||
# Claude Authentication - Choose ONE method:
|
||||
|
||||
# Option 1: Setup Container (Personal/Development)
|
||||
# Use existing Claude Max subscription (5x or 20x plans)
|
||||
# See docs/setup-container-guide.md for setup
|
||||
|
||||
# Option 2: Direct API Key (Production/Team)
|
||||
ANTHROPIC_API_KEY=sk-ant-your-api-key
|
||||
|
||||
# Option 3: AWS Bedrock (Enterprise)
|
||||
AWS_REGION=us-east-1
|
||||
ANTHROPIC_MODEL=anthropic.claude-3-sonnet-20240229-v1:0
|
||||
CLAUDE_CODE_USE_BEDROCK=1
|
||||
@@ -134,6 +131,44 @@ AUTHORIZED_USERS=user1,user2,user3 # Allowed GitHub usernames
|
||||
CLAUDE_API_AUTH_REQUIRED=1 # Enable API authentication
|
||||
```
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Setup Container (Personal/Development)
|
||||
Use your existing Claude Max subscription for automation instead of pay-per-use API fees:
|
||||
|
||||
```bash
|
||||
# 1. Run interactive authentication setup
|
||||
./scripts/setup/setup-claude-interactive.sh
|
||||
|
||||
# 2. In container: authenticate with your subscription
|
||||
claude --dangerously-skip-permissions # Follow authentication flow
|
||||
exit # Save authentication
|
||||
|
||||
# 3. Use captured authentication
|
||||
cp -r ${CLAUDE_HUB_DIR:-~/.claude-hub}/* ~/.claude/
|
||||
```
|
||||
|
||||
**Prerequisites**: Claude Max subscription (5x or 20x plans). Claude Pro does not include Claude Code access.
|
||||
**Details**: [Setup Container Guide](./docs/setup-container-guide.md)
|
||||
|
||||
### Direct API Key (Production/Team)
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-your-api-key-here
|
||||
```
|
||||
|
||||
**Best for**: Production environments, team usage, guaranteed stability.
|
||||
**Details**: [Authentication Guide](./docs/claude-authentication-guide.md)
|
||||
|
||||
### AWS Bedrock (Enterprise)
|
||||
```bash
|
||||
AWS_REGION=us-east-1
|
||||
ANTHROPIC_MODEL=anthropic.claude-3-sonnet-20240229-v1:0
|
||||
CLAUDE_CODE_USE_BEDROCK=1
|
||||
```
|
||||
|
||||
**Best for**: Enterprise deployments, AWS integration, compliance requirements.
|
||||
**Details**: [Authentication Guide](./docs/claude-authentication-guide.md)
|
||||
|
||||
### 2. GitHub Webhook Setup
|
||||
|
||||
1. Navigate to Repository → Settings → Webhooks
|
||||
@@ -164,16 +199,31 @@ AWS_SECRET_ACCESS_KEY=xxx
|
||||
|
||||
### Direct API Access
|
||||
|
||||
Integrate Claude without GitHub webhooks:
|
||||
Create async Claude sessions via the webhook API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/claude \
|
||||
# Create a new session
|
||||
curl -X POST http://localhost:3002/api/webhooks/claude \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-webhook-secret" \
|
||||
-d '{
|
||||
"repoFullName": "owner/repo",
|
||||
"command": "Analyze security vulnerabilities",
|
||||
"authToken": "your-token",
|
||||
"useContainer": true
|
||||
"type": "session.create",
|
||||
"session": {
|
||||
"type": "implementation",
|
||||
"project": {
|
||||
"repository": "owner/repo",
|
||||
"requirements": "Analyze security vulnerabilities"
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
# Check session status
|
||||
curl -X POST http://localhost:3002/api/webhooks/claude \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-webhook-secret" \
|
||||
-d '{
|
||||
"type": "session.get",
|
||||
"sessionId": "session-id-from-create"
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -260,7 +310,7 @@ CLAUDE_CONTAINER_IMAGE=claudecode:latest
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:8082/health
|
||||
curl http://localhost:3002/health
|
||||
```
|
||||
|
||||
### Logs
|
||||
@@ -283,11 +333,17 @@ DEBUG=claude:* npm run dev
|
||||
|
||||
## Documentation
|
||||
|
||||
### Deep Dive Guides
|
||||
- [Setup Container Authentication](./docs/setup-container-guide.md) - Technical details for subscription-based auth
|
||||
- [Authentication Guide](./docs/claude-authentication-guide.md) - All authentication methods and troubleshooting
|
||||
- [Complete Workflow](./docs/complete-workflow.md) - End-to-end technical guide
|
||||
- [Container Setup](./docs/container-setup.md) - Docker configuration details
|
||||
- [AWS Best Practices](./docs/aws-authentication-best-practices.md) - IAM and credential management
|
||||
- [GitHub Integration](./docs/github-workflow.md) - Webhook events and permissions
|
||||
- [Scripts Reference](./SCRIPTS.md) - Utility scripts documentation
|
||||
|
||||
### Reference
|
||||
- [Scripts Documentation](./docs/SCRIPTS.md) - Utility scripts and commands
|
||||
- [Command Reference](./CLAUDE.md) - Build and run commands
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -311,14 +367,6 @@ npm run dev
|
||||
- ESLint + Prettier for code formatting
|
||||
- Conventional commits for version management
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- [ ] No hardcoded credentials
|
||||
- [ ] All inputs sanitized
|
||||
- [ ] Webhook signatures verified
|
||||
- [ ] Container permissions minimal
|
||||
- [ ] Logs redact sensitive data
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
@@ -340,9 +388,9 @@ npm run dev
|
||||
|
||||
### Support
|
||||
|
||||
- Report issues: [GitHub Issues](https://github.com/intelligence-assist/claude-hub/issues)
|
||||
- Report issues: [GitHub Issues](https://github.com/claude-did-this/claude-hub/issues)
|
||||
- Detailed troubleshooting: [Complete Workflow Guide](./docs/complete-workflow.md#troubleshooting)
|
||||
|
||||
## License
|
||||
|
||||
MIT - See the [LICENSE file](LICENSE) for details.
|
||||
MIT - See the [LICENSE file](LICENSE) for details.
|
||||
|
||||
83
analyze-combined-coverage.js
Executable file
83
analyze-combined-coverage.js
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read combined lcov.info
|
||||
const lcovPath = path.join(__dirname, 'coverage-combined', 'lcov.info');
|
||||
if (!fs.existsSync(lcovPath)) {
|
||||
console.error('No coverage-combined/lcov.info file found. Run npm run test:combined-coverage first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lcovContent = fs.readFileSync(lcovPath, 'utf8');
|
||||
const lines = lcovContent.split('\n');
|
||||
|
||||
let currentFile = null;
|
||||
const fileStats = {};
|
||||
let totalLines = 0;
|
||||
let coveredLines = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = line.substring(3);
|
||||
if (!fileStats[currentFile]) {
|
||||
fileStats[currentFile] = { lines: 0, covered: 0, functions: 0, functionsHit: 0 };
|
||||
}
|
||||
} else if (line.startsWith('DA:')) {
|
||||
const [lineNum, hits] = line.substring(3).split(',').map(Number);
|
||||
if (currentFile) {
|
||||
fileStats[currentFile].lines++;
|
||||
totalLines++;
|
||||
if (hits > 0) {
|
||||
fileStats[currentFile].covered++;
|
||||
coveredLines++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overallCoverage = (coveredLines / totalLines) * 100;
|
||||
|
||||
console.log('\n=== Combined Coverage Analysis ===\n');
|
||||
console.log(`Total Lines: ${totalLines}`);
|
||||
console.log(`Covered Lines: ${coveredLines}`);
|
||||
console.log(`Overall Coverage: ${overallCoverage.toFixed(2)}%`);
|
||||
console.log(`Target: 80%`);
|
||||
console.log(`Status: ${overallCoverage >= 80 ? '✅ PASSED' : '❌ FAILED'}\n`);
|
||||
|
||||
// Break down by directory
|
||||
const srcFiles = Object.entries(fileStats).filter(([file]) => file.startsWith('src/'));
|
||||
const cliFiles = Object.entries(fileStats).filter(([file]) => file.startsWith('cli/'));
|
||||
|
||||
const srcStats = srcFiles.reduce((acc, [, stats]) => ({
|
||||
lines: acc.lines + stats.lines,
|
||||
covered: acc.covered + stats.covered
|
||||
}), { lines: 0, covered: 0 });
|
||||
|
||||
const cliStats = cliFiles.reduce((acc, [, stats]) => ({
|
||||
lines: acc.lines + stats.lines,
|
||||
covered: acc.covered + stats.covered
|
||||
}), { lines: 0, covered: 0 });
|
||||
|
||||
console.log('=== Coverage by Component ===');
|
||||
console.log(`Main src/: ${((srcStats.covered / srcStats.lines) * 100).toFixed(2)}% (${srcStats.covered}/${srcStats.lines} lines)`);
|
||||
console.log(`CLI: ${((cliStats.covered / cliStats.lines) * 100).toFixed(2)}% (${cliStats.covered}/${cliStats.lines} lines)`);
|
||||
|
||||
// Show files with lowest coverage
|
||||
console.log('\n=== Files with Lowest Coverage ===');
|
||||
const sorted = Object.entries(fileStats)
|
||||
.map(([file, stats]) => ({
|
||||
file,
|
||||
coverage: (stats.covered / stats.lines) * 100,
|
||||
lines: stats.lines,
|
||||
covered: stats.covered
|
||||
}))
|
||||
.sort((a, b) => a.coverage - b.coverage)
|
||||
.slice(0, 10);
|
||||
|
||||
sorted.forEach(({ file, coverage, covered, lines }) => {
|
||||
console.log(`${file.padEnd(60)} ${coverage.toFixed(2).padStart(6)}% (${covered}/${lines})`);
|
||||
});
|
||||
|
||||
process.exit(overallCoverage >= 80 ? 0 : 1);
|
||||
83
analyze-coverage.js
Normal file
83
analyze-coverage.js
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read lcov.info
|
||||
const lcovPath = path.join(__dirname, 'coverage', 'lcov.info');
|
||||
if (!fs.existsSync(lcovPath)) {
|
||||
console.error('No coverage/lcov.info file found. Run npm test:coverage first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lcovContent = fs.readFileSync(lcovPath, 'utf8');
|
||||
const lines = lcovContent.split('\n');
|
||||
|
||||
let currentFile = null;
|
||||
const fileStats = {};
|
||||
let totalLines = 0;
|
||||
let coveredLines = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = line.substring(3);
|
||||
if (!fileStats[currentFile]) {
|
||||
fileStats[currentFile] = { lines: 0, covered: 0, functions: 0, functionsHit: 0 };
|
||||
}
|
||||
} else if (line.startsWith('DA:')) {
|
||||
const [lineNum, hits] = line.substring(3).split(',').map(Number);
|
||||
if (currentFile) {
|
||||
fileStats[currentFile].lines++;
|
||||
totalLines++;
|
||||
if (hits > 0) {
|
||||
fileStats[currentFile].covered++;
|
||||
coveredLines++;
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('FNF:')) {
|
||||
if (currentFile) {
|
||||
fileStats[currentFile].functions = parseInt(line.substring(4));
|
||||
}
|
||||
} else if (line.startsWith('FNH:')) {
|
||||
if (currentFile) {
|
||||
fileStats[currentFile].functionsHit = parseInt(line.substring(4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Coverage Analysis ===\n');
|
||||
console.log(`Total Lines: ${totalLines}`);
|
||||
console.log(`Covered Lines: ${coveredLines}`);
|
||||
console.log(`Overall Coverage: ${((coveredLines / totalLines) * 100).toFixed(2)}%\n`);
|
||||
|
||||
console.log('=== File Breakdown ===\n');
|
||||
const sortedFiles = Object.entries(fileStats).sort((a, b) => {
|
||||
const coverageA = (a[1].covered / a[1].lines) * 100;
|
||||
const coverageB = (b[1].covered / b[1].lines) * 100;
|
||||
return coverageA - coverageB;
|
||||
});
|
||||
|
||||
for (const [file, stats] of sortedFiles) {
|
||||
const coverage = ((stats.covered / stats.lines) * 100).toFixed(2);
|
||||
console.log(`${file.padEnd(60)} ${coverage.padStart(6)}% (${stats.covered}/${stats.lines} lines)`);
|
||||
}
|
||||
|
||||
// Check if CLI coverage is included
|
||||
console.log('\n=== Coverage Scope Analysis ===\n');
|
||||
const cliFiles = sortedFiles.filter(([file]) => file.includes('cli/'));
|
||||
const srcFiles = sortedFiles.filter(([file]) => file.startsWith('src/'));
|
||||
|
||||
console.log(`Main src/ files: ${srcFiles.length}`);
|
||||
console.log(`CLI files: ${cliFiles.length}`);
|
||||
|
||||
if (cliFiles.length > 0) {
|
||||
console.log('\nCLI files found in coverage:');
|
||||
cliFiles.forEach(([file]) => console.log(` - ${file}`));
|
||||
}
|
||||
|
||||
// Check for any unexpected files
|
||||
const otherFiles = sortedFiles.filter(([file]) => !file.startsWith('src/') && !file.includes('cli/'));
|
||||
if (otherFiles.length > 0) {
|
||||
console.log('\nOther files in coverage:');
|
||||
otherFiles.forEach(([file]) => console.log(` - ${file}`));
|
||||
}
|
||||
99
calculate-codecov-match.js
Normal file
99
calculate-codecov-match.js
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Coverage data from the test output
|
||||
const coverageData = {
|
||||
'src/index.ts': { statements: 92.64, branches: 78.94, functions: 85.71, lines: 92.64 },
|
||||
'src/controllers/githubController.ts': { statements: 69.65, branches: 64.47, functions: 84.61, lines: 69.2 },
|
||||
'src/core/webhook/WebhookProcessor.ts': { statements: 100, branches: 92.3, functions: 100, lines: 100 },
|
||||
'src/core/webhook/WebhookRegistry.ts': { statements: 97.77, branches: 100, functions: 100, lines: 97.67 },
|
||||
'src/core/webhook/constants.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
'src/core/webhook/index.ts': { statements: 0, branches: 100, functions: 0, lines: 0 },
|
||||
'src/providers/claude/ClaudeWebhookProvider.ts': { statements: 77.41, branches: 46.66, functions: 100, lines: 77.41 },
|
||||
'src/providers/claude/index.ts': { statements: 100, branches: 100, functions: 0, lines: 100 },
|
||||
'src/providers/claude/handlers/OrchestrationHandler.ts': { statements: 95.65, branches: 75, functions: 100, lines: 95.65 },
|
||||
'src/providers/claude/handlers/SessionHandler.ts': { statements: 96.66, branches: 89.28, functions: 100, lines: 96.66 },
|
||||
'src/providers/claude/services/SessionManager.ts': { statements: 6.06, branches: 0, functions: 0, lines: 6.06 },
|
||||
'src/providers/claude/services/TaskDecomposer.ts': { statements: 96.87, branches: 93.75, functions: 100, lines: 96.66 },
|
||||
'src/providers/github/GitHubWebhookProvider.ts': { statements: 95.45, branches: 90.62, functions: 100, lines: 95.45 },
|
||||
'src/providers/github/index.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
'src/providers/github/handlers/IssueHandler.ts': { statements: 30.43, branches: 0, functions: 0, lines: 30.43 },
|
||||
'src/routes/github.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
'src/routes/webhooks.ts': { statements: 92.1, branches: 100, functions: 57.14, lines: 91.66 },
|
||||
'src/services/claudeService.ts': { statements: 85.62, branches: 66.17, functions: 100, lines: 86.66 },
|
||||
'src/services/githubService.ts': { statements: 72.22, branches: 78.57, functions: 75, lines: 71.93 },
|
||||
'src/types/claude.ts': { statements: 0, branches: 100, functions: 100, lines: 0 },
|
||||
'src/types/environment.ts': { statements: 0, branches: 0, functions: 0, lines: 0 },
|
||||
'src/types/index.ts': { statements: 0, branches: 0, functions: 0, lines: 0 },
|
||||
'src/utils/awsCredentialProvider.ts': { statements: 65.68, branches: 59.25, functions: 54.54, lines: 65.68 },
|
||||
'src/utils/logger.ts': { statements: 51.61, branches: 47.36, functions: 100, lines: 51.72 },
|
||||
'src/utils/sanitize.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
'src/utils/secureCredentials.ts': { statements: 54.28, branches: 70.58, functions: 33.33, lines: 54.28 },
|
||||
'src/utils/startup-metrics.ts': { statements: 100, branches: 100, functions: 100, lines: 100 }
|
||||
};
|
||||
|
||||
// Calculate different scenarios
|
||||
console.log('\n=== Coverage Analysis - Matching Codecov ===\n');
|
||||
|
||||
// Scenario 1: Exclude type definition files
|
||||
const withoutTypes = Object.entries(coverageData)
|
||||
.filter(([file]) => !file.includes('/types/'))
|
||||
.reduce((acc, [file, data]) => {
|
||||
acc[file] = data;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const avgWithoutTypes = calculateAverage(withoutTypes);
|
||||
console.log(`1. Without type files: ${avgWithoutTypes.toFixed(2)}%`);
|
||||
|
||||
// Scenario 2: Exclude files with 0% coverage
|
||||
const withoutZeroCoverage = Object.entries(coverageData)
|
||||
.filter(([file, data]) => data.lines > 0)
|
||||
.reduce((acc, [file, data]) => {
|
||||
acc[file] = data;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const avgWithoutZero = calculateAverage(withoutZeroCoverage);
|
||||
console.log(`2. Without 0% coverage files: ${avgWithoutZero.toFixed(2)}%`);
|
||||
|
||||
// Scenario 3: Exclude specific low coverage files
|
||||
const excludeLowCoverage = Object.entries(coverageData)
|
||||
.filter(([file]) => {
|
||||
return !file.includes('/types/') &&
|
||||
!file.includes('SessionManager.ts') &&
|
||||
!file.includes('IssueHandler.ts');
|
||||
})
|
||||
.reduce((acc, [file, data]) => {
|
||||
acc[file] = data;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const avgExcludeLow = calculateAverage(excludeLowCoverage);
|
||||
console.log(`3. Without types, SessionManager, IssueHandler: ${avgExcludeLow.toFixed(2)}%`);
|
||||
|
||||
// Scenario 4: Statement coverage only (what codecov might be reporting)
|
||||
const statementOnly = calculateStatementAverage(coverageData);
|
||||
console.log(`4. Statement coverage only: ${statementOnly.toFixed(2)}%`);
|
||||
|
||||
// Show which files have the biggest impact
|
||||
console.log('\n=== Files with lowest coverage ===');
|
||||
const sorted = Object.entries(coverageData)
|
||||
.sort((a, b) => a[1].lines - b[1].lines)
|
||||
.slice(0, 10);
|
||||
|
||||
sorted.forEach(([file, data]) => {
|
||||
console.log(`${file.padEnd(60)} ${data.lines.toFixed(2)}%`);
|
||||
});
|
||||
|
||||
function calculateAverage(data) {
|
||||
const values = Object.values(data).map(d => d.lines);
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
}
|
||||
|
||||
function calculateStatementAverage(data) {
|
||||
const values = Object.values(data).map(d => d.statements);
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
}
|
||||
569
claude-api-swagger.yaml
Normal file
569
claude-api-swagger.yaml
Normal file
@@ -0,0 +1,569 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Claude Webhook API
|
||||
description: |
|
||||
API for creating and managing Claude Code sessions for automated code generation, analysis, and orchestration.
|
||||
This API enables parallel execution of multiple Claude instances for complex software engineering tasks.
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Claude Hub Support
|
||||
url: https://github.com/claude-hub/claude-hub
|
||||
|
||||
servers:
|
||||
- url: https://your-domain.com
|
||||
description: Production server
|
||||
- url: http://localhost:3002
|
||||
description: Local development server
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: Health check
|
||||
description: Check the health status of the API and its dependencies
|
||||
tags:
|
||||
- System
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Service is healthy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HealthCheckResponse'
|
||||
|
||||
/api/webhooks/health:
|
||||
get:
|
||||
summary: Webhook health check
|
||||
description: Check the health status of webhook providers
|
||||
tags:
|
||||
- System
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Webhook providers are healthy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: healthy
|
||||
providers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
handlerCount:
|
||||
type: integer
|
||||
|
||||
/api/webhooks/github:
|
||||
post:
|
||||
summary: GitHub webhook endpoint (legacy)
|
||||
description: Legacy endpoint for GitHub webhooks. Use /api/webhooks/github instead.
|
||||
deprecated: true
|
||||
tags:
|
||||
- Webhooks
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Webhook processed successfully
|
||||
'401':
|
||||
description: Invalid webhook signature
|
||||
'404':
|
||||
description: Webhook event not handled
|
||||
|
||||
/api/webhooks/{provider}:
|
||||
post:
|
||||
summary: Generic webhook endpoint
|
||||
description: Process webhooks from various providers (github, claude)
|
||||
tags:
|
||||
- Webhooks
|
||||
security: []
|
||||
parameters:
|
||||
- name: provider
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [github, claude]
|
||||
description: The webhook provider name
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ClaudeWebhookRequest'
|
||||
- $ref: '#/components/schemas/GitHubWebhookPayload'
|
||||
examples:
|
||||
createSession:
|
||||
summary: Create a new Claude session
|
||||
value:
|
||||
type: session.create
|
||||
session:
|
||||
type: implementation
|
||||
project:
|
||||
repository: acme/webapp
|
||||
branch: feature/user-auth
|
||||
requirements: Implement JWT authentication middleware for Express.js with refresh token support
|
||||
context: Use existing User model, bcrypt for passwords, and jsonwebtoken library
|
||||
dependencies: []
|
||||
createSessionWithDependencies:
|
||||
summary: Create a session that depends on others
|
||||
value:
|
||||
type: session.create
|
||||
session:
|
||||
type: testing
|
||||
project:
|
||||
repository: acme/webapp
|
||||
branch: feature/user-auth
|
||||
requirements: Write comprehensive integration tests for the JWT authentication middleware
|
||||
context: Test all edge cases including token expiration, invalid tokens, and refresh flow
|
||||
dependencies:
|
||||
- 550e8400-e29b-41d4-a716-446655440000
|
||||
- 660e8400-e29b-41d4-a716-446655440001
|
||||
startSession:
|
||||
summary: Start an existing session
|
||||
value:
|
||||
type: session.start
|
||||
sessionId: 550e8400-e29b-41d4-a716-446655440000
|
||||
orchestrate:
|
||||
summary: Create an orchestration with multiple sessions
|
||||
value:
|
||||
type: orchestrate
|
||||
autoStart: true
|
||||
project:
|
||||
repository: acme/webapp
|
||||
branch: feature/complete-auth
|
||||
requirements: |
|
||||
Implement a complete authentication system:
|
||||
1. JWT middleware with refresh tokens
|
||||
2. User registration and login endpoints
|
||||
3. Password reset functionality
|
||||
4. Integration tests for all auth endpoints
|
||||
context: Use existing User model, PostgreSQL database, and follow REST API conventions
|
||||
responses:
|
||||
'200':
|
||||
description: Webhook processed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WebhookResponse'
|
||||
examples:
|
||||
sessionCreated:
|
||||
summary: Session created successfully
|
||||
value:
|
||||
success: true
|
||||
message: Session created successfully
|
||||
data:
|
||||
session:
|
||||
id: 550e8400-e29b-41d4-a716-446655440000
|
||||
type: implementation
|
||||
status: initializing
|
||||
containerId: claude-session-550e8400
|
||||
project:
|
||||
repository: acme/webapp
|
||||
branch: feature/user-auth
|
||||
requirements: Implement JWT authentication middleware for Express.js with refresh token support
|
||||
context: Use existing User model, bcrypt for passwords, and jsonwebtoken library
|
||||
dependencies: []
|
||||
sessionStarted:
|
||||
summary: Session started with dependencies
|
||||
value:
|
||||
success: true
|
||||
message: Session queued, waiting for dependencies
|
||||
data:
|
||||
session:
|
||||
id: 660e8400-e29b-41d4-a716-446655440001
|
||||
status: pending
|
||||
waitingFor:
|
||||
- 550e8400-e29b-41d4-a716-446655440000
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Unauthorized - Invalid token or signature
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Provider not found or session not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: Conflict - Session already started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: Too many requests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: Too many webhook requests
|
||||
message:
|
||||
type: string
|
||||
example: Too many webhook requests from this IP, please try again later.
|
||||
'500':
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Use CLAUDE_WEBHOOK_SECRET as the bearer token
|
||||
|
||||
schemas:
|
||||
HealthCheckResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, degraded]
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
startup:
|
||||
type: object
|
||||
properties:
|
||||
totalStartupTime:
|
||||
type: integer
|
||||
milestones:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
docker:
|
||||
type: object
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
checkTime:
|
||||
type: integer
|
||||
nullable: true
|
||||
claudeCodeImage:
|
||||
type: object
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
checkTime:
|
||||
type: integer
|
||||
nullable: true
|
||||
healthCheckDuration:
|
||||
type: integer
|
||||
|
||||
ClaudeWebhookRequest:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/SessionCreateRequest'
|
||||
- $ref: '#/components/schemas/SessionStartRequest'
|
||||
- $ref: '#/components/schemas/SessionGetRequest'
|
||||
- $ref: '#/components/schemas/SessionOutputRequest'
|
||||
- $ref: '#/components/schemas/SessionListRequest'
|
||||
- $ref: '#/components/schemas/OrchestrateRequest'
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
session.create: '#/components/schemas/SessionCreateRequest'
|
||||
session.start: '#/components/schemas/SessionStartRequest'
|
||||
session.get: '#/components/schemas/SessionGetRequest'
|
||||
session.output: '#/components/schemas/SessionOutputRequest'
|
||||
session.list: '#/components/schemas/SessionListRequest'
|
||||
orchestrate: '#/components/schemas/OrchestrateRequest'
|
||||
|
||||
SessionCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- session
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [session.create]
|
||||
session:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- project
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [implementation, analysis, testing, review, coordination]
|
||||
description: Type of Claude session
|
||||
project:
|
||||
type: object
|
||||
required:
|
||||
- repository
|
||||
- requirements
|
||||
properties:
|
||||
repository:
|
||||
type: string
|
||||
pattern: '^[a-zA-Z0-9-]+/[a-zA-Z0-9-_.]+$'
|
||||
example: acme/webapp
|
||||
description: GitHub repository in owner/repo format
|
||||
branch:
|
||||
type: string
|
||||
example: feature/user-auth
|
||||
description: Target branch name
|
||||
requirements:
|
||||
type: string
|
||||
example: Implement JWT authentication middleware for Express.js
|
||||
description: Clear description of what Claude should do
|
||||
context:
|
||||
type: string
|
||||
example: Use existing User model and bcrypt for password hashing
|
||||
description: Additional context about the codebase or requirements
|
||||
dependencies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Array of session IDs that must complete before this session starts
|
||||
|
||||
SessionStartRequest:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- sessionId
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [session.start]
|
||||
sessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
SessionGetRequest:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- sessionId
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [session.get]
|
||||
sessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
SessionOutputRequest:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- sessionId
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [session.output]
|
||||
sessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
SessionListRequest:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [session.list]
|
||||
orchestrationId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Filter sessions by orchestration ID
|
||||
|
||||
OrchestrateRequest:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- project
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [orchestrate]
|
||||
sessionType:
|
||||
type: string
|
||||
enum: [coordination]
|
||||
default: coordination
|
||||
autoStart:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to start the session immediately
|
||||
project:
|
||||
type: object
|
||||
required:
|
||||
- repository
|
||||
- requirements
|
||||
properties:
|
||||
repository:
|
||||
type: string
|
||||
pattern: '^[a-zA-Z0-9-]+/[a-zA-Z0-9-_.]+$'
|
||||
branch:
|
||||
type: string
|
||||
requirements:
|
||||
type: string
|
||||
context:
|
||||
type: string
|
||||
|
||||
WebhookResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: string
|
||||
example: Session not found
|
||||
|
||||
Session:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
enum: [implementation, analysis, testing, review, coordination]
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, initializing, running, completed, failed, cancelled]
|
||||
containerId:
|
||||
type: string
|
||||
nullable: true
|
||||
claudeSessionId:
|
||||
type: string
|
||||
nullable: true
|
||||
project:
|
||||
type: object
|
||||
properties:
|
||||
repository:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
requirements:
|
||||
type: string
|
||||
context:
|
||||
type: string
|
||||
dependencies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
startedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
output:
|
||||
type: object
|
||||
nullable: true
|
||||
error:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
SessionOutput:
|
||||
type: object
|
||||
properties:
|
||||
logs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
artifacts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [file, commit, pr, issue, comment]
|
||||
path:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
sha:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
summary:
|
||||
type: string
|
||||
example: Implemented JWT authentication middleware with refresh token support
|
||||
nextSteps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: [Add rate limiting, Implement password reset flow]
|
||||
|
||||
GitHubWebhookPayload:
|
||||
type: object
|
||||
description: GitHub webhook payload (simplified schema)
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
repository:
|
||||
type: object
|
||||
properties:
|
||||
full_name:
|
||||
type: string
|
||||
sender:
|
||||
type: object
|
||||
properties:
|
||||
login:
|
||||
type: string
|
||||
|
||||
tags:
|
||||
- name: System
|
||||
description: System health and status endpoints
|
||||
- name: Webhooks
|
||||
description: Webhook processing endpoints
|
||||
- name: Sessions
|
||||
description: Claude session management operations
|
||||
@@ -1,4 +0,0 @@
|
||||
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"d4460a3e-0af0-4e8c-a3c5-0427c9620fab","version":"0.2.118","type":"user","message":{"role":"user","content":"auth"},"uuid":"5bea393c-77c6-4f32-ac62-a157e0159045","timestamp":"2025-05-19T01:19:11.851Z"}
|
||||
{"parentUuid":"5bea393c-77c6-4f32-ac62-a157e0159045","isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"d4460a3e-0af0-4e8c-a3c5-0427c9620fab","version":"0.2.118","message":{"id":"msg_bdrk_01Lz7rrWgXdzbMayCabnExTJ","type":"message","role":"assistant","model":"claude-3-7-sonnet-20250219","content":[{"type":"text","text":"I'll search for authentication-related files and code in the repository."},{"type":"tool_use","id":"toolu_bdrk_01FCr4cpVZtKEZ1E9TD6AXcr","name":"Task","input":{"description":"Find auth files","prompt":"Search for any authentication-related files, code, or implementations in the repository. Look for files with names containing \"auth\", authentication implementations, login functionality, or security-related code. Return a list of relevant files and a brief summary of what each one contains."}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":17318,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":136}},"costUSD":0.053994,"durationMs":5319,"type":"assistant","uuid":"5df3af64-5b6c-457f-b559-9741977e06f5","timestamp":"2025-05-19T01:19:17.209Z"}
|
||||
{"parentUuid":"5df3af64-5b6c-457f-b559-9741977e06f5","isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"d4460a3e-0af0-4e8c-a3c5-0427c9620fab","version":"0.2.118","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"[Request interrupted by user for tool use]","is_error":true,"tool_use_id":"toolu_bdrk_01FCr4cpVZtKEZ1E9TD6AXcr"}]},"uuid":"84e6bfdd-e508-459d-b0b8-d02ccada8f5f","timestamp":"2025-05-19T01:19:21.315Z","toolUseResult":"Error: [Request interrupted by user for tool use]"}
|
||||
{"parentUuid":"84e6bfdd-e508-459d-b0b8-d02ccada8f5f","isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"d4460a3e-0af0-4e8c-a3c5-0427c9620fab","version":"0.2.118","type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user for tool use]"}]},"uuid":"ffe5b08f-786c-4cc7-9271-fead3ca72f4f","timestamp":"2025-05-19T01:19:21.319Z"}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
373
cli/README.md
373
cli/README.md
@@ -1,8 +1,17 @@
|
||||
# Claude Webhook CLI
|
||||
# Claude Hub CLI
|
||||
|
||||
The Claude Hub CLI provides two main interfaces:
|
||||
|
||||
1. **claude-webhook**: Interact with the Claude GitHub webhook service
|
||||
2. **claude-hub**: Manage autonomous Claude Code container sessions
|
||||
|
||||
 
|
||||
|
||||
## Claude Webhook CLI
|
||||
|
||||
A command-line interface to interact with the Claude GitHub webhook service.
|
||||
|
||||
## Installation
|
||||
### Installation
|
||||
|
||||
1. Ensure you have Node.js installed
|
||||
2. Install dependencies:
|
||||
@@ -10,7 +19,7 @@ A command-line interface to interact with the Claude GitHub webhook service.
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file in the root directory with:
|
||||
|
||||
@@ -20,9 +29,9 @@ GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
GITHUB_TOKEN=your-github-token
|
||||
```
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
### Basic Usage
|
||||
#### Basic Usage
|
||||
|
||||
```bash
|
||||
# Using the wrapper script (defaults to the DEFAULT_GITHUB_OWNER env variable)
|
||||
@@ -35,7 +44,7 @@ GITHUB_TOKEN=your-github-token
|
||||
node cli/webhook-cli.js --repo myrepo --command "Your command"
|
||||
```
|
||||
|
||||
### Options
|
||||
#### Options
|
||||
|
||||
- `-r, --repo <repo>`: GitHub repository (format: owner/repo or repo) [required]
|
||||
- If only repo name is provided, defaults to `${DEFAULT_GITHUB_OWNER}/repo`
|
||||
@@ -48,7 +57,7 @@ node cli/webhook-cli.js --repo myrepo --command "Your command"
|
||||
- `-t, --token <token>`: GitHub token (default: from .env)
|
||||
- `-v, --verbose`: Verbose output
|
||||
|
||||
### Examples
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Basic issue comment (uses default owner)
|
||||
@@ -70,7 +79,7 @@ node cli/webhook-cli.js --repo myrepo --command "Your command"
|
||||
./claude-webhook myrepo "Test command" -u https://api.example.com
|
||||
```
|
||||
|
||||
## Response Format
|
||||
#### Response Format
|
||||
|
||||
The CLI will display:
|
||||
- Success/failure status
|
||||
@@ -99,14 +108,356 @@ Here's an analysis of the code structure...
|
||||
}
|
||||
```
|
||||
|
||||
## Claude Hub CLI
|
||||
|
||||
A command-line interface to manage autonomous Claude Code container sessions.
|
||||
|
||||
### Overview
|
||||
|
||||
Claude Hub CLI allows you to run multiple autonomous Claude Code sessions in isolated Docker containers. Each session can work independently on different repositories or tasks, with full persistence and management capabilities.
|
||||
|
||||
### Installation
|
||||
|
||||
1. Ensure you have Node.js and Docker installed
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
cd cli
|
||||
npm install
|
||||
```
|
||||
3. Build the TypeScript files:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file in the root directory with:
|
||||
|
||||
```env
|
||||
# Required for GitHub operations
|
||||
GITHUB_TOKEN=your-github-token
|
||||
|
||||
# Required for Claude operations (one of these)
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
CLAUDE_AUTH_HOST_DIR=~/.claude
|
||||
|
||||
# Optional configurations
|
||||
DEFAULT_GITHUB_OWNER=your-github-username
|
||||
BOT_USERNAME=ClaudeBot
|
||||
BOT_EMAIL=claude@example.com
|
||||
CLAUDE_CONTAINER_IMAGE=claudecode:latest
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
#### Basic Commands
|
||||
|
||||
```bash
|
||||
# Start a new autonomous session
|
||||
./claude-hub start owner/repo "Implement the new authentication system"
|
||||
|
||||
# Start a batch of tasks from a YAML file
|
||||
./claude-hub start-batch tasks.yaml --parallel
|
||||
|
||||
# List all sessions
|
||||
./claude-hub list
|
||||
|
||||
# View session logs
|
||||
./claude-hub logs abc123
|
||||
|
||||
# Follow logs in real-time
|
||||
./claude-hub logs abc123 --follow
|
||||
|
||||
# Continue a session with additional instructions
|
||||
./claude-hub continue abc123 "Also update the documentation"
|
||||
|
||||
# Stop a session
|
||||
./claude-hub stop abc123
|
||||
|
||||
# Stop all running sessions
|
||||
./claude-hub stop all
|
||||
|
||||
# Recover a stopped session
|
||||
./claude-hub recover abc123
|
||||
|
||||
# Synchronize session statuses with container states
|
||||
./claude-hub sync
|
||||
```
|
||||
|
||||
#### Command Reference
|
||||
|
||||
##### `start`
|
||||
|
||||
Start a new autonomous Claude Code session:
|
||||
|
||||
```bash
|
||||
./claude-hub start <repo> "<command>" [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-p, --pr [number]`: Treat as pull request and optionally specify PR number
|
||||
- `-i, --issue <number>`: Treat as issue and specify issue number
|
||||
- `-b, --branch <branch>`: Branch name for PR
|
||||
- `-m, --memory <limit>`: Memory limit (e.g., "2g")
|
||||
- `-c, --cpu <shares>`: CPU shares (e.g., "1024")
|
||||
- `--pids <limit>`: Process ID limit (e.g., "256")
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Basic repository task
|
||||
./claude-hub start myorg/myrepo "Implement feature X"
|
||||
|
||||
# Work on a specific PR
|
||||
./claude-hub start myrepo "Fix bug in authentication" --pr 42
|
||||
|
||||
# Work on a specific issue
|
||||
./claude-hub start myrepo "Investigate the problem" --issue 123
|
||||
|
||||
# Work on a specific branch with custom resource limits
|
||||
./claude-hub start myrepo "Optimize performance" -b feature-branch -m 4g -c 2048
|
||||
```
|
||||
|
||||
##### `start-batch`
|
||||
|
||||
Start multiple autonomous Claude Code sessions from a YAML file:
|
||||
|
||||
```bash
|
||||
./claude-hub start-batch <file> [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-p, --parallel`: Run tasks in parallel (default: sequential)
|
||||
- `-c, --concurrent <number>`: Maximum number of concurrent tasks (default: 2)
|
||||
|
||||
Example YAML file format (`tasks.yaml`):
|
||||
```yaml
|
||||
- repo: owner/repo1
|
||||
command: "Implement feature X"
|
||||
|
||||
- repo: owner/repo2
|
||||
command: "Fix bug in authentication"
|
||||
pr: 42
|
||||
branch: feature-branch
|
||||
|
||||
- repo: owner/repo3
|
||||
command: "Investigate issue"
|
||||
issue: 123
|
||||
resourceLimits:
|
||||
memory: "4g"
|
||||
cpuShares: "2048"
|
||||
pidsLimit: "512"
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Run tasks sequentially
|
||||
./claude-hub start-batch tasks.yaml
|
||||
|
||||
# Run tasks in parallel (max 2 concurrent)
|
||||
./claude-hub start-batch tasks.yaml --parallel
|
||||
|
||||
# Run tasks in parallel with 4 concurrent tasks
|
||||
./claude-hub start-batch tasks.yaml --parallel --concurrent 4
|
||||
```
|
||||
|
||||
##### `list`
|
||||
|
||||
List autonomous Claude Code sessions:
|
||||
|
||||
```bash
|
||||
./claude-hub list [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-s, --status <status>`: Filter by status (running, completed, failed, stopped)
|
||||
- `-r, --repo <repo>`: Filter by repository name
|
||||
- `-l, --limit <number>`: Limit number of sessions shown
|
||||
- `--json`: Output as JSON
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# List all sessions
|
||||
./claude-hub list
|
||||
|
||||
# List only running sessions
|
||||
./claude-hub list --status running
|
||||
|
||||
# List sessions for a specific repository
|
||||
./claude-hub list --repo myrepo
|
||||
|
||||
# Get JSON output for automation
|
||||
./claude-hub list --json
|
||||
```
|
||||
|
||||
##### `logs`
|
||||
|
||||
View logs from a Claude Code session:
|
||||
|
||||
```bash
|
||||
./claude-hub logs <id> [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-f, --follow`: Follow log output
|
||||
- `-t, --tail <number>`: Number of lines to show from the end of the logs
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# View logs for a session
|
||||
./claude-hub logs abc123
|
||||
|
||||
# Follow logs in real-time
|
||||
./claude-hub logs abc123 --follow
|
||||
|
||||
# Show only the last 10 lines
|
||||
./claude-hub logs abc123 --tail 10
|
||||
```
|
||||
|
||||
##### `continue`
|
||||
|
||||
Continue an autonomous Claude Code session with a new command:
|
||||
|
||||
```bash
|
||||
./claude-hub continue <id> "<command>"
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Add more instructions to a session
|
||||
./claude-hub continue abc123 "Also update the documentation"
|
||||
|
||||
# Ask a follow-up question
|
||||
./claude-hub continue abc123 "Why did you choose this approach?"
|
||||
```
|
||||
|
||||
##### `stop`
|
||||
|
||||
Stop an autonomous Claude Code session:
|
||||
|
||||
```bash
|
||||
./claude-hub stop <id|all> [options]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-f, --force`: Force stop (kill) the container
|
||||
- `--remove`: Remove the session after stopping
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Stop a session
|
||||
./claude-hub stop abc123
|
||||
|
||||
# Force stop a session and remove it
|
||||
./claude-hub stop abc123 --force --remove
|
||||
|
||||
# Stop all running sessions
|
||||
./claude-hub stop all
|
||||
```
|
||||
|
||||
##### `recover`
|
||||
|
||||
Recover a stopped session by recreating its container:
|
||||
|
||||
```bash
|
||||
./claude-hub recover <id>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Recover a stopped session
|
||||
./claude-hub recover abc123
|
||||
```
|
||||
|
||||
##### `sync`
|
||||
|
||||
Synchronize session statuses with container states:
|
||||
|
||||
```bash
|
||||
./claude-hub sync
|
||||
```
|
||||
|
||||
This command checks all sessions marked as "running" to verify if their containers are actually running, and updates the status accordingly.
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
1. **Starting**: Creates a new container with the repository cloned and command executed
|
||||
2. **Running**: Container continues to run autonomously until task completion or manual stopping
|
||||
3. **Continuation**: Additional commands can be sent to running sessions
|
||||
4. **Stopping**: Sessions can be stopped manually, preserving their state
|
||||
5. **Recovery**: Stopped sessions can be recovered by recreating their containers
|
||||
6. **Removal**: Session records can be removed while preserving logs
|
||||
|
||||
### Batch Processing
|
||||
|
||||
The CLI supports batch processing of multiple tasks from a YAML file. This is useful for:
|
||||
|
||||
1. **Task queuing**: Set up multiple related tasks to run in sequence
|
||||
2. **Parallel execution**: Run multiple independent tasks concurrently
|
||||
3. **Standardized configuration**: Define consistent resource limits and repository contexts
|
||||
|
||||
### Storage
|
||||
|
||||
Session information is stored in `~/.claude-hub/sessions/` as JSON files.
|
||||
|
||||
## Testing
|
||||
|
||||
The Claude Hub CLI includes comprehensive test coverage to ensure reliability:
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run tests with coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Run tests in watch mode (development)
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
The test suite is organized as follows:
|
||||
|
||||
- **Unit Tests**: Testing individual components in isolation
|
||||
- `__tests__/utils/`: Tests for utility classes (SessionManager, DockerUtils)
|
||||
- `__tests__/commands/`: Tests for CLI commands (start, list, logs, etc.)
|
||||
|
||||
- **Integration Tests**: Testing interactions between components
|
||||
- Tests for command execution flows
|
||||
- Tests for Docker container integration
|
||||
|
||||
- **Fixtures**: Sample data for testing
|
||||
- `__tests__/fixtures/batch-tasks.yaml`: Sample batch task configuration
|
||||
|
||||
### Testing Approach
|
||||
|
||||
1. **Mocking**: External dependencies (Docker, filesystem) are mocked for predictable testing
|
||||
2. **Coverage Goals**:
|
||||
- 80% overall code coverage (current: ~65%)
|
||||
- 90% coverage for core utilities (current: dockerUtils 88.6%, sessionManager 86.27%)
|
||||
- Critical paths fully covered (start.ts: 97.43%, start-batch.ts: 100%)
|
||||
3. **Environment**: Tests use a temporary home directory to avoid affecting user data
|
||||
4. **Docker Testing**: Docker operations are mocked in unit tests but can be tested with real containers in integration tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Authentication errors**: Ensure your webhook secret and GitHub token are correct
|
||||
1. **Authentication errors**: Ensure your GitHub token and Claude authentication are correct
|
||||
2. **Connection errors**: Verify the API URL is correct and the service is running
|
||||
3. **Invalid signatures**: Check that the webhook secret matches the server configuration
|
||||
4. **Docker errors**: Verify Docker is running and you have sufficient permissions
|
||||
5. **Resource constraints**: If sessions are failing, try increasing memory limits
|
||||
6. **Stopped sessions**: Use the `recover` command to restart stopped sessions
|
||||
7. **Inconsistent statuses**: Use the `sync` command to update session statuses based on container states
|
||||
8. **Test failures**: If tests are failing, check Docker availability and environment configuration
|
||||
|
||||
## Security
|
||||
|
||||
- The CLI uses the webhook secret to sign requests
|
||||
- The webhook CLI uses the webhook secret to sign requests
|
||||
- GitHub tokens are used for authentication with the GitHub API
|
||||
- Always store secrets in environment variables, never in code
|
||||
- All autonomous sessions run in isolated Docker containers
|
||||
- Resource limits prevent containers from consuming excessive resources
|
||||
- Claude authentication is securely mounted from your local Claude installation
|
||||
- Always store secrets in environment variables, never in code
|
||||
- All inputs are validated to prevent command injection
|
||||
22
cli/__tests__/__mocks__/dockerUtils.ts
Normal file
22
cli/__tests__/__mocks__/dockerUtils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Mock implementation of DockerUtils for testing
|
||||
export const mockStartContainer = jest.fn().mockResolvedValue('mock-container-id');
|
||||
export const mockStopContainer = jest.fn().mockResolvedValue(true);
|
||||
export const mockGetContainerLogs = jest.fn().mockResolvedValue('Mock container logs');
|
||||
export const mockIsContainerRunning = jest.fn().mockResolvedValue(true);
|
||||
export const mockGetContainerStats = jest.fn().mockResolvedValue({
|
||||
cpu: '5%',
|
||||
memory: '100MB / 2GB',
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
const mockDockerUtils = jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
startContainer: mockStartContainer,
|
||||
stopContainer: mockStopContainer,
|
||||
getContainerLogs: mockGetContainerLogs,
|
||||
isContainerRunning: mockIsContainerRunning,
|
||||
getContainerStats: mockGetContainerStats,
|
||||
};
|
||||
});
|
||||
|
||||
export default mockDockerUtils;
|
||||
61
cli/__tests__/__mocks__/sessionManager.ts
Normal file
61
cli/__tests__/__mocks__/sessionManager.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Mock implementation of SessionManager for testing
|
||||
import { SessionConfig, SessionStatus } from '../../src/types/session';
|
||||
|
||||
const mockSessions: Record<string, SessionConfig> = {};
|
||||
|
||||
export const mockCreateSession = jest.fn().mockImplementation((sessionConfig: SessionConfig) => {
|
||||
mockSessions[sessionConfig.id] = sessionConfig;
|
||||
return Promise.resolve(sessionConfig);
|
||||
});
|
||||
|
||||
export const mockUpdateSession = jest.fn().mockImplementation((id: string, updates: Partial<SessionConfig>) => {
|
||||
if (mockSessions[id]) {
|
||||
mockSessions[id] = { ...mockSessions[id], ...updates };
|
||||
return Promise.resolve(mockSessions[id]);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
export const mockGetSession = jest.fn().mockImplementation((id: string) => {
|
||||
return Promise.resolve(mockSessions[id] || null);
|
||||
});
|
||||
|
||||
export const mockGetAllSessions = jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve(Object.values(mockSessions));
|
||||
});
|
||||
|
||||
export const mockDeleteSession = jest.fn().mockImplementation((id: string) => {
|
||||
if (mockSessions[id]) {
|
||||
delete mockSessions[id];
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
|
||||
export const mockRecoverSession = jest.fn().mockImplementation((id: string) => {
|
||||
if (mockSessions[id]) {
|
||||
mockSessions[id].status = SessionStatus.RUNNING;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
|
||||
export const mockSyncSessions = jest.fn().mockResolvedValue(true);
|
||||
|
||||
const mockSessionManager = jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
createSession: mockCreateSession,
|
||||
updateSession: mockUpdateSession,
|
||||
getSession: mockGetSession,
|
||||
getAllSessions: mockGetAllSessions,
|
||||
deleteSession: mockDeleteSession,
|
||||
recoverSession: mockRecoverSession,
|
||||
syncSessions: mockSyncSessions,
|
||||
reset: () => {
|
||||
// Clear all mock sessions
|
||||
Object.keys(mockSessions).forEach(key => delete mockSessions[key]);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export default mockSessionManager;
|
||||
191
cli/__tests__/commands/continue.test.ts
Normal file
191
cli/__tests__/commands/continue.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerContinueCommand } from '../../src/commands/continue';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
import { SessionConfig } from '../../src/types/session';
|
||||
import ora from 'ora';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../src/utils/sessionManager');
|
||||
jest.mock('../../src/utils/dockerUtils');
|
||||
jest.mock('ora', () => {
|
||||
const mockSpinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
text: ''
|
||||
};
|
||||
return jest.fn(() => mockSpinner);
|
||||
});
|
||||
|
||||
// Mock console methods
|
||||
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
describe('Continue Command', () => {
|
||||
let program: Command;
|
||||
let mockGetSession: jest.Mock;
|
||||
let mockUpdateSessionStatus: jest.Mock;
|
||||
let mockSaveSession: jest.Mock;
|
||||
let mockIsContainerRunning: jest.Mock;
|
||||
let mockExecuteCommand: jest.Mock;
|
||||
let mockSpinner: { start: jest.Mock; succeed: jest.Mock; fail: jest.Mock; };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup program
|
||||
program = new Command();
|
||||
|
||||
// Setup SessionManager mock
|
||||
mockGetSession = jest.fn();
|
||||
mockUpdateSessionStatus = jest.fn();
|
||||
mockSaveSession = jest.fn();
|
||||
(SessionManager as jest.Mock).mockImplementation(() => ({
|
||||
getSession: mockGetSession,
|
||||
updateSessionStatus: mockUpdateSessionStatus,
|
||||
saveSession: mockSaveSession
|
||||
}));
|
||||
|
||||
// Setup DockerUtils mock
|
||||
mockIsContainerRunning = jest.fn();
|
||||
mockExecuteCommand = jest.fn();
|
||||
(DockerUtils as jest.Mock).mockImplementation(() => ({
|
||||
isContainerRunning: mockIsContainerRunning,
|
||||
executeCommand: mockExecuteCommand
|
||||
}));
|
||||
|
||||
// Setup ora spinner mock
|
||||
mockSpinner = ora('') as unknown as { start: jest.Mock; succeed: jest.Mock; fail: jest.Mock; };
|
||||
|
||||
// Register the command
|
||||
registerContinueCommand(program);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockConsoleLog.mockClear();
|
||||
});
|
||||
|
||||
const mockSession: SessionConfig = {
|
||||
id: 'session1',
|
||||
repoFullName: 'user/repo1',
|
||||
containerId: 'container1',
|
||||
command: 'help me with this code',
|
||||
status: 'running',
|
||||
createdAt: '2025-06-01T10:00:00Z',
|
||||
updatedAt: '2025-06-01T10:05:00Z'
|
||||
};
|
||||
|
||||
it('should continue a running session with a new command', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockExecuteCommand.mockResolvedValue({ stdout: 'Command executed' });
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'continue', 'session1', 'analyze this function']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Check if command was executed in container
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith(
|
||||
'container1',
|
||||
expect.stringContaining('analyze this function')
|
||||
);
|
||||
|
||||
// Check if session was updated
|
||||
expect(mockSaveSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'session1',
|
||||
command: expect.stringContaining('Continuation: analyze this function')
|
||||
}));
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Command sent to session'));
|
||||
});
|
||||
|
||||
it('should fail when session does not exist', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(null);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'continue', 'nonexistent', 'analyze this function']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('nonexistent');
|
||||
|
||||
// Container status should not be checked
|
||||
expect(mockIsContainerRunning).not.toHaveBeenCalled();
|
||||
|
||||
// Command should not be executed
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should fail when container is not running', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'continue', 'session1', 'analyze this function']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Command should not be executed
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Check if session status was updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledWith('session1', 'stopped');
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
||||
});
|
||||
|
||||
it('should handle errors during command execution', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockExecuteCommand.mockRejectedValue(new Error('Command execution failed'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'continue', 'session1', 'analyze this function']);
|
||||
|
||||
// Checks should still have been made
|
||||
expect(mockGetSession).toHaveBeenCalled();
|
||||
expect(mockIsContainerRunning).toHaveBeenCalled();
|
||||
expect(mockExecuteCommand).toHaveBeenCalled();
|
||||
|
||||
// Session should not be updated
|
||||
expect(mockSaveSession).not.toHaveBeenCalled();
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Failed to continue session'));
|
||||
});
|
||||
|
||||
it('should not update session status if session is not running', async () => {
|
||||
// Setup mocks with non-running session
|
||||
const stoppedSession = { ...mockSession, status: 'stopped' };
|
||||
mockGetSession.mockReturnValue(stoppedSession);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'continue', 'session1', 'analyze this function']);
|
||||
|
||||
// Check if session status was NOT updated (already stopped)
|
||||
expect(mockUpdateSessionStatus).not.toHaveBeenCalled();
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
||||
});
|
||||
});
|
||||
195
cli/__tests__/commands/list.test.ts
Normal file
195
cli/__tests__/commands/list.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerListCommand } from '../../src/commands/list';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
import { SessionConfig } from '../../src/types/session';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../src/utils/sessionManager');
|
||||
jest.mock('../../src/utils/dockerUtils');
|
||||
jest.mock('cli-table3', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
toString: jest.fn().mockReturnValue('mocked-table')
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Mock console methods
|
||||
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
|
||||
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
describe('List Command', () => {
|
||||
let program: Command;
|
||||
let mockListSessions: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup program
|
||||
program = new Command();
|
||||
|
||||
// Setup SessionManager mock
|
||||
mockListSessions = jest.fn();
|
||||
(SessionManager as jest.Mock).mockImplementation(() => ({
|
||||
listSessions: mockListSessions
|
||||
}));
|
||||
|
||||
// Register the command
|
||||
registerListCommand(program);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockConsoleLog.mockClear();
|
||||
mockConsoleError.mockClear();
|
||||
});
|
||||
|
||||
const mockSessions: SessionConfig[] = [
|
||||
{
|
||||
id: 'session1',
|
||||
repoFullName: 'user/repo1',
|
||||
containerId: 'container1',
|
||||
command: 'help me with this code',
|
||||
status: 'running',
|
||||
createdAt: '2025-06-01T10:00:00Z',
|
||||
updatedAt: '2025-06-01T10:05:00Z'
|
||||
},
|
||||
{
|
||||
id: 'session2',
|
||||
repoFullName: 'user/repo2',
|
||||
containerId: 'container2',
|
||||
command: 'explain this function',
|
||||
status: 'completed',
|
||||
createdAt: '2025-05-31T09:00:00Z',
|
||||
updatedAt: '2025-05-31T09:10:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
it('should list sessions with default options', async () => {
|
||||
// Setup mock to return sessions
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list']);
|
||||
|
||||
// Check if listSessions was called with correct options
|
||||
expect(mockListSessions).toHaveBeenCalledWith({
|
||||
status: undefined,
|
||||
repo: undefined,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Verify output
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith('mocked-table');
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Use'));
|
||||
});
|
||||
|
||||
it('should list sessions with status filter', async () => {
|
||||
// Setup mock to return filtered sessions
|
||||
mockListSessions.mockResolvedValue([mockSessions[0]]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list', '--status', 'running']);
|
||||
|
||||
// Check if listSessions was called with correct options
|
||||
expect(mockListSessions).toHaveBeenCalledWith({
|
||||
status: 'running',
|
||||
repo: undefined,
|
||||
limit: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('should list sessions with repo filter', async () => {
|
||||
// Setup mock to return filtered sessions
|
||||
mockListSessions.mockResolvedValue([mockSessions[0]]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list', '--repo', 'user/repo1']);
|
||||
|
||||
// Check if listSessions was called with correct options
|
||||
expect(mockListSessions).toHaveBeenCalledWith({
|
||||
status: undefined,
|
||||
repo: 'user/repo1',
|
||||
limit: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('should list sessions with limit', async () => {
|
||||
// Setup mock to return sessions
|
||||
mockListSessions.mockResolvedValue([mockSessions[0]]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list', '--limit', '1']);
|
||||
|
||||
// Check if listSessions was called with correct options
|
||||
expect(mockListSessions).toHaveBeenCalledWith({
|
||||
status: undefined,
|
||||
repo: undefined,
|
||||
limit: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should output as JSON when --json flag is used', async () => {
|
||||
// Setup mock to return sessions
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list', '--json']);
|
||||
|
||||
// Verify JSON output
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(JSON.stringify(mockSessions, null, 2));
|
||||
});
|
||||
|
||||
it('should show message when no sessions found', async () => {
|
||||
// Setup mock to return empty array
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list']);
|
||||
|
||||
// Verify output
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith('No sessions found matching the criteria.');
|
||||
});
|
||||
|
||||
it('should show empty JSON array when no sessions found with --json flag', async () => {
|
||||
// Setup mock to return empty array
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list', '--json']);
|
||||
|
||||
// Verify output
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith('[]');
|
||||
});
|
||||
|
||||
it('should reject invalid status values', async () => {
|
||||
// Execute the command with invalid status
|
||||
await program.parseAsync(['node', 'test', 'list', '--status', 'invalid']);
|
||||
|
||||
// Verify error message
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid status'));
|
||||
expect(mockListSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid limit values', async () => {
|
||||
// Execute the command with invalid limit
|
||||
await program.parseAsync(['node', 'test', 'list', '--limit', '-1']);
|
||||
|
||||
// Verify error message
|
||||
expect(mockConsoleError).toHaveBeenCalledWith('Limit must be a positive number');
|
||||
expect(mockListSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors from sessionManager', async () => {
|
||||
// Setup mock to throw error
|
||||
mockListSessions.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'list']);
|
||||
|
||||
// Verify error message
|
||||
expect(mockConsoleError).toHaveBeenCalledWith('Error listing sessions: Database error');
|
||||
});
|
||||
});
|
||||
234
cli/__tests__/commands/logs.test.ts
Normal file
234
cli/__tests__/commands/logs.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerLogsCommand } from '../../src/commands/logs';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
import { SessionConfig } from '../../src/types/session';
|
||||
import ora from 'ora';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../src/utils/sessionManager');
|
||||
jest.mock('../../src/utils/dockerUtils');
|
||||
jest.mock('ora', () => {
|
||||
const mockSpinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
text: ''
|
||||
};
|
||||
return jest.fn(() => mockSpinner);
|
||||
});
|
||||
|
||||
// Mock console methods
|
||||
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
|
||||
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation();
|
||||
const mockConsoleWarn = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
describe('Logs Command', () => {
|
||||
let program: Command;
|
||||
let mockGetSession: jest.Mock;
|
||||
let mockUpdateSessionStatus: jest.Mock;
|
||||
let mockIsContainerRunning: jest.Mock;
|
||||
let mockGetContainerLogs: jest.Mock;
|
||||
let mockSpinner: { start: jest.Mock; stop: jest.Mock; fail: jest.Mock; };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup program
|
||||
program = new Command();
|
||||
|
||||
// Setup SessionManager mock
|
||||
mockGetSession = jest.fn();
|
||||
mockUpdateSessionStatus = jest.fn();
|
||||
(SessionManager as jest.Mock).mockImplementation(() => ({
|
||||
getSession: mockGetSession,
|
||||
updateSessionStatus: mockUpdateSessionStatus
|
||||
}));
|
||||
|
||||
// Setup DockerUtils mock
|
||||
mockIsContainerRunning = jest.fn();
|
||||
mockGetContainerLogs = jest.fn();
|
||||
(DockerUtils as jest.Mock).mockImplementation(() => ({
|
||||
isContainerRunning: mockIsContainerRunning,
|
||||
getContainerLogs: mockGetContainerLogs
|
||||
}));
|
||||
|
||||
// Setup ora spinner mock
|
||||
mockSpinner = ora('') as unknown as { start: jest.Mock; stop: jest.Mock; fail: jest.Mock; };
|
||||
|
||||
// Register the command
|
||||
registerLogsCommand(program);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockConsoleLog.mockClear();
|
||||
mockConsoleError.mockClear();
|
||||
mockConsoleWarn.mockClear();
|
||||
});
|
||||
|
||||
const mockSession: SessionConfig = {
|
||||
id: 'session1',
|
||||
repoFullName: 'user/repo1',
|
||||
containerId: 'container1',
|
||||
command: 'help me with this code',
|
||||
status: 'running',
|
||||
createdAt: '2025-06-01T10:00:00Z',
|
||||
updatedAt: '2025-06-01T10:05:00Z'
|
||||
};
|
||||
|
||||
it('should show logs for a running session', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockGetContainerLogs.mockResolvedValue('Sample log output');
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Session status should not be updated for a running container
|
||||
expect(mockUpdateSessionStatus).not.toHaveBeenCalled();
|
||||
|
||||
// Check if logs were fetched
|
||||
expect(mockGetContainerLogs).toHaveBeenCalledWith('container1', false, expect.any(Number));
|
||||
|
||||
// Check that session details were printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Session details:'));
|
||||
|
||||
// Check that logs were printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith('Sample log output');
|
||||
});
|
||||
|
||||
it('should fail when session does not exist', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(null);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'nonexistent']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('nonexistent');
|
||||
|
||||
// Docker utils should not be called
|
||||
expect(mockIsContainerRunning).not.toHaveBeenCalled();
|
||||
expect(mockGetContainerLogs).not.toHaveBeenCalled();
|
||||
|
||||
// Check for error message
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should update session status when container is not running but session status is running', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
mockGetContainerLogs.mockResolvedValue('Sample log output');
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Session status should be updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledWith('session1', 'stopped');
|
||||
|
||||
// Check if logs were still fetched
|
||||
expect(mockGetContainerLogs).toHaveBeenCalledWith('container1', false, expect.any(Number));
|
||||
});
|
||||
|
||||
it('should follow logs when --follow option is provided', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockGetContainerLogs.mockResolvedValue(undefined); // Follow mode doesn't return logs
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1', '--follow']);
|
||||
|
||||
// Check if logs were fetched with follow=true
|
||||
expect(mockGetContainerLogs).toHaveBeenCalledWith('container1', true, expect.any(Number));
|
||||
|
||||
// Check that streaming message was printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Streaming logs'));
|
||||
});
|
||||
|
||||
it('should warn when using --follow on a non-running session', async () => {
|
||||
// Setup mocks with non-running session
|
||||
const stoppedSession = { ...mockSession, status: 'stopped' };
|
||||
mockGetSession.mockReturnValue(stoppedSession);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
mockGetContainerLogs.mockResolvedValue(undefined);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1', '--follow']);
|
||||
|
||||
// Check that warning was printed
|
||||
expect(mockConsoleWarn).toHaveBeenCalledWith(expect.stringContaining('Warning'));
|
||||
|
||||
// Should still try to follow logs
|
||||
expect(mockGetContainerLogs).toHaveBeenCalledWith('container1', true, expect.any(Number));
|
||||
});
|
||||
|
||||
it('should use custom tail value when --tail option is provided', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockGetContainerLogs.mockResolvedValue('Sample log output');
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1', '--tail', '50']);
|
||||
|
||||
// Check if logs were fetched with custom tail value
|
||||
expect(mockGetContainerLogs).toHaveBeenCalledWith('container1', false, 50);
|
||||
});
|
||||
|
||||
it('should reject invalid tail values', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
|
||||
// Execute the command with invalid tail value
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1', '--tail', '-1']);
|
||||
|
||||
// Check for error message
|
||||
expect(mockConsoleError).toHaveBeenCalledWith('Tail must be a non-negative number');
|
||||
|
||||
// Should not fetch logs
|
||||
expect(mockGetContainerLogs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors when fetching logs', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockGetContainerLogs.mockRejectedValue(new Error('Docker error'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1']);
|
||||
|
||||
// Check if error was handled
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Failed to retrieve logs'));
|
||||
});
|
||||
|
||||
it('should handle general errors', async () => {
|
||||
// Setup mocks to throw error
|
||||
mockGetSession.mockImplementation(() => {
|
||||
throw new Error('Unexpected error');
|
||||
});
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'logs', 'session1']);
|
||||
|
||||
// Check for error message
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Error showing logs'));
|
||||
});
|
||||
});
|
||||
261
cli/__tests__/commands/recover.test.ts
Normal file
261
cli/__tests__/commands/recover.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerRecoverCommand } from '../../src/commands/recover';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { SessionConfig } from '../../src/types/session';
|
||||
import ora from 'ora';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../src/utils/sessionManager');
|
||||
jest.mock('ora', () => {
|
||||
const mockSpinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
info: jest.fn().mockReturnThis(),
|
||||
text: ''
|
||||
};
|
||||
return jest.fn(() => mockSpinner);
|
||||
});
|
||||
|
||||
// Mock console methods
|
||||
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
describe('Recover Command', () => {
|
||||
let program: Command;
|
||||
let mockGetSession: jest.Mock;
|
||||
let mockRecoverSession: jest.Mock;
|
||||
let mockListSessions: jest.Mock;
|
||||
let mockSyncSessionStatuses: jest.Mock;
|
||||
let mockSpinner: { start: jest.Mock; succeed: jest.Mock; fail: jest.Mock; info: jest.Mock; };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup program
|
||||
program = new Command();
|
||||
|
||||
// Setup SessionManager mock
|
||||
mockGetSession = jest.fn();
|
||||
mockRecoverSession = jest.fn();
|
||||
mockListSessions = jest.fn();
|
||||
mockSyncSessionStatuses = jest.fn();
|
||||
(SessionManager as jest.Mock).mockImplementation(() => ({
|
||||
getSession: mockGetSession,
|
||||
recoverSession: mockRecoverSession,
|
||||
listSessions: mockListSessions,
|
||||
syncSessionStatuses: mockSyncSessionStatuses
|
||||
}));
|
||||
|
||||
// Setup ora spinner mock
|
||||
mockSpinner = ora('') as unknown as { start: jest.Mock; succeed: jest.Mock; fail: jest.Mock; info: jest.Mock; };
|
||||
|
||||
// Register the command
|
||||
registerRecoverCommand(program);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockConsoleLog.mockClear();
|
||||
});
|
||||
|
||||
const mockStoppedSession: SessionConfig = {
|
||||
id: 'session1',
|
||||
repoFullName: 'user/repo1',
|
||||
containerId: 'container1',
|
||||
command: 'help me with this code',
|
||||
status: 'stopped',
|
||||
createdAt: '2025-06-01T10:00:00Z',
|
||||
updatedAt: '2025-06-01T10:05:00Z'
|
||||
};
|
||||
|
||||
const mockRunningSession: SessionConfig = {
|
||||
...mockStoppedSession,
|
||||
status: 'running'
|
||||
};
|
||||
|
||||
describe('recover command', () => {
|
||||
it('should recover a stopped session successfully', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockStoppedSession);
|
||||
mockRecoverSession.mockResolvedValue(true);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if recover was called
|
||||
expect(mockRecoverSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Recovered session'));
|
||||
|
||||
// Check that session details were printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Session details:'));
|
||||
});
|
||||
|
||||
it('should handle PR session details when recovering', async () => {
|
||||
// Setup mocks with PR session
|
||||
const prSession = {
|
||||
...mockStoppedSession,
|
||||
isPullRequest: true,
|
||||
prNumber: 42,
|
||||
branchName: 'feature/new-feature'
|
||||
};
|
||||
mockGetSession.mockReturnValue(prSession);
|
||||
mockRecoverSession.mockResolvedValue(true);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'session1']);
|
||||
|
||||
// Check for PR-specific details
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('PR:'));
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Branch:'));
|
||||
});
|
||||
|
||||
it('should handle Issue session details when recovering', async () => {
|
||||
// Setup mocks with Issue session
|
||||
const issueSession = {
|
||||
...mockStoppedSession,
|
||||
isIssue: true,
|
||||
issueNumber: 123
|
||||
};
|
||||
mockGetSession.mockReturnValue(issueSession);
|
||||
mockRecoverSession.mockResolvedValue(true);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'session1']);
|
||||
|
||||
// Check for Issue-specific details
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Issue:'));
|
||||
});
|
||||
|
||||
it('should fail when session does not exist', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(null);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'nonexistent']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('nonexistent');
|
||||
|
||||
// Should not try to recover
|
||||
expect(mockRecoverSession).not.toHaveBeenCalled();
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should not recover when session is not stopped', async () => {
|
||||
// Setup mocks with running session
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Should not try to recover
|
||||
expect(mockRecoverSession).not.toHaveBeenCalled();
|
||||
|
||||
// Check for info message
|
||||
expect(mockSpinner.info).toHaveBeenCalledWith(expect.stringContaining('not stopped'));
|
||||
});
|
||||
|
||||
it('should handle failed recovery', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockStoppedSession);
|
||||
mockRecoverSession.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'session1']);
|
||||
|
||||
// Check if session was retrieved and recover was attempted
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
expect(mockRecoverSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Failed to recover'));
|
||||
});
|
||||
|
||||
it('should handle errors during recovery', async () => {
|
||||
// Setup mocks to throw error
|
||||
mockGetSession.mockReturnValue(mockStoppedSession);
|
||||
mockRecoverSession.mockRejectedValue(new Error('Recovery failed'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'recover', 'session1']);
|
||||
|
||||
// Check for error message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Error recovering session'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync command', () => {
|
||||
it('should sync session statuses successfully', async () => {
|
||||
// Setup mocks
|
||||
mockSyncSessionStatuses.mockResolvedValue(true);
|
||||
mockListSessions.mockResolvedValue([
|
||||
mockRunningSession,
|
||||
{ ...mockStoppedSession, id: 'session2' }
|
||||
]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'sync']);
|
||||
|
||||
// Check if sync was called
|
||||
expect(mockSyncSessionStatuses).toHaveBeenCalled();
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Synchronized'));
|
||||
|
||||
// Check that session counts were printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Running sessions:'));
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Stopped sessions:'));
|
||||
});
|
||||
|
||||
it('should show recover help when stopped sessions exist', async () => {
|
||||
// Setup mocks with stopped sessions
|
||||
mockSyncSessionStatuses.mockResolvedValue(true);
|
||||
mockListSessions.mockResolvedValue([
|
||||
{ ...mockStoppedSession, id: 'session2' }
|
||||
]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'sync']);
|
||||
|
||||
// Check that recover help was printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('To recover a stopped session:'));
|
||||
});
|
||||
|
||||
it('should not show recover help when no stopped sessions exist', async () => {
|
||||
// Setup mocks with only running sessions
|
||||
mockSyncSessionStatuses.mockResolvedValue(true);
|
||||
mockListSessions.mockResolvedValue([mockRunningSession]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'sync']);
|
||||
|
||||
// Check that session counts were printed
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Running sessions: 1'));
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Stopped sessions: 0'));
|
||||
|
||||
// Recover help should not be printed
|
||||
expect(mockConsoleLog).not.toHaveBeenCalledWith(expect.stringContaining('To recover a stopped session:'));
|
||||
});
|
||||
|
||||
it('should handle errors during sync', async () => {
|
||||
// Setup mocks to throw error
|
||||
mockSyncSessionStatuses.mockRejectedValue(new Error('Sync failed'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'sync']);
|
||||
|
||||
// Check for error message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Error synchronizing sessions'));
|
||||
});
|
||||
});
|
||||
});
|
||||
283
cli/__tests__/commands/start-batch.test.ts
Normal file
283
cli/__tests__/commands/start-batch.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Command } from 'commander';
|
||||
import { registerStartBatchCommand } from '../../src/commands/start-batch';
|
||||
import * as startCommand from '../../src/commands/start';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('fs');
|
||||
jest.mock('yaml');
|
||||
jest.mock('ora', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
warn: jest.fn().mockReturnThis(),
|
||||
info: jest.fn().mockReturnThis(),
|
||||
text: '',
|
||||
};
|
||||
});
|
||||
});
|
||||
// Mock just the startSession function from start.ts
|
||||
jest.mock('../../src/commands/start', () => ({
|
||||
registerStartCommand: jest.requireActual('../../src/commands/start').registerStartCommand,
|
||||
startSession: jest.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
// Get the mocked function with correct typing
|
||||
const mockedStartSession = startCommand.startSession as jest.Mock;
|
||||
|
||||
// Mock console.log to prevent output during tests
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
describe('start-batch command', () => {
|
||||
// Test command and mocks
|
||||
let program: Command;
|
||||
|
||||
// Command execution helpers
|
||||
let parseArgs: (args: string[]) => Promise<void>;
|
||||
|
||||
// Mock file content
|
||||
const mockBatchTasksYaml = [
|
||||
{
|
||||
repo: 'owner/repo1',
|
||||
command: 'task 1 command',
|
||||
issue: 42
|
||||
},
|
||||
{
|
||||
repo: 'owner/repo2',
|
||||
command: 'task 2 command',
|
||||
pr: 123,
|
||||
branch: 'feature-branch'
|
||||
},
|
||||
{
|
||||
repo: 'owner/repo3',
|
||||
command: 'task 3 command',
|
||||
resourceLimits: {
|
||||
memory: '4g',
|
||||
cpuShares: '2048',
|
||||
pidsLimit: '512'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset console mocks
|
||||
console.log = jest.fn();
|
||||
console.error = jest.fn();
|
||||
|
||||
// Reset program for each test
|
||||
program = new Command();
|
||||
|
||||
// Register the command
|
||||
registerStartBatchCommand(program);
|
||||
|
||||
// Create parse helper
|
||||
parseArgs = async (args: string[]): Promise<void> => {
|
||||
try {
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
} catch (e) {
|
||||
// Swallow commander errors
|
||||
}
|
||||
};
|
||||
|
||||
// Mock fs functions
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as jest.Mock).mockReturnValue('mock yaml content');
|
||||
|
||||
// Mock yaml.parse
|
||||
const yaml = require('yaml');
|
||||
yaml.parse.mockReturnValue(mockBatchTasksYaml);
|
||||
|
||||
// startSession is already mocked in the jest.mock call
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load tasks from a YAML file', async () => {
|
||||
await parseArgs(['start-batch', 'tasks.yaml']);
|
||||
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('tasks.yaml');
|
||||
expect(fs.readFileSync).toHaveBeenCalled();
|
||||
expect(require('yaml').parse).toHaveBeenCalledWith('mock yaml content');
|
||||
});
|
||||
|
||||
it('should fail if the file does not exist', async () => {
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await parseArgs(['start-batch', 'nonexistent.yaml']);
|
||||
|
||||
expect(fs.readFileSync).not.toHaveBeenCalled();
|
||||
expect(startCommand.startSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if the file contains no valid tasks', async () => {
|
||||
const yaml = require('yaml');
|
||||
yaml.parse.mockReturnValue([]);
|
||||
|
||||
await parseArgs(['start-batch', 'empty.yaml']);
|
||||
|
||||
expect(startCommand.startSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute tasks sequentially by default', async () => {
|
||||
await parseArgs(['start-batch', 'tasks.yaml']);
|
||||
|
||||
// Should call startSession for each task in sequence
|
||||
expect(startCommand.startSession).toHaveBeenCalledTimes(3);
|
||||
|
||||
// First call should be for the first task
|
||||
expect(startCommand.startSession).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'owner/repo1',
|
||||
'task 1 command',
|
||||
expect.objectContaining({ issue: '42' })
|
||||
);
|
||||
|
||||
// Second call should be for the second task
|
||||
expect(startCommand.startSession).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'owner/repo2',
|
||||
'task 2 command',
|
||||
expect.objectContaining({
|
||||
pr: 123,
|
||||
branch: 'feature-branch'
|
||||
})
|
||||
);
|
||||
|
||||
// Third call should be for the third task
|
||||
expect(startCommand.startSession).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'owner/repo3',
|
||||
'task 3 command',
|
||||
expect.objectContaining({
|
||||
memory: '4g',
|
||||
cpu: '2048',
|
||||
pids: '512'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute tasks in parallel when specified', async () => {
|
||||
// Reset mocks before this test
|
||||
mockedStartSession.mockReset();
|
||||
mockedStartSession.mockResolvedValue(undefined);
|
||||
|
||||
// Mock implementation for Promise.all to ensure it's called
|
||||
const originalPromiseAll = Promise.all;
|
||||
Promise.all = jest.fn().mockImplementation((promises) => {
|
||||
return originalPromiseAll(promises);
|
||||
});
|
||||
|
||||
await parseArgs(['start-batch', 'tasks.yaml', '--parallel']);
|
||||
|
||||
// Should call Promise.all to run tasks in parallel
|
||||
expect(Promise.all).toHaveBeenCalled();
|
||||
|
||||
// Restore original Promise.all
|
||||
Promise.all = originalPromiseAll;
|
||||
|
||||
// Should still call startSession for each task (wait for async)
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(startCommand.startSession).toHaveBeenCalled();
|
||||
// We won't check the exact number of calls due to async nature
|
||||
});
|
||||
|
||||
it('should respect maxConcurrent parameter', async () => {
|
||||
// Reset mocks before this test
|
||||
mockedStartSession.mockReset();
|
||||
mockedStartSession.mockResolvedValue(undefined);
|
||||
|
||||
// Set up a larger batch of tasks
|
||||
const largerBatch = Array(7).fill(null).map((_, i) => ({
|
||||
repo: `owner/repo${i+1}`,
|
||||
command: `task ${i+1} command`
|
||||
}));
|
||||
|
||||
const yaml = require('yaml');
|
||||
yaml.parse.mockReturnValue(largerBatch);
|
||||
|
||||
// Mock implementation for Promise.all to count calls
|
||||
const originalPromiseAll = Promise.all;
|
||||
let promiseAllCalls = 0;
|
||||
Promise.all = jest.fn().mockImplementation((promises) => {
|
||||
promiseAllCalls++;
|
||||
return originalPromiseAll(promises);
|
||||
});
|
||||
|
||||
await parseArgs(['start-batch', 'tasks.yaml', '--parallel', '--concurrent', '3']);
|
||||
|
||||
// Validate Promise.all was called
|
||||
expect(Promise.all).toHaveBeenCalled();
|
||||
|
||||
// Restore original Promise.all
|
||||
Promise.all = originalPromiseAll;
|
||||
|
||||
// Should call startSession
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(startCommand.startSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle PR flag as boolean', async () => {
|
||||
// Update mock to include boolean PR flag
|
||||
const booleanPrTask = [
|
||||
{
|
||||
repo: 'owner/repo1',
|
||||
command: 'task with boolean PR',
|
||||
pr: true
|
||||
}
|
||||
];
|
||||
|
||||
const yaml = require('yaml');
|
||||
yaml.parse.mockReturnValue(booleanPrTask);
|
||||
|
||||
await parseArgs(['start-batch', 'tasks.yaml']);
|
||||
|
||||
expect(startCommand.startSession).toHaveBeenCalledWith(
|
||||
'owner/repo1',
|
||||
'task with boolean PR',
|
||||
expect.objectContaining({ pr: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate maxConcurrent parameter', async () => {
|
||||
await parseArgs(['start-batch', 'tasks.yaml', '--parallel', '--concurrent', 'invalid']);
|
||||
|
||||
// Should fail and not start any tasks
|
||||
expect(startCommand.startSession).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--concurrent must be a positive number')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in individual tasks', async () => {
|
||||
// Make the second task fail
|
||||
mockedStartSession.mockImplementation((repo: string) => {
|
||||
if (repo === 'owner/repo2') {
|
||||
throw new Error('Task failed');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await parseArgs(['start-batch', 'tasks.yaml']);
|
||||
|
||||
// Should still complete other tasks
|
||||
expect(startCommand.startSession).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Should log the error
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error running task for owner/repo2'),
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
301
cli/__tests__/commands/start.test.ts
Normal file
301
cli/__tests__/commands/start.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerStartCommand } from '../../src/commands/start';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
|
||||
// Mock the utilities
|
||||
jest.mock('../../src/utils/sessionManager');
|
||||
jest.mock('../../src/utils/dockerUtils');
|
||||
jest.mock('ora', () => {
|
||||
return jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
warn: jest.fn().mockReturnThis(),
|
||||
info: jest.fn().mockReturnThis(),
|
||||
text: '',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Mock console.log to prevent output during tests
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
describe('start command', () => {
|
||||
// Test command and mocks
|
||||
let program: Command;
|
||||
let mockSessionManager: jest.Mocked<SessionManager>;
|
||||
let mockDockerUtils: jest.Mocked<DockerUtils>;
|
||||
|
||||
// Command execution helpers
|
||||
let parseArgs: (args: string[]) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset console mocks
|
||||
console.log = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
|
||||
// Reset program for each test
|
||||
program = new Command();
|
||||
|
||||
// Register the command
|
||||
registerStartCommand(program);
|
||||
|
||||
// Create parse helper
|
||||
parseArgs = async (args: string[]): Promise<void> => {
|
||||
try {
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
} catch (e) {
|
||||
// Swallow commander errors
|
||||
}
|
||||
};
|
||||
|
||||
// Get the mock instances
|
||||
mockSessionManager = SessionManager.prototype as jest.Mocked<SessionManager>;
|
||||
mockDockerUtils = DockerUtils.prototype as jest.Mocked<DockerUtils>;
|
||||
|
||||
// Setup default mock behaviors
|
||||
mockSessionManager.generateSessionId.mockReturnValue('test-session-id');
|
||||
mockSessionManager.createSession.mockImplementation((session) => {
|
||||
return {
|
||||
...session,
|
||||
id: 'test-session-id',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
mockDockerUtils.isDockerAvailable.mockResolvedValue(true);
|
||||
mockDockerUtils.ensureImageExists.mockResolvedValue(true);
|
||||
mockDockerUtils.startContainer.mockResolvedValue('test-container-id');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should start a session for a repository', async () => {
|
||||
// Execute the command
|
||||
await parseArgs(['start', 'owner/repo', 'analyze this code']);
|
||||
|
||||
// Verify the Docker container was started
|
||||
expect(mockDockerUtils.isDockerAvailable).toHaveBeenCalled();
|
||||
expect(mockDockerUtils.ensureImageExists).toHaveBeenCalled();
|
||||
expect(mockDockerUtils.startContainer).toHaveBeenCalledWith(
|
||||
'claude-hub-test-session-id',
|
||||
expect.objectContaining({
|
||||
REPO_FULL_NAME: 'owner/repo',
|
||||
IS_PULL_REQUEST: 'false',
|
||||
IS_ISSUE: 'false',
|
||||
COMMAND: expect.stringContaining('analyze this code')
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
// Verify the session was created
|
||||
expect(mockSessionManager.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoFullName: 'owner/repo',
|
||||
containerId: 'test-container-id',
|
||||
command: 'analyze this code',
|
||||
status: 'running'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should add default owner when repo format is simple', async () => {
|
||||
// Save original env
|
||||
const originalEnv = process.env.DEFAULT_GITHUB_OWNER;
|
||||
// Set env for test
|
||||
process.env.DEFAULT_GITHUB_OWNER = 'default-owner';
|
||||
|
||||
// Execute the command
|
||||
await parseArgs(['start', 'repo', 'analyze this code']);
|
||||
|
||||
// Verify the correct repository name was used
|
||||
expect(mockDockerUtils.startContainer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
REPO_FULL_NAME: 'default-owner/repo'
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
// Restore original env
|
||||
process.env.DEFAULT_GITHUB_OWNER = originalEnv;
|
||||
});
|
||||
|
||||
it('should handle pull request context', async () => {
|
||||
// Execute the command with PR option
|
||||
await parseArgs(['start', 'owner/repo', 'review this PR', '--pr', '42', '--branch', 'feature-branch']);
|
||||
|
||||
// Verify PR context was set
|
||||
expect(mockDockerUtils.startContainer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
REPO_FULL_NAME: 'owner/repo',
|
||||
IS_PULL_REQUEST: 'true',
|
||||
IS_ISSUE: 'false',
|
||||
ISSUE_NUMBER: '42',
|
||||
BRANCH_NAME: 'feature-branch',
|
||||
COMMAND: expect.stringContaining('pull request')
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
// Verify the session was created with PR context
|
||||
expect(mockSessionManager.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isPullRequest: true,
|
||||
isIssue: false,
|
||||
prNumber: 42,
|
||||
branchName: 'feature-branch'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle issue context', async () => {
|
||||
// Execute the command with issue option
|
||||
await parseArgs(['start', 'owner/repo', 'fix this issue', '--issue', '123']);
|
||||
|
||||
// Verify issue context was set
|
||||
expect(mockDockerUtils.startContainer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
REPO_FULL_NAME: 'owner/repo',
|
||||
IS_PULL_REQUEST: 'false',
|
||||
IS_ISSUE: 'true',
|
||||
ISSUE_NUMBER: '123',
|
||||
COMMAND: expect.stringContaining('issue')
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
// Verify the session was created with issue context
|
||||
expect(mockSessionManager.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isPullRequest: false,
|
||||
isIssue: true,
|
||||
issueNumber: 123
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply resource limits', async () => {
|
||||
// Execute the command with resource limits
|
||||
await parseArgs([
|
||||
'start', 'owner/repo', 'analyze this code',
|
||||
'--memory', '4g',
|
||||
'--cpu', '2048',
|
||||
'--pids', '512'
|
||||
]);
|
||||
|
||||
// Verify resource limits were passed
|
||||
expect(mockDockerUtils.startContainer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
{
|
||||
memory: '4g',
|
||||
cpuShares: '2048',
|
||||
pidsLimit: '512'
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the session was created with resource limits
|
||||
expect(mockSessionManager.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceLimits: {
|
||||
memory: '4g',
|
||||
cpuShares: '2048',
|
||||
pidsLimit: '512'
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when Docker is not available', async () => {
|
||||
// Mock Docker not available
|
||||
mockDockerUtils.isDockerAvailable.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await parseArgs(['start', 'owner/repo', 'analyze this code']);
|
||||
|
||||
// Verify Docker availability was checked
|
||||
expect(mockDockerUtils.isDockerAvailable).toHaveBeenCalled();
|
||||
|
||||
// Verify the container was not started
|
||||
expect(mockDockerUtils.startContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Verify no session was created
|
||||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail when Docker image cannot be ensured', async () => {
|
||||
// Mock Docker image not available
|
||||
mockDockerUtils.ensureImageExists.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await parseArgs(['start', 'owner/repo', 'analyze this code']);
|
||||
|
||||
// Verify Docker image check was attempted
|
||||
expect(mockDockerUtils.ensureImageExists).toHaveBeenCalled();
|
||||
|
||||
// Verify the container was not started
|
||||
expect(mockDockerUtils.startContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Verify no session was created
|
||||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail when both PR and issue options are specified', async () => {
|
||||
// Execute the command with conflicting options
|
||||
await parseArgs(['start', 'owner/repo', 'conflicting context', '--pr', '42', '--issue', '123']);
|
||||
|
||||
// Verify Docker checks were not performed
|
||||
expect(mockDockerUtils.isDockerAvailable).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the container was not started
|
||||
expect(mockDockerUtils.startContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Verify no session was created
|
||||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should warn when branch is specified without PR context', async () => {
|
||||
// Execute the command with branch but no PR
|
||||
await parseArgs(['start', 'owner/repo', 'analyze this code', '--branch', 'feature-branch']);
|
||||
|
||||
// Verify the session was created anyway
|
||||
expect(mockSessionManager.createSession).toHaveBeenCalled();
|
||||
|
||||
// Verify the branch was ignored (not set in PR context)
|
||||
expect(mockSessionManager.createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isPullRequest: false,
|
||||
branchName: 'feature-branch'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle container start failure', async () => {
|
||||
// Mock container start failure
|
||||
mockDockerUtils.startContainer.mockResolvedValue(null);
|
||||
|
||||
// Execute the command
|
||||
await parseArgs(['start', 'owner/repo', 'analyze this code']);
|
||||
|
||||
// Verify Docker container start was attempted
|
||||
expect(mockDockerUtils.startContainer).toHaveBeenCalled();
|
||||
|
||||
// Verify no session was created
|
||||
expect(mockSessionManager.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
389
cli/__tests__/commands/stop.test.ts
Normal file
389
cli/__tests__/commands/stop.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerStopCommand } from '../../src/commands/stop';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
import { SessionConfig } from '../../src/types/session';
|
||||
import ora from 'ora';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../src/utils/sessionManager');
|
||||
jest.mock('../../src/utils/dockerUtils');
|
||||
jest.mock('ora', () => {
|
||||
const mockSpinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
info: jest.fn().mockReturnThis(),
|
||||
warn: jest.fn().mockReturnThis(),
|
||||
text: ''
|
||||
};
|
||||
return jest.fn(() => mockSpinner);
|
||||
});
|
||||
|
||||
// Mock console methods
|
||||
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
describe('Stop Command', () => {
|
||||
let program: Command;
|
||||
let mockGetSession: jest.Mock;
|
||||
let mockUpdateSessionStatus: jest.Mock;
|
||||
let mockDeleteSession: jest.Mock;
|
||||
let mockListSessions: jest.Mock;
|
||||
let mockIsContainerRunning: jest.Mock;
|
||||
let mockStopContainer: jest.Mock;
|
||||
let mockSpinner: {
|
||||
start: jest.Mock;
|
||||
succeed: jest.Mock;
|
||||
fail: jest.Mock;
|
||||
info: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup program
|
||||
program = new Command();
|
||||
|
||||
// Setup SessionManager mock
|
||||
mockGetSession = jest.fn();
|
||||
mockUpdateSessionStatus = jest.fn();
|
||||
mockDeleteSession = jest.fn();
|
||||
mockListSessions = jest.fn();
|
||||
(SessionManager as jest.Mock).mockImplementation(() => ({
|
||||
getSession: mockGetSession,
|
||||
updateSessionStatus: mockUpdateSessionStatus,
|
||||
deleteSession: mockDeleteSession,
|
||||
listSessions: mockListSessions
|
||||
}));
|
||||
|
||||
// Setup DockerUtils mock
|
||||
mockIsContainerRunning = jest.fn();
|
||||
mockStopContainer = jest.fn();
|
||||
(DockerUtils as jest.Mock).mockImplementation(() => ({
|
||||
isContainerRunning: mockIsContainerRunning,
|
||||
stopContainer: mockStopContainer
|
||||
}));
|
||||
|
||||
// Setup ora spinner mock
|
||||
mockSpinner = ora('') as unknown as {
|
||||
start: jest.Mock;
|
||||
succeed: jest.Mock;
|
||||
fail: jest.Mock;
|
||||
info: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
};
|
||||
|
||||
// Register the command
|
||||
registerStopCommand(program);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockConsoleLog.mockClear();
|
||||
});
|
||||
|
||||
const mockRunningSession: SessionConfig = {
|
||||
id: 'session1',
|
||||
repoFullName: 'user/repo1',
|
||||
containerId: 'container1',
|
||||
command: 'help me with this code',
|
||||
status: 'running',
|
||||
createdAt: '2025-06-01T10:00:00Z',
|
||||
updatedAt: '2025-06-01T10:05:00Z'
|
||||
};
|
||||
|
||||
const mockStoppedSession: SessionConfig = {
|
||||
...mockRunningSession,
|
||||
status: 'stopped'
|
||||
};
|
||||
|
||||
describe('stop single session', () => {
|
||||
it('should stop a running session', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockStopContainer.mockResolvedValue(true);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Check if container was stopped
|
||||
expect(mockStopContainer).toHaveBeenCalledWith('container1', undefined);
|
||||
|
||||
// Check if session status was updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledWith('session1', 'stopped');
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('stopped'));
|
||||
});
|
||||
|
||||
it('should use force option when provided', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockStopContainer.mockResolvedValue(true);
|
||||
|
||||
// Execute the command with force option
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1', '--force']);
|
||||
|
||||
// Check if container was force stopped
|
||||
expect(mockStopContainer).toHaveBeenCalledWith('container1', true);
|
||||
});
|
||||
|
||||
it('should remove session when --remove option is provided', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockStopContainer.mockResolvedValue(true);
|
||||
|
||||
// Execute the command with remove option
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1', '--remove']);
|
||||
|
||||
// Check if container was stopped
|
||||
expect(mockStopContainer).toHaveBeenCalledWith('container1', undefined);
|
||||
|
||||
// Check if session was updated and then deleted
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledWith('session1', 'stopped');
|
||||
expect(mockDeleteSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('stopped and removed'));
|
||||
});
|
||||
|
||||
it('should fail when session does not exist', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(null);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'nonexistent']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('nonexistent');
|
||||
|
||||
// Should not try to check or stop container
|
||||
expect(mockIsContainerRunning).not.toHaveBeenCalled();
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should handle already stopped sessions correctly', async () => {
|
||||
// Setup mocks with already stopped session
|
||||
mockGetSession.mockReturnValue(mockStoppedSession);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Should not try to stop container that's not running
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Session status should not be updated since it's already stopped
|
||||
expect(mockUpdateSessionStatus).not.toHaveBeenCalled();
|
||||
|
||||
// Check for info message
|
||||
expect(mockSpinner.info).toHaveBeenCalledWith(expect.stringContaining('already stopped'));
|
||||
});
|
||||
|
||||
it('should update session status if marked as running but container is not running', async () => {
|
||||
// Setup mocks with session marked as running but container not running
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1']);
|
||||
|
||||
// Check if session was retrieved
|
||||
expect(mockGetSession).toHaveBeenCalledWith('session1');
|
||||
|
||||
// Check if container running status was checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Should not try to stop container that's not running
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Session status should be updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledWith('session1', 'stopped');
|
||||
|
||||
// Check for info message
|
||||
expect(mockSpinner.info).toHaveBeenCalledWith(expect.stringContaining('already stopped, updated status'));
|
||||
});
|
||||
|
||||
it('should handle failure to stop container', async () => {
|
||||
// Setup mocks
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockStopContainer.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1']);
|
||||
|
||||
// Check if container was attempted to be stopped
|
||||
expect(mockStopContainer).toHaveBeenCalledWith('container1', undefined);
|
||||
|
||||
// Session status should not be updated
|
||||
expect(mockUpdateSessionStatus).not.toHaveBeenCalled();
|
||||
|
||||
// Check for failure message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Failed to stop container'));
|
||||
});
|
||||
|
||||
it('should handle errors during stop operation', async () => {
|
||||
// Setup mocks to throw error
|
||||
mockGetSession.mockReturnValue(mockRunningSession);
|
||||
mockIsContainerRunning.mockRejectedValue(new Error('Docker error'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'session1']);
|
||||
|
||||
// Check for error message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Failed to stop session'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop all sessions', () => {
|
||||
it('should stop all running sessions', async () => {
|
||||
// Setup mocks with multiple running sessions
|
||||
const sessions = [
|
||||
mockRunningSession,
|
||||
{ ...mockRunningSession, id: 'session2', containerId: 'container2' }
|
||||
];
|
||||
mockListSessions.mockResolvedValue(sessions);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockStopContainer.mockResolvedValue(true);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'all']);
|
||||
|
||||
// Check if sessions were listed
|
||||
expect(mockListSessions).toHaveBeenCalledWith({ status: 'running' });
|
||||
|
||||
// Check if containers were checked and stopped
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledTimes(2);
|
||||
expect(mockStopContainer).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check if all session statuses were updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Stopped all 2 running sessions'));
|
||||
});
|
||||
|
||||
it('should handle when no running sessions exist', async () => {
|
||||
// Setup mocks with no running sessions
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'all']);
|
||||
|
||||
// Check if sessions were listed
|
||||
expect(mockListSessions).toHaveBeenCalledWith({ status: 'running' });
|
||||
|
||||
// Should not try to check or stop any containers
|
||||
expect(mockIsContainerRunning).not.toHaveBeenCalled();
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Check for info message
|
||||
expect(mockSpinner.info).toHaveBeenCalledWith('No running sessions found.');
|
||||
});
|
||||
|
||||
it('should remove all sessions when --remove option is provided', async () => {
|
||||
// Setup mocks
|
||||
const sessions = [
|
||||
mockRunningSession,
|
||||
{ ...mockRunningSession, id: 'session2', containerId: 'container2' }
|
||||
];
|
||||
mockListSessions.mockResolvedValue(sessions);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
mockStopContainer.mockResolvedValue(true);
|
||||
|
||||
// Execute the command with remove option
|
||||
await program.parseAsync(['node', 'test', 'stop', 'all', '--remove']);
|
||||
|
||||
// Check if all sessions were deleted
|
||||
expect(mockDeleteSession).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check for note about removal
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Note:'));
|
||||
});
|
||||
|
||||
it('should handle partial failures when stopping multiple sessions', async () => {
|
||||
// Setup mocks with one success and one failure
|
||||
const sessions = [
|
||||
mockRunningSession,
|
||||
{ ...mockRunningSession, id: 'session2', containerId: 'container2' }
|
||||
];
|
||||
mockListSessions.mockResolvedValue(sessions);
|
||||
mockIsContainerRunning.mockResolvedValue(true);
|
||||
|
||||
// First container stops successfully, second fails
|
||||
mockStopContainer
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'all']);
|
||||
|
||||
// Check if all containers were checked
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check if all containers were attempted to be stopped
|
||||
expect(mockStopContainer).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Only one session status should be updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check for warning message
|
||||
expect(mockSpinner.warn).toHaveBeenCalledWith(expect.stringContaining('Stopped 1 sessions, failed to stop 1 sessions'));
|
||||
});
|
||||
|
||||
it('should update status for sessions marked as running but with non-running containers', async () => {
|
||||
// Setup mocks
|
||||
const sessions = [mockRunningSession];
|
||||
mockListSessions.mockResolvedValue(sessions);
|
||||
mockIsContainerRunning.mockResolvedValue(false);
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'all']);
|
||||
|
||||
// Check if session was listed and container status was checked
|
||||
expect(mockListSessions).toHaveBeenCalledWith({ status: 'running' });
|
||||
expect(mockIsContainerRunning).toHaveBeenCalledWith('container1');
|
||||
|
||||
// Should not try to stop container that's not running
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
|
||||
// Session status should be updated
|
||||
expect(mockUpdateSessionStatus).toHaveBeenCalledWith('session1', 'stopped');
|
||||
|
||||
// Check for success message
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Stopped all 1 running sessions'));
|
||||
});
|
||||
|
||||
it('should handle errors during stop all operation', async () => {
|
||||
// Setup mocks to throw error
|
||||
mockListSessions.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Execute the command
|
||||
await program.parseAsync(['node', 'test', 'stop', 'all']);
|
||||
|
||||
// Check for error message
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(expect.stringContaining('Failed to stop sessions'));
|
||||
});
|
||||
});
|
||||
});
|
||||
40
cli/__tests__/fixtures/batch-tasks.yaml
Normal file
40
cli/__tests__/fixtures/batch-tasks.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Sample batch tasks file for testing the start-batch command
|
||||
# Each item in this list represents a task to be executed by Claude
|
||||
|
||||
# Task with issue context
|
||||
- repo: claude-did-this/demo-repository
|
||||
command: >
|
||||
Analyze issue #42 and suggest possible solutions.
|
||||
Check if there are any similar patterns in the codebase.
|
||||
issue: 42
|
||||
|
||||
# Task with PR context and branch
|
||||
- repo: claude-did-this/demo-repository
|
||||
command: >
|
||||
Review this PR and provide detailed feedback.
|
||||
Focus on code quality, performance, and security.
|
||||
pr: 123
|
||||
branch: feature/new-api
|
||||
|
||||
# Simple repository task
|
||||
- repo: claude-did-this/demo-repository
|
||||
command: >
|
||||
Generate a new utility function for string formatting
|
||||
that handles multi-line text with proper indentation.
|
||||
|
||||
# Task with resource limits
|
||||
- repo: claude-did-this/large-repo
|
||||
command: >
|
||||
Perform a comprehensive security audit of the authentication module.
|
||||
Look for potential vulnerabilities in the token handling code.
|
||||
resourceLimits:
|
||||
memory: 4g
|
||||
cpuShares: 2048
|
||||
pidsLimit: 512
|
||||
|
||||
# Boolean PR flag
|
||||
- repo: claude-did-this/demo-repository
|
||||
command: >
|
||||
Create a new feature branch and implement a dark mode toggle
|
||||
for the application settings page.
|
||||
pr: true
|
||||
39
cli/__tests__/setup.ts
Normal file
39
cli/__tests__/setup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Global test setup
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
// Define test home directory path
|
||||
const TEST_HOME_DIR = path.join(os.tmpdir(), 'claude-hub-test-home');
|
||||
|
||||
// Mock the HOME directory for testing
|
||||
process.env.HOME = TEST_HOME_DIR;
|
||||
|
||||
// Create temp directories for testing
|
||||
beforeAll(() => {
|
||||
// Create temp test home directory
|
||||
if (!fs.existsSync(TEST_HOME_DIR)) {
|
||||
fs.mkdirSync(TEST_HOME_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Create sessions directory
|
||||
const sessionsDir = path.join(TEST_HOME_DIR, '.claude-hub', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
});
|
||||
|
||||
// Clean up after tests
|
||||
afterAll(() => {
|
||||
// Optional: Remove temp directories after tests
|
||||
// Uncomment if you want to clean up after tests
|
||||
// fs.rmSync(TEST_HOME_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Mock console.log to prevent noise during tests
|
||||
global.console = {
|
||||
...console,
|
||||
// Uncomment to silence logs during tests
|
||||
// log: jest.fn(),
|
||||
// info: jest.fn(),
|
||||
// warn: jest.fn(),
|
||||
error: console.error, // Keep error logs visible
|
||||
};
|
||||
137
cli/__tests__/utils/dockerUtils.simple.test.ts
Normal file
137
cli/__tests__/utils/dockerUtils.simple.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
import { promisify } from 'util';
|
||||
|
||||
// Mock the child_process module
|
||||
jest.mock('child_process', () => ({
|
||||
exec: jest.fn(),
|
||||
execFile: jest.fn(),
|
||||
spawn: jest.fn(() => ({
|
||||
stdout: { pipe: jest.fn() },
|
||||
stderr: { pipe: jest.fn() },
|
||||
on: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock promisify to return our mocked exec/execFile functions
|
||||
jest.mock('util', () => ({
|
||||
promisify: jest.fn((fn) => fn)
|
||||
}));
|
||||
|
||||
describe('DockerUtils - Simple Tests', () => {
|
||||
let dockerUtils: DockerUtils;
|
||||
const mockExec = require('child_process').exec;
|
||||
const mockExecFile = require('child_process').execFile;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup mock implementations
|
||||
mockExec.mockImplementation((command: string, callback?: (error: Error | null, result: {stdout: string, stderr: string}) => void) => {
|
||||
if (callback) callback(null, { stdout: 'Mock exec output', stderr: '' });
|
||||
return Promise.resolve({ stdout: 'Mock exec output', stderr: '' });
|
||||
});
|
||||
|
||||
mockExecFile.mockImplementation((file: string, args: string[], options?: any, callback?: (error: Error | null, result: {stdout: string, stderr: string}) => void) => {
|
||||
if (callback) callback(null, { stdout: 'Mock execFile output', stderr: '' });
|
||||
return Promise.resolve({ stdout: 'Mock execFile output', stderr: '' });
|
||||
});
|
||||
|
||||
// Create a new instance for each test
|
||||
dockerUtils = new DockerUtils();
|
||||
});
|
||||
|
||||
describe('isDockerAvailable', () => {
|
||||
it('should check if Docker is available', async () => {
|
||||
mockExec.mockResolvedValueOnce({ stdout: 'Docker version 20.10.7', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.isDockerAvailable();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith('docker --version');
|
||||
});
|
||||
|
||||
it('should return false if Docker is not available', async () => {
|
||||
mockExec.mockRejectedValueOnce(new Error('Docker not found'));
|
||||
|
||||
const result = await dockerUtils.isDockerAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockExec).toHaveBeenCalledWith('docker --version');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doesImageExist', () => {
|
||||
it('should check if the Docker image exists', async () => {
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: 'Image exists', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.doesImageExist();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockExecFile).toHaveBeenCalledWith('docker', ['inspect', expect.any(String)]);
|
||||
});
|
||||
|
||||
it('should return false if the Docker image does not exist', async () => {
|
||||
mockExecFile.mockRejectedValueOnce(new Error('No such image'));
|
||||
|
||||
const result = await dockerUtils.doesImageExist();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockExecFile).toHaveBeenCalledWith('docker', ['inspect', expect.any(String)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startContainer', () => {
|
||||
it('should start a Docker container', async () => {
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: 'container-id', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.startContainer(
|
||||
'test-container',
|
||||
{ REPO_FULL_NAME: 'owner/repo', COMMAND: 'test command' }
|
||||
);
|
||||
|
||||
expect(result).toBe('container-id');
|
||||
expect(mockExecFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null if container start fails', async () => {
|
||||
mockExecFile.mockRejectedValueOnce(new Error('Failed to start container'));
|
||||
|
||||
const result = await dockerUtils.startContainer(
|
||||
'test-container',
|
||||
{ REPO_FULL_NAME: 'owner/repo', COMMAND: 'test command' }
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockExecFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopContainer', () => {
|
||||
it('should stop a container', async () => {
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.stopContainer('container-id');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockExecFile).toHaveBeenCalledWith('docker', ['stop', 'container-id']);
|
||||
});
|
||||
|
||||
it('should kill a container when force is true', async () => {
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.stopContainer('container-id', true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockExecFile).toHaveBeenCalledWith('docker', ['kill', 'container-id']);
|
||||
});
|
||||
|
||||
it('should return false if container stop fails', async () => {
|
||||
mockExecFile.mockRejectedValueOnce(new Error('Failed to stop container'));
|
||||
|
||||
const result = await dockerUtils.stopContainer('container-id');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockExecFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
417
cli/__tests__/utils/dockerUtils.test.ts
Normal file
417
cli/__tests__/utils/dockerUtils.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
import { ResourceLimits } from '../../src/types/session';
|
||||
import { exec, execFile } from 'child_process';
|
||||
|
||||
// Mock child_process
|
||||
jest.mock('child_process', () => ({
|
||||
exec: jest.fn(),
|
||||
execFile: jest.fn(),
|
||||
spawn: jest.fn().mockReturnValue({
|
||||
stdout: { pipe: jest.fn() },
|
||||
stderr: { pipe: jest.fn() },
|
||||
on: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
// Type for mocked exec function
|
||||
type MockedExec = {
|
||||
mockImplementation: (fn: (...args: any[]) => any) => void;
|
||||
mockResolvedValue: (value: any) => void;
|
||||
mockRejectedValue: (value: any) => void;
|
||||
};
|
||||
|
||||
// Type for mocked execFile function
|
||||
type MockedExecFile = {
|
||||
mockImplementation: (fn: (...args: any[]) => any) => void;
|
||||
mockResolvedValue: (value: any) => void;
|
||||
mockRejectedValue: (value: any) => void;
|
||||
};
|
||||
|
||||
describe('DockerUtils', () => {
|
||||
let dockerUtils: DockerUtils;
|
||||
|
||||
// Mocks
|
||||
const mockedExec = exec as unknown as MockedExec;
|
||||
const mockedExecFile = execFile as unknown as MockedExecFile;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset environment variables
|
||||
delete process.env.CLAUDE_CONTAINER_IMAGE;
|
||||
delete process.env.CLAUDE_AUTH_HOST_DIR;
|
||||
|
||||
// Keep HOME from setup.ts
|
||||
|
||||
// Create fresh instance for each test
|
||||
dockerUtils = new DockerUtils();
|
||||
|
||||
// Default mock implementation for exec
|
||||
mockedExec.mockImplementation((command, callback) => {
|
||||
if (callback) {
|
||||
callback(null, { stdout: 'success', stderr: '' });
|
||||
}
|
||||
return { stdout: 'success', stderr: '' };
|
||||
});
|
||||
|
||||
// Default mock implementation for execFile
|
||||
mockedExecFile.mockImplementation((file, args, options, callback) => {
|
||||
if (callback) {
|
||||
callback(null, { stdout: 'success', stderr: '' });
|
||||
}
|
||||
return { stdout: 'success', stderr: '' };
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDockerAvailable', () => {
|
||||
it('should return true when Docker is available', async () => {
|
||||
mockedExec.mockResolvedValue({ stdout: 'Docker version 20.10.7', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.isDockerAvailable();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith('docker --version');
|
||||
});
|
||||
|
||||
it('should return false when Docker is not available', async () => {
|
||||
mockedExec.mockRejectedValue(new Error('Command failed'));
|
||||
|
||||
const result = await dockerUtils.isDockerAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(exec).toHaveBeenCalledWith('docker --version');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doesImageExist', () => {
|
||||
it('should return true when the image exists', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'Image details', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.doesImageExist();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['inspect', 'claudecode:latest']
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when the image does not exist', async () => {
|
||||
mockedExecFile.mockRejectedValue(new Error('No such image'));
|
||||
|
||||
const result = await dockerUtils.doesImageExist();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should use custom image name from environment', async () => {
|
||||
process.env.CLAUDE_CONTAINER_IMAGE = 'custom-image:latest';
|
||||
|
||||
// Create a new instance with updated env vars
|
||||
dockerUtils = new DockerUtils();
|
||||
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'Image details', stderr: '' });
|
||||
|
||||
await dockerUtils.doesImageExist();
|
||||
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['inspect', 'custom-image:latest'],
|
||||
{ stdio: 'ignore' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureImageExists', () => {
|
||||
it('should return true when the image already exists', async () => {
|
||||
// Mock doesImageExist to return true
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'Image details', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.ensureImageExists();
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not try to build the image
|
||||
expect(execFile).not.toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['build', '-f', 'Dockerfile.claudecode', '-t', 'claudecode:latest', '.'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should build the image when it does not exist', async () => {
|
||||
// First call to execFile (doesImageExist) fails
|
||||
// Second call to execFile (build) succeeds
|
||||
mockedExecFile.mockImplementation((file, args, options, callback) => {
|
||||
if (args[0] === 'inspect') {
|
||||
throw new Error('No such image');
|
||||
}
|
||||
if (callback) {
|
||||
callback(null, { stdout: 'Built image', stderr: '' });
|
||||
}
|
||||
return { stdout: 'Built image', stderr: '' };
|
||||
});
|
||||
|
||||
const result = await dockerUtils.ensureImageExists();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['build', '-f', 'Dockerfile.claudecode', '-t', 'claudecode:latest', '.'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when build fails', async () => {
|
||||
// Mock doesImageExist to return false
|
||||
mockedExecFile.mockImplementation((file, args, options, callback) => {
|
||||
if (args[0] === 'inspect') {
|
||||
throw new Error('No such image');
|
||||
}
|
||||
if (args[0] === 'build') {
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
return { stdout: '', stderr: 'Build failed' };
|
||||
});
|
||||
|
||||
const result = await dockerUtils.ensureImageExists();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startContainer', () => {
|
||||
it('should start a container with default resource limits', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'container-id', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.startContainer(
|
||||
'test-container',
|
||||
{ REPO_FULL_NAME: 'test/repo', COMMAND: 'test command' }
|
||||
);
|
||||
|
||||
expect(result).toBe('container-id');
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'run', '-d', '--rm',
|
||||
'--name', 'test-container',
|
||||
'--memory', '2g',
|
||||
'--cpu-shares', '1024',
|
||||
'--pids-limit', '256'
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should start a container with custom resource limits', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'container-id', stderr: '' });
|
||||
|
||||
const resourceLimits: ResourceLimits = {
|
||||
memory: '4g',
|
||||
cpuShares: '2048',
|
||||
pidsLimit: '512'
|
||||
};
|
||||
|
||||
const result = await dockerUtils.startContainer(
|
||||
'test-container',
|
||||
{ REPO_FULL_NAME: 'test/repo', COMMAND: 'test command' },
|
||||
resourceLimits
|
||||
);
|
||||
|
||||
expect(result).toBe('container-id');
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'run', '-d', '--rm',
|
||||
'--name', 'test-container',
|
||||
'--memory', '4g',
|
||||
'--cpu-shares', '2048',
|
||||
'--pids-limit', '512'
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should add environment variables to the container', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'container-id', stderr: '' });
|
||||
|
||||
await dockerUtils.startContainer(
|
||||
'test-container',
|
||||
{
|
||||
REPO_FULL_NAME: 'test/repo',
|
||||
COMMAND: 'test command',
|
||||
GITHUB_TOKEN: 'secret-token',
|
||||
IS_PULL_REQUEST: 'true'
|
||||
}
|
||||
);
|
||||
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'-e', 'REPO_FULL_NAME=test/repo',
|
||||
'-e', 'COMMAND=test command',
|
||||
'-e', 'GITHUB_TOKEN=secret-token',
|
||||
'-e', 'IS_PULL_REQUEST=true'
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when container start fails', async () => {
|
||||
mockedExecFile.mockRejectedValue(new Error('Start failed'));
|
||||
|
||||
const result = await dockerUtils.startContainer(
|
||||
'test-container',
|
||||
{ REPO_FULL_NAME: 'test/repo', COMMAND: 'test command' }
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopContainer', () => {
|
||||
it('should stop a container', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.stopContainer('container-id');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['stop', 'container-id'],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should force kill a container when force is true', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.stopContainer('container-id', true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['kill', 'container-id'],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when stop fails', async () => {
|
||||
mockedExecFile.mockRejectedValue(new Error('Stop failed'));
|
||||
|
||||
const result = await dockerUtils.stopContainer('container-id');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerLogs', () => {
|
||||
it('should get container logs', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'Container log output', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.getContainerLogs('container-id');
|
||||
|
||||
expect(result).toBe('Container log output');
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['logs', 'container-id'],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should get container logs with tail option', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'Container log output', stderr: '' });
|
||||
|
||||
await dockerUtils.getContainerLogs('container-id', false, 100);
|
||||
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['logs', '--tail', '100', 'container-id'],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle follow mode', async () => {
|
||||
const result = await dockerUtils.getContainerLogs('container-id', true);
|
||||
|
||||
expect(result).toBe('Streaming logs...');
|
||||
// Verify spawn was called (in child_process mock)
|
||||
const { spawn } = require('child_process');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['logs', '-f', 'container-id'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockedExecFile.mockRejectedValue(new Error('Logs failed'));
|
||||
|
||||
const result = await dockerUtils.getContainerLogs('container-id');
|
||||
|
||||
expect(result).toContain('Error retrieving logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainerRunning', () => {
|
||||
// Set explicit timeout for these tests
|
||||
jest.setTimeout(10000);
|
||||
|
||||
it('should return true for a running container', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'true', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.isContainerRunning('container-id');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['inspect', '--format', '{{.State.Running}}', 'container-id'],
|
||||
undefined
|
||||
);
|
||||
}, 10000); // Explicit timeout
|
||||
|
||||
it('should return false for a stopped container', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'false', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.isContainerRunning('container-id');
|
||||
|
||||
expect(result).toBe(false);
|
||||
}, 10000); // Explicit timeout
|
||||
|
||||
it('should return false when container does not exist', async () => {
|
||||
mockedExecFile.mockImplementation(() => {
|
||||
throw new Error('No such container');
|
||||
});
|
||||
|
||||
const result = await dockerUtils.isContainerRunning('container-id');
|
||||
|
||||
expect(result).toBe(false);
|
||||
}, 10000); // Explicit timeout
|
||||
});
|
||||
|
||||
describe('executeCommand', () => {
|
||||
jest.setTimeout(10000);
|
||||
|
||||
it('should execute a command in a container', async () => {
|
||||
mockedExecFile.mockResolvedValue({ stdout: 'Command output', stderr: '' });
|
||||
|
||||
const result = await dockerUtils.executeCommand('container-id', 'echo "hello"');
|
||||
|
||||
expect(result).toBe('Command output');
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['exec', 'container-id', 'bash', '-c', 'echo "hello"'],
|
||||
undefined
|
||||
);
|
||||
}, 10000); // Explicit timeout
|
||||
|
||||
it('should throw an error when command execution fails', async () => {
|
||||
mockedExecFile.mockImplementation(() => {
|
||||
throw new Error('Command failed');
|
||||
});
|
||||
|
||||
await expect(dockerUtils.executeCommand('container-id', 'invalid-command'))
|
||||
.rejects.toThrow('Command failed');
|
||||
}, 10000); // Explicit timeout
|
||||
});
|
||||
});
|
||||
287
cli/__tests__/utils/sessionManager.test.ts
Normal file
287
cli/__tests__/utils/sessionManager.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import mockFs from 'mock-fs';
|
||||
import { SessionManager } from '../../src/utils/sessionManager';
|
||||
import { SessionConfig, SessionStatus } from '../../src/types/session';
|
||||
import { DockerUtils } from '../../src/utils/dockerUtils';
|
||||
|
||||
// Mock DockerUtils
|
||||
jest.mock('../../src/utils/dockerUtils');
|
||||
|
||||
// Type for mocked DockerUtils
|
||||
type MockedDockerUtils = {
|
||||
isContainerRunning: jest.MockedFunction<DockerUtils['isContainerRunning']>;
|
||||
startContainer: jest.MockedFunction<DockerUtils['startContainer']>;
|
||||
};
|
||||
|
||||
describe('SessionManager', () => {
|
||||
let sessionManager: SessionManager;
|
||||
const sessionsDir = path.join(process.env.HOME as string, '.claude-hub', 'sessions');
|
||||
|
||||
// Sample session data
|
||||
const sampleSession: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
repoFullName: 'test/repo',
|
||||
containerId: 'test-container-id',
|
||||
command: 'analyze this code',
|
||||
status: 'running' as SessionStatus
|
||||
};
|
||||
|
||||
// Mock DockerUtils implementation
|
||||
const mockDockerUtils = DockerUtils as jest.MockedClass<typeof DockerUtils>;
|
||||
let mockDockerInstance: MockedDockerUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup mock DockerUtils instance
|
||||
mockDockerInstance = {
|
||||
isContainerRunning: jest.fn(),
|
||||
startContainer: jest.fn()
|
||||
} as unknown as MockedDockerUtils;
|
||||
|
||||
mockDockerUtils.mockImplementation(() => mockDockerInstance as any);
|
||||
|
||||
// Default mock implementation
|
||||
mockDockerInstance.isContainerRunning.mockResolvedValue(true);
|
||||
mockDockerInstance.startContainer.mockResolvedValue('new-container-id');
|
||||
|
||||
// Setup mock file system
|
||||
const testHomeDir = process.env.HOME as string;
|
||||
const claudeHubDir = path.join(testHomeDir, '.claude-hub');
|
||||
mockFs({
|
||||
[testHomeDir]: {},
|
||||
[claudeHubDir]: {},
|
||||
[sessionsDir]: {} // Empty directory
|
||||
});
|
||||
|
||||
// Create fresh instance for each test
|
||||
sessionManager = new SessionManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore real file system
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new session with a generated ID', () => {
|
||||
const session = sessionManager.createSession(sampleSession);
|
||||
|
||||
expect(session).toHaveProperty('id');
|
||||
expect(session.repoFullName).toBe('test/repo');
|
||||
expect(session.containerId).toBe('test-container-id');
|
||||
expect(session.command).toBe('analyze this code');
|
||||
expect(session.status).toBe('running');
|
||||
expect(session).toHaveProperty('createdAt');
|
||||
expect(session).toHaveProperty('updatedAt');
|
||||
});
|
||||
|
||||
it('should save the session to disk', () => {
|
||||
// We need to spy on the filesystem write operation
|
||||
const spy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const session = sessionManager.createSession(sampleSession);
|
||||
|
||||
// Verify the write operation was called with the correct arguments
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy.mock.calls[0][0]).toContain(`${session.id}.json`);
|
||||
|
||||
// Check that the content passed to writeFileSync is correct
|
||||
const writtenContent = JSON.parse(spy.mock.calls[0][1] as string);
|
||||
expect(writtenContent).toEqual(session);
|
||||
|
||||
// Clean up
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should retrieve a session by ID', () => {
|
||||
const session = sessionManager.createSession(sampleSession);
|
||||
const retrievedSession = sessionManager.getSession(session.id);
|
||||
|
||||
expect(retrievedSession).toEqual(session);
|
||||
});
|
||||
|
||||
it('should return null for a non-existent session', () => {
|
||||
const retrievedSession = sessionManager.getSession('non-existent');
|
||||
|
||||
expect(retrievedSession).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSessionStatus', () => {
|
||||
it('should update the status of a session', () => {
|
||||
const session = sessionManager.createSession(sampleSession);
|
||||
const result = sessionManager.updateSessionStatus(session.id, 'completed');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const updatedSession = sessionManager.getSession(session.id);
|
||||
expect(updatedSession?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should return false for a non-existent session', () => {
|
||||
const result = sessionManager.updateSessionStatus('non-existent', 'completed');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete a session', () => {
|
||||
const session = sessionManager.createSession(sampleSession);
|
||||
const result = sessionManager.deleteSession(session.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const filePath = path.join(sessionsDir, `${session.id}.json`);
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a non-existent session', () => {
|
||||
const result = sessionManager.deleteSession('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
beforeEach(() => {
|
||||
// Create multiple sessions for testing
|
||||
sessionManager.createSession({
|
||||
...sampleSession,
|
||||
repoFullName: 'test/repo1',
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
sessionManager.createSession({
|
||||
...sampleSession,
|
||||
repoFullName: 'test/repo2',
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
sessionManager.createSession({
|
||||
...sampleSession,
|
||||
repoFullName: 'other/repo',
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
it('should list all sessions', async () => {
|
||||
const sessions = await sessionManager.listSessions();
|
||||
|
||||
expect(sessions.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter sessions by status', async () => {
|
||||
const sessions = await sessionManager.listSessions({ status: 'running' });
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
expect(sessions.every(s => s.status === 'running')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter sessions by repo', async () => {
|
||||
const sessions = await sessionManager.listSessions({ repo: 'test' });
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
expect(sessions.every(s => s.repoFullName.includes('test'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply limit to results', async () => {
|
||||
const sessions = await sessionManager.listSessions({ limit: 2 });
|
||||
|
||||
expect(sessions.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should verify running container status', async () => {
|
||||
// Mock container not running for one session
|
||||
mockDockerInstance.isContainerRunning.mockImplementation(async (containerId) => {
|
||||
return containerId !== 'test-container-id';
|
||||
});
|
||||
|
||||
const sessions = await sessionManager.listSessions();
|
||||
|
||||
// At least one session should be updated to stopped
|
||||
expect(sessions.some(s => s.status === 'stopped')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverSession', () => {
|
||||
let stoppedSessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a stopped session for recovery testing
|
||||
const session = sessionManager.createSession({
|
||||
...sampleSession,
|
||||
status: 'stopped'
|
||||
});
|
||||
stoppedSessionId = session.id;
|
||||
});
|
||||
|
||||
it('should recover a stopped session', async () => {
|
||||
const result = await sessionManager.recoverSession(stoppedSessionId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockDockerInstance.startContainer).toHaveBeenCalled();
|
||||
|
||||
const updatedSession = sessionManager.getSession(stoppedSessionId);
|
||||
expect(updatedSession?.status).toBe('running');
|
||||
expect(updatedSession?.containerId).toBe('new-container-id');
|
||||
});
|
||||
|
||||
it('should fail to recover a non-existent session', async () => {
|
||||
const result = await sessionManager.recoverSession('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockDockerInstance.startContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail to recover a running session', async () => {
|
||||
// Create a running session
|
||||
const session = sessionManager.createSession({
|
||||
...sampleSession,
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
const result = await sessionManager.recoverSession(session.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockDockerInstance.startContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncSessionStatuses', () => {
|
||||
beforeEach(() => {
|
||||
// Create multiple sessions for testing
|
||||
sessionManager.createSession({
|
||||
...sampleSession,
|
||||
containerId: 'running-container',
|
||||
status: 'running'
|
||||
});
|
||||
|
||||
sessionManager.createSession({
|
||||
...sampleSession,
|
||||
containerId: 'stopped-container',
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
it('should sync session statuses with container states', async () => {
|
||||
// Mock container running check
|
||||
mockDockerInstance.isContainerRunning.mockImplementation(async (containerId) => {
|
||||
return containerId === 'running-container';
|
||||
});
|
||||
|
||||
await sessionManager.syncSessionStatuses();
|
||||
|
||||
// Get all sessions after sync
|
||||
const sessions = await sessionManager.listSessions();
|
||||
|
||||
// Should have one running and one stopped session
|
||||
expect(sessions.filter(s => s.status === 'running').length).toBe(1);
|
||||
expect(sessions.filter(s => s.status === 'stopped').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
25
cli/claude-hub
Executable file
25
cli/claude-hub
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Claude Hub CLI Wrapper
|
||||
# Usage: ./claude-hub <command> [options]
|
||||
|
||||
# Determine the script directory
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# Check if ts-node is available
|
||||
if command -v ts-node &> /dev/null; then
|
||||
# Run with ts-node for development
|
||||
ts-node "$SCRIPT_DIR/src/index.ts" "$@"
|
||||
else
|
||||
# Check if compiled version exists
|
||||
if [ -f "$SCRIPT_DIR/dist/index.js" ]; then
|
||||
# Run compiled version
|
||||
node "$SCRIPT_DIR/dist/index.js" "$@"
|
||||
else
|
||||
echo "Error: Neither ts-node nor compiled JavaScript is available."
|
||||
echo "Please either install ts-node or compile the TypeScript files:"
|
||||
echo " npm install -g ts-node # To install ts-node globally"
|
||||
echo " npm run build # To compile TypeScript"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
21
cli/jest.config.js
Normal file
21
cli/jest.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,js}',
|
||||
'!src/index.ts',
|
||||
'!**/node_modules/**',
|
||||
'!**/dist/**',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.{ts,js}'],
|
||||
setupFilesAfterEnv: ['<rootDir>/__tests__/setup.ts'],
|
||||
};
|
||||
4355
cli/package-lock.json
generated
Normal file
4355
cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
cli/package.json
Normal file
42
cli/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "claude-hub-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI tool to manage autonomous Claude Code sessions",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"test": "jest --testPathIgnorePatterns='__tests__/utils/dockerUtils.test.ts'",
|
||||
"test:specific": "jest '__tests__/commands/start.test.ts' '__tests__/commands/start-batch.test.ts' '__tests__/utils/sessionManager.test.ts' '__tests__/utils/dockerUtils.simple.test.ts'",
|
||||
"test:all": "jest --testPathIgnorePatterns='__tests__/utils/dockerUtils.test.ts'",
|
||||
"test:coverage": "jest --testPathIgnorePatterns='__tests__/utils/dockerUtils.test.ts' --coverage",
|
||||
"test:watch": "jest --testPathIgnorePatterns='__tests__/utils/dockerUtils.test.ts' --watch"
|
||||
},
|
||||
"bin": {
|
||||
"claude-hub": "./claude-hub"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ora": "^3.1.0",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"cli-table3": "^0.6.5",
|
||||
"jest": "^29.5.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"ora": "^8.2.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.2",
|
||||
"yaml": "^2.8.0"
|
||||
}
|
||||
}
|
||||
91
cli/src/commands/continue.ts
Normal file
91
cli/src/commands/continue.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Command } from 'commander';
|
||||
import { SessionManager } from '../utils/sessionManager';
|
||||
import { DockerUtils } from '../utils/dockerUtils';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export function registerContinueCommand(program: Command): void {
|
||||
program
|
||||
.command('continue')
|
||||
.description('Continue an autonomous Claude Code session with a new command')
|
||||
.argument('<id>', 'Session ID')
|
||||
.argument('<command>', 'Additional command to send to Claude')
|
||||
.action(async (id, command) => {
|
||||
await continueSession(id, command);
|
||||
});
|
||||
}
|
||||
|
||||
async function continueSession(id: string, command: string): Promise<void> {
|
||||
const spinner = ora('Continuing session...').start();
|
||||
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
const dockerUtils = new DockerUtils();
|
||||
|
||||
// Get session by ID
|
||||
const session = sessionManager.getSession(id);
|
||||
if (!session) {
|
||||
spinner.fail(`Session with ID ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if container is running
|
||||
const isRunning = await dockerUtils.isContainerRunning(session.containerId);
|
||||
if (!isRunning) {
|
||||
if (session.status === 'running') {
|
||||
// Update session status to stopped
|
||||
sessionManager.updateSessionStatus(id, 'stopped');
|
||||
}
|
||||
|
||||
spinner.fail(`Session ${id} is not running (status: ${session.status}). Cannot continue.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the continuation command
|
||||
spinner.text = 'Sending command to session...';
|
||||
|
||||
// Create a script to execute in the container
|
||||
const continuationScript = `
|
||||
#!/bin/bash
|
||||
cd /workspace/repo
|
||||
|
||||
# Save the command to a file
|
||||
cat > /tmp/continuation_command.txt << 'EOL'
|
||||
${command}
|
||||
EOL
|
||||
|
||||
# Run Claude with the continuation command
|
||||
sudo -u node -E env \\
|
||||
HOME="${process.env.HOME || '/home/node'}" \\
|
||||
PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \\
|
||||
ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY || ''}" \\
|
||||
GH_TOKEN="${process.env.GITHUB_TOKEN || ''}" \\
|
||||
GITHUB_TOKEN="${process.env.GITHUB_TOKEN || ''}" \\
|
||||
/usr/local/share/npm-global/bin/claude \\
|
||||
--allowedTools "Bash,Create,Edit,Read,Write,GitHub" \\
|
||||
--verbose \\
|
||||
--print "$(cat /tmp/continuation_command.txt)"
|
||||
`;
|
||||
|
||||
// Execute the script in the container
|
||||
await dockerUtils.executeCommand(session.containerId, continuationScript);
|
||||
|
||||
// Update session with the additional command
|
||||
session.command += `\n\nContinuation: ${command}`;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
sessionManager.saveSession(session);
|
||||
|
||||
spinner.succeed(`Command sent to session ${chalk.green(id)}`);
|
||||
console.log();
|
||||
console.log(`${chalk.blue('Session details:')}`);
|
||||
console.log(` ${chalk.yellow('Repository:')} ${session.repoFullName}`);
|
||||
console.log(` ${chalk.yellow('Status:')} ${chalk.green('running')}`);
|
||||
console.log(` ${chalk.yellow('Container:')} ${session.containerId}`);
|
||||
console.log();
|
||||
console.log(`To view logs: ${chalk.cyan(`claude-hub logs ${session.id}`)}`);
|
||||
console.log(`To stop session: ${chalk.cyan(`claude-hub stop ${session.id}`)}`);
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to continue session: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
128
cli/src/commands/list.ts
Normal file
128
cli/src/commands/list.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Command } from 'commander';
|
||||
import { SessionManager } from '../utils/sessionManager';
|
||||
import { DockerUtils } from '../utils/dockerUtils';
|
||||
import { SessionStatus } from '../types/session';
|
||||
import chalk from 'chalk';
|
||||
import Table from 'cli-table3';
|
||||
|
||||
export function registerListCommand(program: Command): void {
|
||||
program
|
||||
.command('list')
|
||||
.description('List autonomous Claude Code sessions')
|
||||
.option('-s, --status <status>', 'Filter by status (running, completed, failed, stopped)')
|
||||
.option('-r, --repo <repo>', 'Filter by repository name')
|
||||
.option('-l, --limit <number>', 'Limit number of sessions shown', '10')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
await listSessions(options);
|
||||
});
|
||||
}
|
||||
|
||||
async function listSessions(options: {
|
||||
status?: string;
|
||||
repo?: string;
|
||||
limit?: string;
|
||||
json?: boolean;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
const dockerUtils = new DockerUtils();
|
||||
|
||||
// Validate status option if provided
|
||||
const validStatuses: SessionStatus[] = ['running', 'completed', 'failed', 'stopped'];
|
||||
let status: SessionStatus | undefined = undefined;
|
||||
|
||||
if (options.status) {
|
||||
if (!validStatuses.includes(options.status as SessionStatus)) {
|
||||
console.error(`Invalid status: ${options.status}. Valid values: ${validStatuses.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
status = options.status as SessionStatus;
|
||||
}
|
||||
|
||||
// Validate limit option
|
||||
const limit = options.limit ? parseInt(options.limit, 10) : 10;
|
||||
if (isNaN(limit) || limit <= 0) {
|
||||
console.error('Limit must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get sessions with filters
|
||||
const sessions = await sessionManager.listSessions({
|
||||
status,
|
||||
repo: options.repo,
|
||||
limit
|
||||
});
|
||||
|
||||
if (sessions.length === 0) {
|
||||
if (options.json) {
|
||||
console.log('[]');
|
||||
} else {
|
||||
console.log('No sessions found matching the criteria.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For JSON output, just print the sessions
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(sessions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a table for nicer display
|
||||
const table = new Table({
|
||||
head: [
|
||||
chalk.blue('ID'),
|
||||
chalk.blue('Repository'),
|
||||
chalk.blue('Status'),
|
||||
chalk.blue('Created'),
|
||||
chalk.blue('Command')
|
||||
],
|
||||
colWidths: [10, 25, 12, 25, 50]
|
||||
});
|
||||
|
||||
// Format and add sessions to table
|
||||
for (const session of sessions) {
|
||||
// Format the date to be more readable
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const formattedDate = createdDate.toLocaleString();
|
||||
|
||||
// Format status with color
|
||||
let statusText: string = session.status;
|
||||
switch (session.status) {
|
||||
case 'running':
|
||||
statusText = chalk.green('running');
|
||||
break;
|
||||
case 'completed':
|
||||
statusText = chalk.blue('completed');
|
||||
break;
|
||||
case 'failed':
|
||||
statusText = chalk.red('failed');
|
||||
break;
|
||||
case 'stopped':
|
||||
statusText = chalk.yellow('stopped');
|
||||
break;
|
||||
}
|
||||
|
||||
// Truncate command if it's too long
|
||||
const maxCommandLength = 47; // Account for "..."
|
||||
const command = session.command.length > maxCommandLength
|
||||
? `${session.command.substring(0, maxCommandLength)}...`
|
||||
: session.command;
|
||||
|
||||
table.push([
|
||||
session.id,
|
||||
session.repoFullName,
|
||||
statusText,
|
||||
formattedDate,
|
||||
command
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(table.toString());
|
||||
console.log(`\nUse ${chalk.cyan('claude-hub logs <id>')} to view session logs`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error listing sessions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
111
cli/src/commands/logs.ts
Normal file
111
cli/src/commands/logs.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Command } from 'commander';
|
||||
import { SessionManager } from '../utils/sessionManager';
|
||||
import { DockerUtils } from '../utils/dockerUtils';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export function registerLogsCommand(program: Command): void {
|
||||
program
|
||||
.command('logs')
|
||||
.description('View logs from a Claude Code session')
|
||||
.argument('<id>', 'Session ID')
|
||||
.option('-f, --follow', 'Follow log output')
|
||||
.option('-t, --tail <number>', 'Number of lines to show from the end of the logs', '100')
|
||||
.action(async (id, options) => {
|
||||
await showLogs(id, options);
|
||||
});
|
||||
}
|
||||
|
||||
async function showLogs(
|
||||
id: string,
|
||||
options: {
|
||||
follow?: boolean;
|
||||
tail?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
const dockerUtils = new DockerUtils();
|
||||
|
||||
// Get session by ID
|
||||
const session = sessionManager.getSession(id);
|
||||
if (!session) {
|
||||
console.error(`Session with ID ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tail option
|
||||
let tail: number | undefined = undefined;
|
||||
if (options.tail) {
|
||||
tail = parseInt(options.tail, 10);
|
||||
if (isNaN(tail) || tail < 0) {
|
||||
console.error('Tail must be a non-negative number');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if container exists and is running
|
||||
const isRunning = await dockerUtils.isContainerRunning(session.containerId);
|
||||
if (!isRunning && session.status === 'running') {
|
||||
console.log(`Session ${id} container is not running, but was marked as running. Updating status...`);
|
||||
sessionManager.updateSessionStatus(id, 'stopped');
|
||||
session.status = 'stopped';
|
||||
}
|
||||
|
||||
console.log(`${chalk.blue('Session details:')}`);
|
||||
console.log(` ${chalk.yellow('ID:')} ${session.id}`);
|
||||
console.log(` ${chalk.yellow('Repository:')} ${session.repoFullName}`);
|
||||
console.log(` ${chalk.yellow('Status:')} ${getStatusWithColor(session.status)}`);
|
||||
console.log(` ${chalk.yellow('Container ID:')} ${session.containerId}`);
|
||||
console.log(` ${chalk.yellow('Created:')} ${new Date(session.createdAt).toLocaleString()}`);
|
||||
console.log();
|
||||
|
||||
// In case of follow mode and session not running, warn the user
|
||||
if (options.follow && session.status !== 'running') {
|
||||
console.warn(chalk.yellow(`Warning: Session is not running (status: ${session.status}). --follow may not show new logs.`));
|
||||
}
|
||||
|
||||
// Show spinner while fetching logs
|
||||
const spinner = ora('Fetching logs...').start();
|
||||
|
||||
try {
|
||||
if (options.follow) {
|
||||
spinner.stop();
|
||||
console.log(chalk.cyan('Streaming logs... (Press Ctrl+C to exit)'));
|
||||
console.log(chalk.gray('─'.repeat(80)));
|
||||
|
||||
// For follow mode, we need to handle streaming differently
|
||||
await dockerUtils.getContainerLogs(session.containerId, true, tail);
|
||||
} else {
|
||||
// Get logs
|
||||
const logs = await dockerUtils.getContainerLogs(session.containerId, false, tail);
|
||||
spinner.stop();
|
||||
|
||||
console.log(chalk.cyan('Logs:'));
|
||||
console.log(chalk.gray('─'.repeat(80)));
|
||||
console.log(logs);
|
||||
console.log(chalk.gray('─'.repeat(80)));
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to retrieve logs: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error showing logs: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusWithColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return chalk.green('running');
|
||||
case 'completed':
|
||||
return chalk.blue('completed');
|
||||
case 'failed':
|
||||
return chalk.red('failed');
|
||||
case 'stopped':
|
||||
return chalk.yellow('stopped');
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
104
cli/src/commands/recover.ts
Normal file
104
cli/src/commands/recover.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Command } from 'commander';
|
||||
import { SessionManager } from '../utils/sessionManager';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export function registerRecoverCommand(program: Command): void {
|
||||
program
|
||||
.command('recover')
|
||||
.description('Recover a stopped Claude Code session by recreating its container')
|
||||
.argument('<id>', 'Session ID to recover')
|
||||
.action(async (id) => {
|
||||
await recoverSession(id);
|
||||
});
|
||||
|
||||
program
|
||||
.command('sync')
|
||||
.description('Synchronize session status with container status')
|
||||
.action(async () => {
|
||||
await syncSessions();
|
||||
});
|
||||
}
|
||||
|
||||
async function recoverSession(id: string): Promise<void> {
|
||||
const spinner = ora(`Recovering session ${id}...`).start();
|
||||
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
|
||||
// Get session by ID
|
||||
const session = sessionManager.getSession(id);
|
||||
if (!session) {
|
||||
spinner.fail(`Session with ID ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if session is stopped
|
||||
if (session.status !== 'stopped') {
|
||||
spinner.info(`Session ${id} is not stopped (status: ${session.status}). Only stopped sessions can be recovered.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recover the session
|
||||
const recovered = await sessionManager.recoverSession(id);
|
||||
|
||||
if (recovered) {
|
||||
spinner.succeed(`Recovered session ${id} successfully`);
|
||||
console.log();
|
||||
console.log(`${chalk.blue('Session details:')}`);
|
||||
console.log(` ${chalk.yellow('Repository:')} ${session.repoFullName}`);
|
||||
console.log(` ${chalk.yellow('Command:')} ${session.command}`);
|
||||
|
||||
if (session.isPullRequest) {
|
||||
console.log(` ${chalk.yellow('PR:')} #${session.prNumber || 'N/A'}`);
|
||||
if (session.branchName) {
|
||||
console.log(` ${chalk.yellow('Branch:')} ${session.branchName}`);
|
||||
}
|
||||
} else if (session.isIssue) {
|
||||
console.log(` ${chalk.yellow('Issue:')} #${session.issueNumber}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(`To view logs: ${chalk.cyan(`claude-hub logs ${session.id}`)}`);
|
||||
console.log(`To continue session: ${chalk.cyan(`claude-hub continue ${session.id} "Additional command"`)}`);
|
||||
console.log(`To stop session: ${chalk.cyan(`claude-hub stop ${session.id}`)}`);
|
||||
} else {
|
||||
spinner.fail(`Failed to recover session ${id}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail(`Error recovering session: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSessions(): Promise<void> {
|
||||
const spinner = ora('Synchronizing session statuses...').start();
|
||||
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
|
||||
// Sync session statuses
|
||||
await sessionManager.syncSessionStatuses();
|
||||
|
||||
// Get updated sessions
|
||||
const sessions = await sessionManager.listSessions();
|
||||
|
||||
spinner.succeed(`Synchronized ${sessions.length} sessions`);
|
||||
|
||||
// Display running sessions
|
||||
const runningSessions = sessions.filter(s => s.status === 'running');
|
||||
const stoppedSessions = sessions.filter(s => s.status === 'stopped');
|
||||
|
||||
console.log();
|
||||
console.log(`${chalk.green('Running sessions:')} ${runningSessions.length}`);
|
||||
console.log(`${chalk.yellow('Stopped sessions:')} ${stoppedSessions.length}`);
|
||||
|
||||
if (stoppedSessions.length > 0) {
|
||||
console.log();
|
||||
console.log(`To recover a stopped session: ${chalk.cyan('claude-hub recover <id>')}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail(`Error synchronizing sessions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
162
cli/src/commands/start-batch.ts
Normal file
162
cli/src/commands/start-batch.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Command } from 'commander';
|
||||
import { BatchTaskDefinition, BatchOptions } from '../types/session';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export function registerStartBatchCommand(program: Command): void {
|
||||
program
|
||||
.command('start-batch')
|
||||
.description('Start multiple autonomous Claude Code sessions from a task file')
|
||||
.argument('<file>', 'YAML file containing batch task definitions')
|
||||
.option('-p, --parallel', 'Run tasks in parallel', false)
|
||||
.option('-c, --concurrent <number>', 'Maximum number of concurrent tasks (default: 2)', '2')
|
||||
.action(async (file, options) => {
|
||||
await startBatch(file, options);
|
||||
});
|
||||
}
|
||||
|
||||
async function startBatch(
|
||||
file: string,
|
||||
options: {
|
||||
parallel?: boolean;
|
||||
concurrent?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const spinner = ora('Loading batch tasks...').start();
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(file)) {
|
||||
spinner.fail(`Task file not found: ${file}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load and parse YAML file
|
||||
const filePath = path.resolve(file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const tasks = yaml.parse(fileContent) as BatchTaskDefinition[];
|
||||
|
||||
if (!Array.isArray(tasks) || tasks.length === 0) {
|
||||
spinner.fail('No valid tasks found in the task file.');
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.succeed(`Loaded ${tasks.length} tasks from ${path.basename(file)}`);
|
||||
|
||||
const batchOptions: BatchOptions = {
|
||||
tasksFile: filePath,
|
||||
parallel: options.parallel,
|
||||
maxConcurrent: options.concurrent ? parseInt(options.concurrent, 10) : 2
|
||||
};
|
||||
|
||||
// Validate maxConcurrent
|
||||
if (isNaN(batchOptions.maxConcurrent!) || batchOptions.maxConcurrent! < 1) {
|
||||
console.error('Error: --concurrent must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the batch
|
||||
if (batchOptions.parallel) {
|
||||
console.log(`Running ${tasks.length} tasks in parallel (max ${batchOptions.maxConcurrent} concurrent)...`);
|
||||
await runTasksInParallel(tasks, batchOptions.maxConcurrent!);
|
||||
} else {
|
||||
console.log(`Running ${tasks.length} tasks sequentially...`);
|
||||
await runTasksSequentially(tasks);
|
||||
}
|
||||
|
||||
console.log(chalk.green('✓ Batch execution completed.'));
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to start batch: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTasksSequentially(tasks: BatchTaskDefinition[]): Promise<void> {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
console.log(`\n[${i + 1}/${tasks.length}] Starting task for ${task.repo}: "${task.command.substring(0, 50)}${task.command.length > 50 ? '...' : ''}"`);
|
||||
|
||||
// Run the individual task (using start command)
|
||||
await runTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTasksInParallel(tasks: BatchTaskDefinition[], maxConcurrent: number): Promise<void> {
|
||||
// Split tasks into chunks of maxConcurrent
|
||||
for (let i = 0; i < tasks.length; i += maxConcurrent) {
|
||||
const chunk = tasks.slice(i, i + maxConcurrent);
|
||||
|
||||
console.log(`\nStarting batch ${Math.floor(i / maxConcurrent) + 1}/${Math.ceil(tasks.length / maxConcurrent)} (${chunk.length} tasks)...`);
|
||||
|
||||
// Run all tasks in this chunk concurrently
|
||||
await Promise.all(chunk.map((task, idx) => {
|
||||
console.log(`[${i + idx + 1}/${tasks.length}] Starting task for ${task.repo}: "${task.command.substring(0, 30)}${task.command.length > 30 ? '...' : ''}"`);
|
||||
return runTask(task);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function runTask(task: BatchTaskDefinition): Promise<void> {
|
||||
try {
|
||||
// Prepare args for the start command
|
||||
const args = ['start', task.repo, task.command];
|
||||
|
||||
// Add issue context if specified
|
||||
if (task.issue) {
|
||||
args.push('--issue', String(task.issue));
|
||||
}
|
||||
|
||||
// Add PR context if specified
|
||||
if (task.pr !== undefined) {
|
||||
if (typeof task.pr === 'boolean') {
|
||||
if (task.pr) args.push('--pr');
|
||||
} else {
|
||||
args.push('--pr', String(task.pr));
|
||||
}
|
||||
}
|
||||
|
||||
// Add branch if specified
|
||||
if (task.branch) {
|
||||
args.push('--branch', task.branch);
|
||||
}
|
||||
|
||||
// Add resource limits if specified
|
||||
if (task.resourceLimits) {
|
||||
if (task.resourceLimits.memory) {
|
||||
args.push('--memory', task.resourceLimits.memory);
|
||||
}
|
||||
if (task.resourceLimits.cpuShares) {
|
||||
args.push('--cpu', task.resourceLimits.cpuShares);
|
||||
}
|
||||
if (task.resourceLimits.pidsLimit) {
|
||||
args.push('--pids', task.resourceLimits.pidsLimit);
|
||||
}
|
||||
}
|
||||
|
||||
// Import the start command function directly
|
||||
const { startSession } = await import('./start');
|
||||
|
||||
// Extract command and options from the args
|
||||
const repo = task.repo;
|
||||
const command = task.command;
|
||||
const options: any = {};
|
||||
|
||||
if (task.issue) options.issue = String(task.issue);
|
||||
if (task.pr !== undefined) options.pr = task.pr;
|
||||
if (task.branch) options.branch = task.branch;
|
||||
|
||||
if (task.resourceLimits) {
|
||||
if (task.resourceLimits.memory) options.memory = task.resourceLimits.memory;
|
||||
if (task.resourceLimits.cpuShares) options.cpu = task.resourceLimits.cpuShares;
|
||||
if (task.resourceLimits.pidsLimit) options.pids = task.resourceLimits.pidsLimit;
|
||||
}
|
||||
|
||||
// Run the start command
|
||||
await startSession(repo, command, options);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error running task for ${task.repo}:`, error);
|
||||
}
|
||||
}
|
||||
251
cli/src/commands/start.ts
Normal file
251
cli/src/commands/start.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Command } from 'commander';
|
||||
import { SessionManager } from '../utils/sessionManager';
|
||||
import { DockerUtils } from '../utils/dockerUtils';
|
||||
import { StartSessionOptions, SessionConfig } from '../types/session';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export function registerStartCommand(program: Command): void {
|
||||
program
|
||||
.command('start')
|
||||
.description('Start a new autonomous Claude Code session')
|
||||
.argument('<repo>', 'GitHub repository (format: owner/repo or repo)')
|
||||
.argument('<command>', 'Command to send to Claude')
|
||||
.option('-p, --pr [number]', 'Treat as pull request and optionally specify PR number')
|
||||
.option('-i, --issue <number>', 'Treat as issue and specify issue number')
|
||||
.option('-b, --branch <branch>', 'Branch name for PR')
|
||||
.option('-m, --memory <limit>', 'Memory limit (e.g., "2g")')
|
||||
.option('-c, --cpu <shares>', 'CPU shares (e.g., "1024")')
|
||||
.option('--pids <limit>', 'Process ID limit (e.g., "256")')
|
||||
.action(async (repo, command, options) => {
|
||||
await startSession(repo, command, options);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startSession(
|
||||
repo: string,
|
||||
command: string,
|
||||
options: {
|
||||
pr?: string | boolean;
|
||||
issue?: string;
|
||||
branch?: string;
|
||||
memory?: string;
|
||||
cpu?: string;
|
||||
pids?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const spinner = ora('Starting autonomous Claude Code session...').start();
|
||||
|
||||
try {
|
||||
// Process repo format (owner/repo or just repo)
|
||||
let repoFullName = repo;
|
||||
if (!repo.includes('/')) {
|
||||
const defaultOwner = process.env.DEFAULT_GITHUB_OWNER || 'default-owner';
|
||||
repoFullName = `${defaultOwner}/${repo}`;
|
||||
}
|
||||
|
||||
// Validate context: PR and issue cannot both be specified
|
||||
if (options.pr !== undefined && options.issue !== undefined) {
|
||||
spinner.fail('Error: Cannot specify both --pr and --issue. Choose one context type.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process PR option
|
||||
const isPullRequest = options.pr !== undefined;
|
||||
const prNumber = typeof options.pr === 'string' ? parseInt(options.pr, 10) : undefined;
|
||||
|
||||
// Process Issue option
|
||||
const isIssue = options.issue !== undefined;
|
||||
const issueNumber = options.issue ? parseInt(options.issue, 10) : undefined;
|
||||
|
||||
// Branch is only valid with PR context
|
||||
if (options.branch && !isPullRequest) {
|
||||
spinner.warn('Note: --branch is only used with --pr option. It will be ignored for this session.');
|
||||
}
|
||||
|
||||
// Prepare resource limits if specified
|
||||
const resourceLimits = (options.memory || options.cpu || options.pids) ? {
|
||||
memory: options.memory || '2g',
|
||||
cpuShares: options.cpu || '1024',
|
||||
pidsLimit: options.pids || '256'
|
||||
} : undefined;
|
||||
|
||||
// Session configuration
|
||||
const sessionOptions: StartSessionOptions = {
|
||||
repoFullName,
|
||||
command,
|
||||
isPullRequest,
|
||||
isIssue,
|
||||
issueNumber,
|
||||
prNumber,
|
||||
branchName: options.branch,
|
||||
resourceLimits
|
||||
};
|
||||
|
||||
// Initialize utilities
|
||||
const sessionManager = new SessionManager();
|
||||
const dockerUtils = new DockerUtils();
|
||||
|
||||
// Check if Docker is available
|
||||
if (!await dockerUtils.isDockerAvailable()) {
|
||||
spinner.fail('Docker is not available. Please install Docker and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure Docker image exists
|
||||
spinner.text = 'Checking Docker image...';
|
||||
if (!await dockerUtils.ensureImageExists()) {
|
||||
spinner.fail('Failed to ensure Docker image exists.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate session ID and container name
|
||||
const sessionId = sessionManager.generateSessionId();
|
||||
const containerName = `claude-hub-${sessionId}`;
|
||||
|
||||
// Prepare environment variables for the container
|
||||
const envVars = createEnvironmentVars(sessionOptions);
|
||||
|
||||
// Start the container
|
||||
spinner.text = 'Starting Docker container...';
|
||||
const containerId = await dockerUtils.startContainer(
|
||||
containerName,
|
||||
envVars,
|
||||
resourceLimits
|
||||
);
|
||||
|
||||
if (!containerId) {
|
||||
spinner.fail('Failed to start Docker container.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and save session
|
||||
const session: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
repoFullName: sessionOptions.repoFullName,
|
||||
containerId,
|
||||
command: sessionOptions.command,
|
||||
status: 'running',
|
||||
isPullRequest: sessionOptions.isPullRequest,
|
||||
isIssue: sessionOptions.isIssue,
|
||||
prNumber: sessionOptions.prNumber,
|
||||
issueNumber: sessionOptions.issueNumber,
|
||||
branchName: sessionOptions.branchName,
|
||||
resourceLimits: sessionOptions.resourceLimits
|
||||
};
|
||||
|
||||
const savedSession = sessionManager.createSession(session);
|
||||
|
||||
spinner.succeed(`Started autonomous session with ID: ${chalk.green(savedSession.id)}`);
|
||||
console.log();
|
||||
console.log(`${chalk.blue('Session details:')}`);
|
||||
console.log(` ${chalk.yellow('Repository:')} ${savedSession.repoFullName}`);
|
||||
console.log(` ${chalk.yellow('Command:')} ${savedSession.command}`);
|
||||
|
||||
if (savedSession.isPullRequest) {
|
||||
console.log(` ${chalk.yellow('PR:')} #${savedSession.prNumber || 'N/A'}`);
|
||||
if (savedSession.branchName) {
|
||||
console.log(` ${chalk.yellow('Branch:')} ${savedSession.branchName}`);
|
||||
}
|
||||
} else if (savedSession.isIssue) {
|
||||
console.log(` ${chalk.yellow('Issue:')} #${savedSession.issueNumber}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(`To view logs: ${chalk.cyan(`claude-hub logs ${savedSession.id}`)}`);
|
||||
console.log(`To continue session: ${chalk.cyan(`claude-hub continue ${savedSession.id} "Additional command"`)}`);
|
||||
console.log(`To stop session: ${chalk.cyan(`claude-hub stop ${savedSession.id}`)}`);
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to start session: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create environment variables for container
|
||||
*/
|
||||
function createEnvironmentVars(options: StartSessionOptions): Record<string, string> {
|
||||
// Get GitHub token from environment or secure storage
|
||||
const githubToken = process.env.GITHUB_TOKEN || '';
|
||||
if (!githubToken) {
|
||||
console.warn('Warning: No GitHub token found. Set GITHUB_TOKEN environment variable.');
|
||||
}
|
||||
|
||||
// Get Anthropic API key from environment or secure storage
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY || '';
|
||||
if (!anthropicApiKey) {
|
||||
console.warn('Warning: No Anthropic API key found. Set ANTHROPIC_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
// Set the issue or PR number in the ISSUE_NUMBER env var
|
||||
// The entrypoint script uses this variable for both issues and PRs
|
||||
let issueNumber = '';
|
||||
if (options.isPullRequest && options.prNumber) {
|
||||
issueNumber = String(options.prNumber);
|
||||
} else if (options.isIssue && options.issueNumber) {
|
||||
issueNumber = String(options.issueNumber);
|
||||
}
|
||||
|
||||
return {
|
||||
REPO_FULL_NAME: options.repoFullName,
|
||||
ISSUE_NUMBER: issueNumber,
|
||||
IS_PULL_REQUEST: options.isPullRequest ? 'true' : 'false',
|
||||
IS_ISSUE: options.isIssue ? 'true' : 'false',
|
||||
BRANCH_NAME: options.branchName || '',
|
||||
OPERATION_TYPE: 'default',
|
||||
COMMAND: createPrompt(options),
|
||||
GITHUB_TOKEN: githubToken,
|
||||
ANTHROPIC_API_KEY: anthropicApiKey,
|
||||
BOT_USERNAME: process.env.BOT_USERNAME || 'ClaudeBot',
|
||||
BOT_EMAIL: process.env.BOT_EMAIL || 'claude@example.com'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create prompt based on context
|
||||
*/
|
||||
function createPrompt(options: StartSessionOptions): string {
|
||||
// Determine the context type (repository, PR, or issue)
|
||||
let contextType = 'repository';
|
||||
if (options.isPullRequest) {
|
||||
contextType = 'pull request';
|
||||
} else if (options.isIssue) {
|
||||
contextType = 'issue';
|
||||
}
|
||||
|
||||
return `You are ${process.env.BOT_USERNAME || 'ClaudeBot'}, an AI assistant working autonomously on a GitHub ${contextType}.
|
||||
|
||||
**Context:**
|
||||
- Repository: ${options.repoFullName}
|
||||
${options.isPullRequest ? `- Pull Request Number: #${options.prNumber || 'N/A'}` : ''}
|
||||
${options.isIssue ? `- Issue Number: #${options.issueNumber}` : ''}
|
||||
${options.branchName ? `- Branch: ${options.branchName}` : ''}
|
||||
- Running in: Autonomous mode
|
||||
|
||||
**Important Instructions:**
|
||||
1. You have full GitHub CLI access via the 'gh' command
|
||||
2. When writing code:
|
||||
- Always create a feature branch for new work
|
||||
- Make commits with descriptive messages
|
||||
- Push your work to the remote repository
|
||||
- Run all tests and ensure they pass
|
||||
- Fix any linting or type errors
|
||||
- Create a pull request if appropriate
|
||||
3. Iterate until the task is complete - don't stop at partial solutions
|
||||
4. Always check in your work by pushing to the remote before finishing
|
||||
5. Use 'gh issue comment' or 'gh pr comment' to provide updates on your progress
|
||||
6. If you encounter errors, debug and fix them before completing
|
||||
7. **Markdown Formatting:**
|
||||
- When your response contains markdown, return it as properly formatted markdown
|
||||
- Do NOT escape or encode special characters like newlines (\\n) or quotes
|
||||
- Return clean, human-readable markdown that GitHub will render correctly
|
||||
8. **Progress Acknowledgment:**
|
||||
- For larger or complex tasks, first acknowledge the request
|
||||
- Post a brief comment describing your plan before starting
|
||||
- Use 'gh issue comment' or 'gh pr comment' to post this acknowledgment
|
||||
- This lets the user know their request was received and is being processed
|
||||
|
||||
**User Request:**
|
||||
${options.command}
|
||||
|
||||
Please complete this task fully and autonomously.`;
|
||||
}
|
||||
159
cli/src/commands/stop.ts
Normal file
159
cli/src/commands/stop.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Command } from 'commander';
|
||||
import { SessionManager } from '../utils/sessionManager';
|
||||
import { DockerUtils } from '../utils/dockerUtils';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export function registerStopCommand(program: Command): void {
|
||||
program
|
||||
.command('stop')
|
||||
.description('Stop an autonomous Claude Code session')
|
||||
.argument('<id>', 'Session ID or "all" to stop all running sessions')
|
||||
.option('-f, --force', 'Force stop (kill) the container')
|
||||
.option('--remove', 'Remove the session after stopping')
|
||||
.action(async (id, options) => {
|
||||
if (id.toLowerCase() === 'all') {
|
||||
await stopAllSessions(options);
|
||||
} else {
|
||||
await stopSession(id, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function stopSession(
|
||||
id: string,
|
||||
options: {
|
||||
force?: boolean;
|
||||
remove?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const spinner = ora(`Stopping session ${id}...`).start();
|
||||
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
const dockerUtils = new DockerUtils();
|
||||
|
||||
// Get session by ID
|
||||
const session = sessionManager.getSession(id);
|
||||
if (!session) {
|
||||
spinner.fail(`Session with ID ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if container is running
|
||||
const isRunning = await dockerUtils.isContainerRunning(session.containerId);
|
||||
if (!isRunning) {
|
||||
if (session.status === 'running') {
|
||||
// Update session status to stopped
|
||||
sessionManager.updateSessionStatus(id, 'stopped');
|
||||
spinner.info(`Session ${id} was already stopped, updated status.`);
|
||||
} else {
|
||||
spinner.info(`Session ${id} is already stopped (status: ${session.status}).`);
|
||||
}
|
||||
|
||||
// If remove option is set, remove the session
|
||||
if (options.remove) {
|
||||
sessionManager.deleteSession(id);
|
||||
spinner.succeed(`Session ${id} removed from records.`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the container
|
||||
spinner.text = `Stopping container ${session.containerId}...`;
|
||||
const stopped = await dockerUtils.stopContainer(session.containerId, options.force);
|
||||
|
||||
if (!stopped) {
|
||||
spinner.fail(`Failed to stop container ${session.containerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session status to stopped
|
||||
sessionManager.updateSessionStatus(id, 'stopped');
|
||||
|
||||
// If remove option is set, remove the session
|
||||
if (options.remove) {
|
||||
sessionManager.deleteSession(id);
|
||||
spinner.succeed(`Session ${id} stopped and removed.`);
|
||||
} else {
|
||||
spinner.succeed(`Session ${id} stopped.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to stop session: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAllSessions(
|
||||
options: {
|
||||
force?: boolean;
|
||||
remove?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const spinner = ora('Stopping all running sessions...').start();
|
||||
|
||||
try {
|
||||
const sessionManager = new SessionManager();
|
||||
const dockerUtils = new DockerUtils();
|
||||
|
||||
// Get all running sessions
|
||||
const sessions = await sessionManager.listSessions({ status: 'running' });
|
||||
|
||||
if (sessions.length === 0) {
|
||||
spinner.info('No running sessions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.text = `Stopping ${sessions.length} sessions...`;
|
||||
|
||||
let stoppedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// Stop each session
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
// Check if container is actually running
|
||||
const isRunning = await dockerUtils.isContainerRunning(session.containerId);
|
||||
if (!isRunning) {
|
||||
// Update session status to stopped
|
||||
sessionManager.updateSessionStatus(session.id, 'stopped');
|
||||
stoppedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop the container
|
||||
const stopped = await dockerUtils.stopContainer(session.containerId, options.force);
|
||||
|
||||
if (stopped) {
|
||||
// Update session status to stopped
|
||||
sessionManager.updateSessionStatus(session.id, 'stopped');
|
||||
|
||||
// If remove option is set, remove the session
|
||||
if (options.remove) {
|
||||
sessionManager.deleteSession(session.id);
|
||||
}
|
||||
|
||||
stoppedCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
} catch {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
spinner.warn(`Stopped ${stoppedCount} sessions, failed to stop ${failedCount} sessions.`);
|
||||
} else {
|
||||
spinner.succeed(`Stopped all ${stoppedCount} running sessions.`);
|
||||
}
|
||||
|
||||
if (options.remove) {
|
||||
console.log(`${chalk.yellow('Note:')} Removed stopped sessions from records.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to stop sessions: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
85
cli/src/index.ts
Normal file
85
cli/src/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Claude Hub CLI
|
||||
* A command-line interface for managing autonomous Claude Code sessions
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { registerStartCommand } from './commands/start';
|
||||
import { registerStartBatchCommand } from './commands/start-batch';
|
||||
import { registerListCommand } from './commands/list';
|
||||
import { registerLogsCommand } from './commands/logs';
|
||||
import { registerContinueCommand } from './commands/continue';
|
||||
import { registerStopCommand } from './commands/stop';
|
||||
import { registerRecoverCommand } from './commands/recover';
|
||||
import dotenv from 'dotenv';
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Find package.json to get version
|
||||
let version = '1.0.0';
|
||||
try {
|
||||
const packageJsonPath = path.join(__dirname, '../../package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
version = packageJson.version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not read package.json for version');
|
||||
}
|
||||
|
||||
// Create the CLI program
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('claude-hub')
|
||||
.description('CLI to manage autonomous Claude Code sessions')
|
||||
.version(version);
|
||||
|
||||
// Register commands
|
||||
registerStartCommand(program);
|
||||
registerStartBatchCommand(program);
|
||||
registerListCommand(program);
|
||||
registerLogsCommand(program);
|
||||
registerContinueCommand(program);
|
||||
registerStopCommand(program);
|
||||
registerRecoverCommand(program);
|
||||
|
||||
// Add a help command that displays examples
|
||||
program
|
||||
.command('examples')
|
||||
.description('Show usage examples')
|
||||
.action(() => {
|
||||
console.log(chalk.blue('Claude Hub CLI Examples:'));
|
||||
console.log();
|
||||
console.log(chalk.yellow('Starting sessions:'));
|
||||
console.log(` claude-hub start myorg/myrepo "Implement feature X"`);
|
||||
console.log(` claude-hub start myrepo "Fix bug in authentication" --pr 42`);
|
||||
console.log(` claude-hub start myrepo "Investigate issue" --issue 123`);
|
||||
console.log(` claude-hub start-batch tasks.yaml --parallel --concurrent 3`);
|
||||
console.log();
|
||||
console.log(chalk.yellow('Managing sessions:'));
|
||||
console.log(` claude-hub list`);
|
||||
console.log(` claude-hub list --status running --repo myrepo`);
|
||||
console.log(` claude-hub logs abc123`);
|
||||
console.log(` claude-hub logs abc123 --follow`);
|
||||
console.log(` claude-hub continue abc123 "Also update the documentation"`);
|
||||
console.log(` claude-hub stop abc123`);
|
||||
console.log(` claude-hub stop all --force`);
|
||||
console.log();
|
||||
console.log(chalk.yellow('Session recovery:'));
|
||||
console.log(` claude-hub sync`);
|
||||
console.log(` claude-hub recover abc123`);
|
||||
});
|
||||
|
||||
// Error on unknown commands
|
||||
program.showHelpAfterError();
|
||||
program.showSuggestionAfterError();
|
||||
|
||||
// Parse arguments
|
||||
program.parse();
|
||||
75
cli/src/types/session.ts
Normal file
75
cli/src/types/session.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Types for managing Claude Code sessions
|
||||
*/
|
||||
|
||||
export interface SessionConfig {
|
||||
id: string;
|
||||
repoFullName: string;
|
||||
containerId: string;
|
||||
command: string;
|
||||
status: SessionStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPullRequest?: boolean;
|
||||
isIssue?: boolean;
|
||||
issueNumber?: number;
|
||||
prNumber?: number;
|
||||
branchName?: string;
|
||||
resourceLimits?: ResourceLimits;
|
||||
}
|
||||
|
||||
export type SessionStatus = 'running' | 'completed' | 'failed' | 'stopped';
|
||||
|
||||
export interface ResourceLimits {
|
||||
memory: string;
|
||||
cpuShares: string;
|
||||
pidsLimit: string;
|
||||
}
|
||||
|
||||
export interface StartSessionOptions {
|
||||
repoFullName: string;
|
||||
command: string;
|
||||
isPullRequest?: boolean;
|
||||
isIssue?: boolean;
|
||||
issueNumber?: number;
|
||||
prNumber?: number;
|
||||
branchName?: string;
|
||||
resourceLimits?: ResourceLimits;
|
||||
}
|
||||
|
||||
export interface ContinueSessionOptions {
|
||||
sessionId: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface SessionListOptions {
|
||||
status?: SessionStatus;
|
||||
repo?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SessionLogOptions {
|
||||
sessionId: string;
|
||||
follow?: boolean;
|
||||
tail?: number;
|
||||
}
|
||||
|
||||
export interface StopSessionOptions {
|
||||
sessionId: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface BatchTaskDefinition {
|
||||
repo: string;
|
||||
command: string;
|
||||
issue?: number;
|
||||
pr?: number | boolean;
|
||||
branch?: string;
|
||||
resourceLimits?: ResourceLimits;
|
||||
}
|
||||
|
||||
export interface BatchOptions {
|
||||
tasksFile: string;
|
||||
parallel?: boolean;
|
||||
maxConcurrent?: number;
|
||||
}
|
||||
221
cli/src/utils/dockerUtils.ts
Normal file
221
cli/src/utils/dockerUtils.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { promisify } from 'util';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import path from 'path';
|
||||
import { ResourceLimits } from '../types/session';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Utilities for Docker container operations
|
||||
*/
|
||||
export class DockerUtils {
|
||||
private dockerImageName: string;
|
||||
private entrypointScript: string;
|
||||
|
||||
constructor() {
|
||||
// Use the same image name and entrypoint as the main service
|
||||
this.dockerImageName = process.env.CLAUDE_CONTAINER_IMAGE || 'claudecode:latest';
|
||||
this.entrypointScript = '/scripts/runtime/claudecode-entrypoint.sh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker is available
|
||||
*/
|
||||
async isDockerAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('docker --version');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the required Docker image exists
|
||||
*/
|
||||
async doesImageExist(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('docker', ['inspect', this.dockerImageName]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Docker image if it doesn't exist
|
||||
*/
|
||||
async ensureImageExists(): Promise<boolean> {
|
||||
if (await this.doesImageExist()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Building Docker image ${this.dockerImageName}...`);
|
||||
try {
|
||||
// Try to build from the repository root directory
|
||||
const repoRoot = path.resolve(process.cwd(), '..');
|
||||
await execFileAsync('docker',
|
||||
['build', '-f', path.join(repoRoot, 'Dockerfile.claudecode'), '-t', this.dockerImageName, repoRoot],
|
||||
{ cwd: repoRoot }
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to build Docker image:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new container for a Claude session
|
||||
*/
|
||||
async startContainer(
|
||||
containerName: string,
|
||||
envVars: Record<string, string>,
|
||||
resourceLimits?: ResourceLimits
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Build docker run command as an array to prevent command injection
|
||||
const dockerArgs = ['run', '-d', '--rm'];
|
||||
|
||||
// Add container name
|
||||
dockerArgs.push('--name', containerName);
|
||||
|
||||
// Add resource limits if specified
|
||||
if (resourceLimits) {
|
||||
dockerArgs.push(
|
||||
'--memory', resourceLimits.memory,
|
||||
'--cpu-shares', resourceLimits.cpuShares,
|
||||
'--pids-limit', resourceLimits.pidsLimit
|
||||
);
|
||||
} else {
|
||||
// Default resource limits
|
||||
dockerArgs.push(
|
||||
'--memory', '2g',
|
||||
'--cpu-shares', '1024',
|
||||
'--pids-limit', '256'
|
||||
);
|
||||
}
|
||||
|
||||
// Add required capabilities
|
||||
['NET_ADMIN', 'SYS_ADMIN'].forEach(cap => {
|
||||
dockerArgs.push(`--cap-add=${cap}`);
|
||||
});
|
||||
|
||||
// Add Claude authentication directory as a volume mount
|
||||
const claudeAuthDir = process.env.CLAUDE_AUTH_HOST_DIR || path.join(process.env.HOME || '~', '.claude');
|
||||
dockerArgs.push('-v', `${claudeAuthDir}:/home/node/.claude`);
|
||||
|
||||
// Add environment variables
|
||||
Object.entries(envVars)
|
||||
.filter(([, value]) => value !== undefined && value !== '')
|
||||
.forEach(([key, value]) => {
|
||||
dockerArgs.push('-e', `${key}=${String(value)}`);
|
||||
});
|
||||
|
||||
// Add the image name and custom entrypoint
|
||||
dockerArgs.push('--entrypoint', this.entrypointScript, this.dockerImageName);
|
||||
|
||||
// Start the container
|
||||
const { stdout } = await execFileAsync('docker', dockerArgs);
|
||||
const containerId = stdout.trim();
|
||||
|
||||
return containerId;
|
||||
} catch (error) {
|
||||
console.error('Failed to start container:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a container
|
||||
*/
|
||||
async stopContainer(containerId: string, force = false): Promise<boolean> {
|
||||
try {
|
||||
const command = force ? 'kill' : 'stop';
|
||||
await execFileAsync('docker', [command, containerId]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to stop container ${containerId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs from a container
|
||||
*/
|
||||
async getContainerLogs(containerId: string, follow = false, tail?: number): Promise<string> {
|
||||
try {
|
||||
const args = ['logs'];
|
||||
|
||||
if (follow) {
|
||||
args.push('-f');
|
||||
}
|
||||
|
||||
if (tail !== undefined) {
|
||||
args.push('--tail', String(tail));
|
||||
}
|
||||
|
||||
args.push(containerId);
|
||||
|
||||
if (follow) {
|
||||
// For follow mode, we can't use execFileAsync as it would wait for the process to exit
|
||||
// Instead, we spawn the process and stream the output
|
||||
const { spawn } = require('child_process');
|
||||
const process = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
process.stdout.pipe(process.stdout);
|
||||
process.stderr.pipe(process.stderr);
|
||||
|
||||
// Handle termination
|
||||
process.on('exit', () => {
|
||||
console.log('Log streaming ended');
|
||||
});
|
||||
|
||||
return 'Streaming logs...';
|
||||
} else {
|
||||
const { stdout } = await execFileAsync('docker', args);
|
||||
return stdout;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get logs for container ${containerId}:`, error);
|
||||
return `Error retrieving logs: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container is running
|
||||
*/
|
||||
async isContainerRunning(containerId: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['inspect', '--format', '{{.State.Running}}', containerId]);
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in a running container
|
||||
*/
|
||||
async executeCommand(containerId: string, command: string): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('docker', [
|
||||
'exec',
|
||||
containerId,
|
||||
'bash',
|
||||
'-c',
|
||||
command
|
||||
]);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Command execution stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error(`Failed to execute command in container ${containerId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
250
cli/src/utils/sessionManager.ts
Normal file
250
cli/src/utils/sessionManager.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
SessionConfig,
|
||||
SessionStatus,
|
||||
SessionListOptions
|
||||
} from '../types/session';
|
||||
import { DockerUtils } from './dockerUtils';
|
||||
|
||||
/**
|
||||
* Session manager for storing and retrieving Claude session data
|
||||
*/
|
||||
export class SessionManager {
|
||||
private sessionsDir: string;
|
||||
private dockerUtils: DockerUtils;
|
||||
|
||||
constructor() {
|
||||
// Store sessions in ~/.claude-hub/sessions
|
||||
this.sessionsDir = path.join(os.homedir(), '.claude-hub', 'sessions');
|
||||
this.ensureSessionsDirectory();
|
||||
this.dockerUtils = new DockerUtils();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the sessions directory exists
|
||||
*/
|
||||
private ensureSessionsDirectory(): void {
|
||||
if (!fs.existsSync(this.sessionsDir)) {
|
||||
fs.mkdirSync(this.sessionsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new session ID
|
||||
*/
|
||||
generateSessionId(): string {
|
||||
return uuidv4().substring(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
createSession(sessionConfig: Omit<SessionConfig, 'id' | 'createdAt' | 'updatedAt'>): SessionConfig {
|
||||
const id = this.generateSessionId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const session: SessionConfig = {
|
||||
...sessionConfig,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
this.saveSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to disk
|
||||
*/
|
||||
saveSession(session: SessionConfig): void {
|
||||
const filePath = path.join(this.sessionsDir, `${session.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by ID
|
||||
*/
|
||||
getSession(id: string): SessionConfig | null {
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, `${id}.json`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(fileContent) as SessionConfig;
|
||||
} catch (error) {
|
||||
console.error(`Error reading session ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
*/
|
||||
updateSessionStatus(id: string, status: SessionStatus): boolean {
|
||||
const session = this.getSession(id);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.status = status;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
this.saveSession(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session
|
||||
*/
|
||||
deleteSession(id: string): boolean {
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, `${id}.json`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting session ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions with optional filtering
|
||||
*/
|
||||
async listSessions(options: SessionListOptions = {}): Promise<SessionConfig[]> {
|
||||
try {
|
||||
const files = fs.readdirSync(this.sessionsDir)
|
||||
.filter(file => file.endsWith('.json'));
|
||||
|
||||
let sessions = files.map(file => {
|
||||
const filePath = path.join(this.sessionsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(fileContent) as SessionConfig;
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
if (options.status) {
|
||||
sessions = sessions.filter(session => session.status === options.status);
|
||||
}
|
||||
|
||||
if (options.repo) {
|
||||
const repoFilter = options.repo;
|
||||
sessions = sessions.filter(session => session.repoFullName.includes(repoFilter));
|
||||
}
|
||||
|
||||
// Verify status of running sessions
|
||||
const runningSessionsToCheck = sessions.filter(session => session.status === 'running');
|
||||
await Promise.all(runningSessionsToCheck.map(async (session) => {
|
||||
const isRunning = await this.dockerUtils.isContainerRunning(session.containerId);
|
||||
if (!isRunning) {
|
||||
session.status = 'stopped';
|
||||
this.updateSessionStatus(session.id, 'stopped');
|
||||
}
|
||||
}));
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
// Apply limit if specified
|
||||
if (options.limit && options.limit > 0) {
|
||||
sessions = sessions.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('Error listing sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a session by recreating the container
|
||||
*/
|
||||
async recoverSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
const session = this.getSession(id);
|
||||
if (!session) {
|
||||
console.error(`Session ${id} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.status !== 'stopped') {
|
||||
console.error(`Session ${id} is not stopped (status: ${session.status})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate a new container name
|
||||
const containerName = `claude-hub-${session.id}-recovered`;
|
||||
|
||||
// Prepare environment variables for the container
|
||||
const envVars: Record<string, string> = {
|
||||
REPO_FULL_NAME: session.repoFullName,
|
||||
ISSUE_NUMBER: session.issueNumber ? String(session.issueNumber) : (session.prNumber ? String(session.prNumber) : ''),
|
||||
IS_PULL_REQUEST: session.isPullRequest ? 'true' : 'false',
|
||||
IS_ISSUE: session.isIssue ? 'true' : 'false',
|
||||
BRANCH_NAME: session.branchName || '',
|
||||
OPERATION_TYPE: 'default',
|
||||
COMMAND: session.command,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN || '',
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '',
|
||||
BOT_USERNAME: process.env.BOT_USERNAME || 'ClaudeBot',
|
||||
BOT_EMAIL: process.env.BOT_EMAIL || 'claude@example.com'
|
||||
};
|
||||
|
||||
// Start the container
|
||||
const containerId = await this.dockerUtils.startContainer(
|
||||
containerName,
|
||||
envVars,
|
||||
session.resourceLimits
|
||||
);
|
||||
|
||||
if (!containerId) {
|
||||
console.error('Failed to start container for session recovery');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update session with new container ID and status
|
||||
session.containerId = containerId;
|
||||
session.status = 'running';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
this.saveSession(session);
|
||||
|
||||
console.log(`Session ${id} recovered with new container ID: ${containerId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error recovering session ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize session status with container status
|
||||
* Updates session statuses based on actual container states
|
||||
*/
|
||||
async syncSessionStatuses(): Promise<void> {
|
||||
try {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (session.status === 'running') {
|
||||
const isRunning = await this.dockerUtils.isContainerRunning(session.containerId);
|
||||
if (!isRunning) {
|
||||
session.status = 'stopped';
|
||||
this.updateSessionStatus(session.id, 'stopped');
|
||||
console.log(`Updated session ${session.id} status from running to stopped (container not found)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing session statuses:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
cli/tsconfig.json
Normal file
16
cli/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3819
coverage-combined/lcov.info
Normal file
3819
coverage-combined/lcov.info
Normal file
File diff suppressed because it is too large
Load Diff
68
docker-compose.test.yml
Normal file
68
docker-compose.test.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Test runner service - runs tests in container
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: test
|
||||
cache_from:
|
||||
- ${DOCKER_HUB_ORGANIZATION:-intelligenceassist}/claude-hub:test-cache
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- CI=true
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN:-test-token}
|
||||
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET:-test-secret}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-test-key}
|
||||
volumes:
|
||||
- ./coverage:/app/coverage
|
||||
# Run only unit tests in CI (no e2e tests that require Docker)
|
||||
command: npm run test:unit
|
||||
|
||||
# Integration test service
|
||||
integration-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: test
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- CI=true
|
||||
- TEST_SUITE=integration
|
||||
volumes:
|
||||
- ./coverage:/app/coverage
|
||||
command: npm run test:integration
|
||||
depends_on:
|
||||
- webhook
|
||||
|
||||
# Webhook service for integration testing
|
||||
webhook:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- PORT=3002
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN:-test-token}
|
||||
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET:-test-secret}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-test-key}
|
||||
ports:
|
||||
- "3002:3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3002/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# E2E test service - removed from CI, use for local development only
|
||||
# To run e2e tests locally with Docker access:
|
||||
# docker compose -f docker-compose.test.yml run --rm -v /var/run/docker.sock:/var/run/docker.sock e2e-test
|
||||
|
||||
# Networks
|
||||
networks:
|
||||
default:
|
||||
name: claude-hub-test
|
||||
driver: bridge
|
||||
@@ -2,55 +2,43 @@ services:
|
||||
webhook:
|
||||
build: .
|
||||
ports:
|
||||
- "8082:3002"
|
||||
- "${PORT:-3002}:${PORT:-3002}"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${HOME}/.aws:/root/.aws:ro
|
||||
- ${HOME}/.claude:/home/claudeuser/.claude
|
||||
secrets:
|
||||
- github_token
|
||||
- anthropic_api_key
|
||||
- webhook_secret
|
||||
- ${HOME}/.claude-hub:/home/node/.claude
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3002
|
||||
- PORT=${PORT:-3002}
|
||||
- TRUST_PROXY=${TRUST_PROXY:-true}
|
||||
- AUTHORIZED_USERS=${AUTHORIZED_USERS:-Cheffromspace}
|
||||
- BOT_USERNAME=${BOT_USERNAME:-@MCPClaude}
|
||||
- BOT_EMAIL=${BOT_EMAIL:-claude@example.com}
|
||||
- DEFAULT_GITHUB_OWNER=${DEFAULT_GITHUB_OWNER:-Cheffromspace}
|
||||
- DEFAULT_GITHUB_USER=${DEFAULT_GITHUB_USER:-Cheffromspace}
|
||||
- DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}
|
||||
- CLAUDE_USE_CONTAINERS=1
|
||||
- CLAUDE_CONTAINER_IMAGE=claudecode:latest
|
||||
- CLAUDE_AUTH_HOST_DIR=${CLAUDE_AUTH_HOST_DIR:-${HOME}/.claude-hub}
|
||||
- DISABLE_LOG_REDACTION=true
|
||||
# Claude Code timeout settings for unattended mode
|
||||
- BASH_DEFAULT_TIMEOUT_MS=${BASH_DEFAULT_TIMEOUT_MS:-600000} # 10 minutes default
|
||||
- BASH_MAX_TIMEOUT_MS=${BASH_MAX_TIMEOUT_MS:-1200000} # 20 minutes max
|
||||
# Smart wait for all meaningful checks by default, or use specific workflow trigger
|
||||
- PR_REVIEW_WAIT_FOR_ALL_CHECKS=${PR_REVIEW_WAIT_FOR_ALL_CHECKS:-true}
|
||||
- PR_REVIEW_TRIGGER_WORKFLOW=${PR_REVIEW_TRIGGER_WORKFLOW:-}
|
||||
- PR_REVIEW_DEBOUNCE_MS=${PR_REVIEW_DEBOUNCE_MS:-5000}
|
||||
- PR_REVIEW_MAX_WAIT_MS=${PR_REVIEW_MAX_WAIT_MS:-1800000}
|
||||
- PR_REVIEW_CONDITIONAL_TIMEOUT_MS=${PR_REVIEW_CONDITIONAL_TIMEOUT_MS:-300000}
|
||||
# Point to secret files instead of env vars
|
||||
- GITHUB_TOKEN_FILE=/run/secrets/github_token
|
||||
- ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key
|
||||
- GITHUB_WEBHOOK_SECRET_FILE=/run/secrets/webhook_secret
|
||||
# Secrets from environment variables
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
|
||||
- CLAUDE_WEBHOOK_SECRET=${CLAUDE_WEBHOOK_SECRET}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3002/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3002}/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- n8n_default
|
||||
|
||||
secrets:
|
||||
github_token:
|
||||
file: ./secrets/github_token.txt
|
||||
anthropic_api_key:
|
||||
file: ./secrets/anthropic_api_key.txt
|
||||
webhook_secret:
|
||||
file: ./secrets/webhook_secret.txt
|
||||
|
||||
networks:
|
||||
n8n_default:
|
||||
external: true
|
||||
start_period: 10s
|
||||
@@ -1,121 +0,0 @@
|
||||
# Discord Chatbot Provider Setup
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation provides a comprehensive chatbot provider system that integrates Claude with Discord using slash commands. The system requires repository and branch parameters to function properly.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **ChatbotProvider.js**: Abstract base class for all chatbot providers
|
||||
- **DiscordProvider.js**: Discord-specific implementation with Ed25519 signature verification
|
||||
- **ProviderFactory.js**: Dependency injection singleton for managing providers
|
||||
- **chatbotController.js**: Generic webhook handler working with any provider
|
||||
- **chatbot.js**: Express routes with rate limiting
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_discord_public_key
|
||||
DISCORD_APPLICATION_ID=your_discord_application_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2,admin
|
||||
DISCORD_BOT_MENTION=claude
|
||||
```
|
||||
|
||||
## Discord Slash Command Configuration
|
||||
|
||||
In the Discord Developer Portal, create a slash command with these parameters:
|
||||
|
||||
- **Command Name**: `claude`
|
||||
- **Description**: `Ask Claude to help with repository tasks`
|
||||
- **Parameters**:
|
||||
- `repo` (required, string): Repository in format "owner/name"
|
||||
- `branch` (optional, string): Git branch name (defaults to "main")
|
||||
- `command` (required, string): Command for Claude to execute
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/webhooks/chatbot/discord` - Discord webhook handler (rate limited: 100 req/15min per IP)
|
||||
- `GET /api/webhooks/chatbot/stats` - Provider statistics and status
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```
|
||||
/claude repo:owner/myrepo command:help me fix this bug
|
||||
/claude repo:owner/myrepo branch:feature command:review this code
|
||||
/claude repo:owner/myrepo command:add error handling to this function
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- Ed25519 webhook signature verification
|
||||
- User authorization checking
|
||||
- Repository parameter validation
|
||||
- Rate limiting (100 requests per 15 minutes per IP)
|
||||
- Container isolation for Claude execution
|
||||
- Input sanitization and validation
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Set up environment variables in `.env`:
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_token
|
||||
DISCORD_PUBLIC_KEY=your_public_key
|
||||
DISCORD_APPLICATION_ID=your_app_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2
|
||||
```
|
||||
|
||||
3. Configure Discord slash command in Developer Portal
|
||||
|
||||
4. Start the server:
|
||||
```bash
|
||||
npm start
|
||||
# or for development
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all unit tests
|
||||
npm run test:unit
|
||||
|
||||
# Run specific provider tests
|
||||
npm test -- test/unit/providers/DiscordProvider.test.js
|
||||
|
||||
# Run controller tests
|
||||
npm test -- test/unit/controllers/chatbotController.test.js
|
||||
```
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
1. **Repository Parameter Validation**: Commands require a `repo` parameter in "owner/name" format
|
||||
2. **Branch Support**: Optional `branch` parameter (defaults to "main")
|
||||
3. **Error Handling**: Comprehensive error messages with reference IDs
|
||||
4. **Rate Limiting**: Protection against abuse with express-rate-limit
|
||||
5. **Message Splitting**: Automatic splitting for Discord's 2000 character limit
|
||||
6. **Comprehensive Testing**: 35+ unit tests covering all scenarios
|
||||
|
||||
## Workflow
|
||||
|
||||
1. User executes Discord slash command: `/claude repo:owner/myrepo command:fix this issue`
|
||||
2. Discord sends webhook to `/api/webhooks/chatbot/discord`
|
||||
3. System verifies signature and parses payload
|
||||
4. Repository parameter is validated (required)
|
||||
5. Branch parameter is extracted (defaults to "main")
|
||||
6. User authorization is checked
|
||||
7. Command is processed by Claude with repository context
|
||||
8. Response is sent back to Discord (automatically split if needed)
|
||||
|
||||
## Extension Points
|
||||
|
||||
The architecture supports easy addition of new platforms:
|
||||
- Implement new provider class extending ChatbotProvider
|
||||
- Add environment configuration in ProviderFactory
|
||||
- Register provider and add route handler
|
||||
- System automatically handles authentication, validation, and Claude integration
|
||||
102
docs/SCRIPTS.md
102
docs/SCRIPTS.md
@@ -9,25 +9,20 @@ This document provides an overview of the scripts in this repository, organized
|
||||
| `scripts/setup/setup.sh` | Main setup script for the project | `./scripts/setup/setup.sh` |
|
||||
| `scripts/setup/setup-precommit.sh` | Sets up pre-commit hooks | `./scripts/setup/setup-precommit.sh` |
|
||||
| `scripts/setup/setup-claude-auth.sh` | Sets up Claude authentication | `./scripts/setup/setup-claude-auth.sh` |
|
||||
| `scripts/setup/setup-new-repo.sh` | Sets up a new clean repository | `./scripts/setup/setup-new-repo.sh` |
|
||||
| `scripts/setup/create-new-repo.sh` | Creates a new repository | `./scripts/setup/create-new-repo.sh` |
|
||||
| `scripts/setup/setup-secure-credentials.sh` | Sets up secure credentials | `./scripts/setup/setup-secure-credentials.sh` |
|
||||
|
||||
## Build Scripts
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `scripts/build/build-claude-container.sh` | Builds the Claude container | `./scripts/build/build-claude-container.sh` |
|
||||
| `scripts/build/build-claudecode.sh` | Builds the Claude Code runner Docker image | `./scripts/build/build-claudecode.sh` |
|
||||
| `scripts/build/update-production-image.sh` | Updates the production Docker image | `./scripts/build/update-production-image.sh` |
|
||||
| `scripts/build/build.sh` | Builds the Docker images | `./scripts/build/build.sh` |
|
||||
|
||||
## AWS Configuration and Credentials
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `scripts/aws/create-aws-profile.sh` | Creates AWS profiles programmatically | `./scripts/aws/create-aws-profile.sh <profile-name> <access-key-id> <secret-access-key> [region] [output-format]` |
|
||||
| `scripts/aws/migrate-aws-credentials.sh` | Migrates AWS credentials to profiles | `./scripts/aws/migrate-aws-credentials.sh` |
|
||||
| `scripts/aws/setup-aws-profiles.sh` | Sets up AWS profiles | `./scripts/aws/setup-aws-profiles.sh` |
|
||||
| `scripts/aws/update-aws-creds.sh` | Updates AWS credentials | `./scripts/aws/update-aws-creds.sh` |
|
||||
|
||||
## Runtime and Execution
|
||||
|
||||
@@ -45,58 +40,48 @@ This document provides an overview of the scripts in this repository, organized
|
||||
|--------|-------------|-------|
|
||||
| `scripts/security/init-firewall.sh` | Initializes firewall for containers | `./scripts/security/init-firewall.sh` |
|
||||
| `scripts/security/accept-permissions.sh` | Handles permission acceptance | `./scripts/security/accept-permissions.sh` |
|
||||
| `scripts/security/fix-credential-references.sh` | Fixes credential references | `./scripts/security/fix-credential-references.sh` |
|
||||
| `scripts/security/credential-audit.sh` | Audits code for credential leaks | `./scripts/security/credential-audit.sh` |
|
||||
|
||||
## Utility Scripts
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `scripts/utils/ensure-test-dirs.sh` | Ensures test directories exist | `./scripts/utils/ensure-test-dirs.sh` |
|
||||
| `scripts/utils/prepare-clean-repo.sh` | Prepares a clean repository | `./scripts/utils/prepare-clean-repo.sh` |
|
||||
| `scripts/utils/volume-test.sh` | Tests volume mounting | `./scripts/utils/volume-test.sh` |
|
||||
| `scripts/utils/setup-repository-labels.js` | Sets up GitHub repository labels | `node scripts/utils/setup-repository-labels.js owner/repo` |
|
||||
|
||||
## Testing Scripts
|
||||
## Testing
|
||||
|
||||
### Integration Tests
|
||||
All shell-based test scripts have been migrated to JavaScript E2E tests using Jest. Use the following npm commands:
|
||||
|
||||
| Script | Description | Usage |
|
||||
### JavaScript Test Files
|
||||
|
||||
**Note**: Shell-based test scripts have been migrated to JavaScript E2E tests using Jest. The following test files provide comprehensive testing:
|
||||
|
||||
| Test File | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/integration/test-full-flow.sh` | Tests the full workflow | `./test/integration/test-full-flow.sh` |
|
||||
| `test/integration/test-claudecode-docker.sh` | Tests Claude Code Docker setup | `./test/integration/test-claudecode-docker.sh` |
|
||||
| `test/e2e/scenarios/container-execution.test.js` | Tests container functionality | `npm run test:e2e` |
|
||||
| `test/e2e/scenarios/claude-integration.test.js` | Tests Claude integration | `npm run test:e2e` |
|
||||
| `test/e2e/scenarios/docker-execution.test.js` | Tests Docker execution | `npm run test:e2e` |
|
||||
| `test/e2e/scenarios/security-firewall.test.js` | Tests security and firewall | `npm run test:e2e` |
|
||||
|
||||
### AWS Tests
|
||||
### Running Tests
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/aws/test-aws-profile.sh` | Tests AWS profile configuration | `./test/aws/test-aws-profile.sh` |
|
||||
| `test/aws/test-aws-mount.sh` | Tests AWS mount functionality | `./test/aws/test-aws-mount.sh` |
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
### Container Tests
|
||||
# Run unit tests
|
||||
npm run test:unit
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/container/test-basic-container.sh` | Tests basic container functionality | `./test/container/test-basic-container.sh` |
|
||||
| `test/container/test-container-cleanup.sh` | Tests container cleanup | `./test/container/test-container-cleanup.sh` |
|
||||
| `test/container/test-container-privileged.sh` | Tests container privileged mode | `./test/container/test-container-privileged.sh` |
|
||||
# Run E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
### Claude Tests
|
||||
# Run tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/claude/test-claude-direct.sh` | Tests direct Claude integration | `./test/claude/test-claude-direct.sh` |
|
||||
| `test/claude/test-claude-no-firewall.sh` | Tests Claude without firewall | `./test/claude/test-claude-no-firewall.sh` |
|
||||
| `test/claude/test-claude-installation.sh` | Tests Claude installation | `./test/claude/test-claude-installation.sh` |
|
||||
| `test/claude/test-claude-version.sh` | Tests Claude version | `./test/claude/test-claude-version.sh` |
|
||||
| `test/claude/test-claude-response.sh` | Tests Claude response | `./test/claude/test-claude-response.sh` |
|
||||
| `test/claude/test-direct-claude.sh` | Tests direct Claude access | `./test/claude/test-direct-claude.sh` |
|
||||
|
||||
### Security Tests
|
||||
|
||||
| Script | Description | Usage |
|
||||
|--------|-------------|-------|
|
||||
| `test/security/test-firewall.sh` | Tests firewall configuration | `./test/security/test-firewall.sh` |
|
||||
| `test/security/test-with-auth.sh` | Tests with authentication | `./test/security/test-with-auth.sh` |
|
||||
| `test/security/test-github-token.sh` | Tests GitHub token | `./test/security/test-github-token.sh` |
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
@@ -109,6 +94,9 @@ This document provides an overview of the scripts in this repository, organized
|
||||
# Set up Claude authentication
|
||||
./scripts/setup/setup-claude-auth.sh
|
||||
|
||||
# Set up secure credentials
|
||||
./scripts/setup/setup-secure-credentials.sh
|
||||
|
||||
# Create AWS profile
|
||||
./scripts/aws/create-aws-profile.sh claude-webhook YOUR_ACCESS_KEY YOUR_SECRET_KEY
|
||||
```
|
||||
@@ -116,8 +104,8 @@ This document provides an overview of the scripts in this repository, organized
|
||||
### Building and Running
|
||||
|
||||
```bash
|
||||
# Build Claude Code container
|
||||
./scripts/build/build-claudecode.sh
|
||||
# Build Docker images
|
||||
./scripts/build/build.sh
|
||||
|
||||
# Start the API server
|
||||
./scripts/runtime/start-api.sh
|
||||
@@ -129,22 +117,18 @@ docker compose up -d
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run integration tests
|
||||
./test/integration/test-full-flow.sh
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run AWS tests
|
||||
./test/aws/test-aws-profile.sh
|
||||
# Run E2E tests specifically
|
||||
npm run test:e2e
|
||||
|
||||
# Run Claude tests
|
||||
./test/claude/test-claude-direct.sh
|
||||
# Run unit tests specifically
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
## Notes
|
||||
|
||||
For backward compatibility, wrapper scripts are provided in the root directory for the most commonly used scripts:
|
||||
|
||||
- `setup-claude-auth.sh` -> `scripts/setup/setup-claude-auth.sh`
|
||||
- `build-claudecode.sh` -> `scripts/build/build-claudecode.sh`
|
||||
- `start-api.sh` -> `scripts/runtime/start-api.sh`
|
||||
|
||||
These wrappers simply forward all arguments to the actual scripts in their new locations.
|
||||
- All shell-based test scripts have been migrated to JavaScript E2E tests for better maintainability and consistency.
|
||||
- The project uses npm scripts for most common operations. See `package.json` for available scripts.
|
||||
- Docker Compose is the recommended way to run the service in production.
|
||||
@@ -1,220 +0,0 @@
|
||||
# Chatbot Providers Documentation
|
||||
|
||||
This document describes the chatbot provider system that enables Claude to work with Discord using dependency injection and configuration-based selection. The system is designed with an extensible architecture that can support future platforms.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The chatbot provider system uses a flexible architecture with:
|
||||
|
||||
- **Base Provider Interface**: Common contract for all chatbot providers (`ChatbotProvider.js`)
|
||||
- **Provider Implementations**: Platform-specific implementations (currently Discord only)
|
||||
- **Provider Factory**: Dependency injection container for managing providers (`ProviderFactory.js`)
|
||||
- **Generic Controller**: Unified webhook handling logic (`chatbotController.js`)
|
||||
- **Route Integration**: Clean API endpoints for each provider
|
||||
|
||||
## Available Providers
|
||||
|
||||
### Discord Provider
|
||||
**Status**: ✅ Implemented
|
||||
**Endpoint**: `POST /api/webhooks/chatbot/discord`
|
||||
|
||||
Features:
|
||||
- Ed25519 signature verification
|
||||
- Slash command support
|
||||
- Interactive component handling
|
||||
- Message splitting for 2000 character limit
|
||||
- Follow-up message support
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Discord
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_discord_public_key
|
||||
DISCORD_APPLICATION_ID=your_discord_application_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2,admin
|
||||
DISCORD_BOT_MENTION=claude
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Webhook Endpoints
|
||||
|
||||
- `POST /api/webhooks/chatbot/discord` - Discord webhook handler
|
||||
|
||||
### Management Endpoints
|
||||
|
||||
- `GET /api/webhooks/chatbot/stats` - Provider statistics and status
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Discord Setup
|
||||
|
||||
1. **Create Discord Application**
|
||||
- Go to https://discord.com/developers/applications
|
||||
- Create a new application
|
||||
- Copy Application ID, Bot Token, and Public Key
|
||||
|
||||
2. **Configure Webhook**
|
||||
- Set webhook URL to `https://your-domain.com/api/webhooks/chatbot/discord`
|
||||
- Configure slash commands in Discord Developer Portal
|
||||
|
||||
3. **Environment Setup**
|
||||
```bash
|
||||
DISCORD_BOT_TOKEN=your_bot_token
|
||||
DISCORD_PUBLIC_KEY=your_public_key
|
||||
DISCORD_APPLICATION_ID=your_app_id
|
||||
DISCORD_AUTHORIZED_USERS=user1,user2
|
||||
```
|
||||
|
||||
4. **Configure Discord Slash Command**
|
||||
Create a slash command in Discord Developer Portal with these parameters:
|
||||
- **Command Name**: `claude`
|
||||
- **Description**: `Ask Claude to help with repository tasks`
|
||||
- **Parameters**:
|
||||
- `repo` (required): Repository in format "owner/name"
|
||||
- `branch` (optional): Git branch name (defaults to "main")
|
||||
- `command` (required): Command for Claude to execute
|
||||
|
||||
5. **Test the Bot**
|
||||
- Use slash commands: `/claude repo:owner/myrepo command:help me fix this bug`
|
||||
- Optional branch: `/claude repo:owner/myrepo branch:feature command:review this code`
|
||||
- Bot responds directly in Discord channel
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
To add a new chatbot provider in the future:
|
||||
|
||||
1. **Create Provider Class**
|
||||
```javascript
|
||||
// src/providers/NewProvider.js
|
||||
const ChatbotProvider = require('./ChatbotProvider');
|
||||
|
||||
class NewProvider extends ChatbotProvider {
|
||||
async initialize() {
|
||||
// Provider-specific initialization
|
||||
}
|
||||
|
||||
verifyWebhookSignature(req) {
|
||||
// Platform-specific signature verification
|
||||
}
|
||||
|
||||
parseWebhookPayload(payload) {
|
||||
// Parse platform-specific payload
|
||||
}
|
||||
|
||||
// Implement all required methods...
|
||||
}
|
||||
|
||||
module.exports = NewProvider;
|
||||
```
|
||||
|
||||
2. **Register Provider**
|
||||
```javascript
|
||||
// src/providers/ProviderFactory.js
|
||||
const NewProvider = require('./NewProvider');
|
||||
|
||||
// In constructor:
|
||||
this.registerProvider('newprovider', NewProvider);
|
||||
```
|
||||
|
||||
3. **Add Route Handler**
|
||||
```javascript
|
||||
// src/controllers/chatbotController.js
|
||||
async function handleNewProviderWebhook(req, res) {
|
||||
return await handleChatbotWebhook(req, res, 'newprovider');
|
||||
}
|
||||
```
|
||||
|
||||
4. **Add Environment Config**
|
||||
```javascript
|
||||
// In ProviderFactory.js getEnvironmentConfig():
|
||||
case 'newprovider':
|
||||
config.apiKey = process.env.NEWPROVIDER_API_KEY;
|
||||
config.secret = process.env.NEWPROVIDER_SECRET;
|
||||
// Add other config...
|
||||
break;
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### Webhook Verification
|
||||
The Discord provider implements Ed25519 signature verification for secure webhook authentication.
|
||||
|
||||
### User Authorization
|
||||
- Configurable authorized user lists for Discord
|
||||
- Discord-specific user ID validation
|
||||
- Graceful handling of unauthorized access attempts
|
||||
|
||||
### Container Security
|
||||
- Isolated execution environment for Claude commands
|
||||
- Resource limits and capability restrictions
|
||||
- Secure credential management
|
||||
|
||||
## Provider Factory
|
||||
|
||||
The `ProviderFactory` manages provider instances using dependency injection:
|
||||
|
||||
```javascript
|
||||
const providerFactory = require('./providers/ProviderFactory');
|
||||
|
||||
// Create provider from environment
|
||||
const discord = await providerFactory.createFromEnvironment('discord');
|
||||
|
||||
// Get existing provider
|
||||
const provider = providerFactory.getProvider('discord');
|
||||
|
||||
// Get statistics
|
||||
const stats = providerFactory.getStats();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system provides comprehensive error handling:
|
||||
|
||||
- **Provider Initialization Errors**: Graceful fallback and logging
|
||||
- **Webhook Verification Failures**: Clear error responses
|
||||
- **Command Processing Errors**: User-friendly error messages with reference IDs
|
||||
- **Network/API Errors**: Automatic retry logic where appropriate
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### Logging
|
||||
The Discord provider uses structured logging with:
|
||||
- Provider name identification
|
||||
- Request/response tracking
|
||||
- Error correlation IDs
|
||||
- Performance metrics
|
||||
|
||||
### Statistics Endpoint
|
||||
The `/api/webhooks/chatbot/stats` endpoint provides:
|
||||
- Provider registration status
|
||||
- Initialization health
|
||||
- Basic configuration info (non-sensitive)
|
||||
|
||||
### Health Checks
|
||||
The provider can be health-checked to ensure proper operation.
|
||||
|
||||
## Extensible Architecture
|
||||
|
||||
While only Discord is currently implemented, the system is designed to easily support additional platforms:
|
||||
|
||||
- **Modular Design**: Each provider is self-contained with common interfaces
|
||||
- **Dependency Injection**: Clean separation between provider logic and application code
|
||||
- **Configuration-Driven**: Environment-based provider selection and configuration
|
||||
- **Unified Webhook Handling**: Common controller logic with platform-specific implementations
|
||||
- **Standardized Security**: Consistent signature verification and authorization patterns
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
The extensible architecture enables future enhancements such as:
|
||||
|
||||
- **Additional Platforms**: Easy integration of new chat platforms
|
||||
- **Message Threading**: Support for threaded conversations
|
||||
- **Rich Media**: File attachments and embeds
|
||||
- **Interactive Components**: Buttons, dropdowns, forms
|
||||
- **Multi-provider Commands**: Cross-platform functionality
|
||||
- **Provider Plugins**: Dynamic provider loading
|
||||
- **Advanced Authorization**: Role-based access control
|
||||
222
docs/claude-authentication-guide.md
Normal file
222
docs/claude-authentication-guide.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Claude Authentication Guide
|
||||
|
||||
This guide covers three authentication methods for using Claude with the webhook service.
|
||||
|
||||
## Authentication Methods Overview
|
||||
|
||||
| Method | Use Case | Setup Complexity |
|
||||
|--------|----------|------------------|
|
||||
| **Setup Container** | Personal development | Medium |
|
||||
| **ANTHROPIC_API_KEY** | Production environments | Low |
|
||||
| **AWS Bedrock** | Enterprise integration | High |
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Option 1: Setup Container (Personal Development)
|
||||
|
||||
Uses personal Claude Code subscription for authentication.
|
||||
|
||||
### Setup Process
|
||||
|
||||
#### 1. Run Interactive Authentication Setup
|
||||
```bash
|
||||
./scripts/setup/setup-claude-interactive.sh
|
||||
```
|
||||
|
||||
#### 2. Authenticate in Container
|
||||
When the container starts:
|
||||
```bash
|
||||
# In the container shell:
|
||||
claude --dangerously-skip-permissions # Follow authentication flow
|
||||
exit # Save authentication state
|
||||
```
|
||||
|
||||
#### 3. Test Captured Authentication
|
||||
```bash
|
||||
./scripts/setup/test-claude-auth.sh
|
||||
```
|
||||
|
||||
#### 4. Use Captured Authentication
|
||||
```bash
|
||||
# Option A: Copy to your main Claude directory
|
||||
cp -r ${CLAUDE_HUB_DIR:-~/.claude-hub}/* ~/.claude/
|
||||
|
||||
# Option B: Mount in docker-compose
|
||||
# Update docker-compose.yml:
|
||||
# - ./${CLAUDE_HUB_DIR:-~/.claude-hub}:/home/node/.claude
|
||||
```
|
||||
|
||||
#### 5. Verify Setup
|
||||
```bash
|
||||
node cli/webhook-cli.js --repo "owner/repo" --command "Test authentication" --url "http://localhost:8082"
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
- **Tokens expire**: Re-run authentication setup when needed
|
||||
- **File permissions**: Ensure `.credentials.json` is readable by container user
|
||||
- **Mount issues**: Verify correct path in docker-compose volume mounts
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Option 2: ANTHROPIC_API_KEY (Production)
|
||||
|
||||
Direct API key authentication for production environments.
|
||||
|
||||
### Setup Process
|
||||
|
||||
#### 1. Get API Key
|
||||
1. Go to [Anthropic Console](https://console.anthropic.com/)
|
||||
2. Create a new API key
|
||||
3. Copy the key (starts with `sk-ant-`)
|
||||
|
||||
#### 2. Configure Environment
|
||||
```bash
|
||||
# Add to .env file
|
||||
ANTHROPIC_API_KEY=sk-ant-your-api-key-here
|
||||
```
|
||||
|
||||
#### 3. Restart Service
|
||||
```bash
|
||||
docker compose restart webhook
|
||||
```
|
||||
|
||||
#### 4. Test
|
||||
```bash
|
||||
node cli/webhook-cli.js --repo "owner/repo" --command "Test API key authentication" --url "http://localhost:8082"
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
- **Key rotation**: Regularly rotate API keys
|
||||
- **Environment security**: Never commit keys to version control
|
||||
- **Usage monitoring**: Monitor API usage through Anthropic Console
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Option 3: AWS Bedrock (Enterprise)
|
||||
|
||||
AWS-integrated Claude access for enterprise deployments.
|
||||
|
||||
### Setup Process
|
||||
|
||||
#### 1. Configure AWS Credentials
|
||||
```bash
|
||||
# Option A: AWS Profile (Recommended)
|
||||
./scripts/aws/create-aws-profile.sh
|
||||
|
||||
# Option B: Environment Variables
|
||||
export AWS_ACCESS_KEY_ID=your_access_key
|
||||
export AWS_SECRET_ACCESS_KEY=your_secret_key
|
||||
export AWS_REGION=us-east-1
|
||||
```
|
||||
|
||||
#### 2. Configure Bedrock Settings
|
||||
```bash
|
||||
# Add to .env file
|
||||
CLAUDE_CODE_USE_BEDROCK=1
|
||||
ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0
|
||||
AWS_REGION=us-east-1
|
||||
|
||||
# If using profiles
|
||||
USE_AWS_PROFILE=true
|
||||
AWS_PROFILE=claude-webhook
|
||||
```
|
||||
|
||||
#### 3. Verify Bedrock Access
|
||||
```bash
|
||||
aws bedrock list-foundation-models --region us-east-1
|
||||
```
|
||||
|
||||
#### 4. Restart Service
|
||||
```bash
|
||||
docker compose restart webhook
|
||||
```
|
||||
|
||||
#### 5. Test
|
||||
```bash
|
||||
node cli/webhook-cli.js --repo "owner/repo" --command "Test Bedrock authentication" --url "http://localhost:8082"
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
- **IAM policies**: Use minimal required permissions
|
||||
- **Regional selection**: Choose appropriate AWS region
|
||||
- **Access logging**: Enable CloudTrail for audit compliance
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Authentication Priority and Fallback
|
||||
|
||||
The system checks authentication methods in this order:
|
||||
|
||||
1. **ANTHROPIC_API_KEY** (highest priority)
|
||||
2. **Claude Interactive Authentication** (setup container)
|
||||
3. **AWS Bedrock** (if configured)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Method 1: Direct API Key
|
||||
ANTHROPIC_API_KEY=sk-ant-your-key
|
||||
|
||||
# Method 2: Claude Interactive (automatic if ~/.claude is mounted)
|
||||
# No environment variables needed
|
||||
|
||||
# Method 3: AWS Bedrock
|
||||
CLAUDE_CODE_USE_BEDROCK=1
|
||||
ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_key
|
||||
# OR
|
||||
USE_AWS_PROFILE=true
|
||||
AWS_PROFILE=your-profile-name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Switching Between Methods
|
||||
|
||||
You can switch between authentication methods by updating your `.env` file:
|
||||
|
||||
```bash
|
||||
# Development with personal subscription
|
||||
# Comment out API key, ensure ~/.claude is mounted
|
||||
# ANTHROPIC_API_KEY=
|
||||
# Mount: ~/.claude:/home/node/.claude
|
||||
|
||||
# Production with API key
|
||||
ANTHROPIC_API_KEY=sk-ant-your-production-key
|
||||
|
||||
# Enterprise with Bedrock
|
||||
CLAUDE_CODE_USE_BEDROCK=1
|
||||
ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0
|
||||
USE_AWS_PROFILE=true
|
||||
AWS_PROFILE=production-claude
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Authentication Not Working
|
||||
1. Check environment variables are set correctly
|
||||
2. Verify API keys are valid and not expired
|
||||
3. For Bedrock: Ensure AWS credentials have correct permissions
|
||||
4. For setup container: Re-run authentication if tokens expired
|
||||
|
||||
### Rate Limiting
|
||||
- **API Key**: Contact Anthropic for rate limit information
|
||||
- **Bedrock**: Configure AWS throttling settings
|
||||
- **Setup Container**: Limited by subscription tier
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Anthropic Console](https://console.anthropic.com/) - API key management
|
||||
- [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/) - Enterprise setup
|
||||
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code) - Official Claude CLI docs
|
||||
- [Setup Container Deep Dive](./setup-container-guide.md) - Detailed setup container documentation
|
||||
|
||||
---
|
||||
|
||||
*This guide covers all authentication methods for the Claude GitHub Webhook service. Choose the method that best fits your technical requirements.*
|
||||
524
docs/claude-orchestration.md
Normal file
524
docs/claude-orchestration.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# Claude Orchestration Provider
|
||||
|
||||
The Claude orchestration provider enables parallel execution of multiple Claude Code containers to solve complex tasks. This is designed for the MCP (Model Context Protocol) hackathon to demonstrate super-charged Claude capabilities.
|
||||
|
||||
## Overview
|
||||
|
||||
The orchestration system provides REST endpoints that can be wrapped as MCP Server tools, allowing Claude Desktop (or other MCP clients) to:
|
||||
- Create and manage individual Claude Code sessions
|
||||
- Start sessions with specific requirements and dependencies
|
||||
- Monitor session status and retrieve outputs
|
||||
- Orchestrate complex multi-session workflows intelligently
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
POST /api/webhooks/claude
|
||||
├── ClaudeWebhookProvider (webhook handling)
|
||||
├── OrchestrationHandler (orchestration logic)
|
||||
├── SessionManager (container lifecycle)
|
||||
└── TaskDecomposer (task analysis)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Session Management Endpoints
|
||||
|
||||
All endpoints use the base URL: `POST /api/webhooks/claude`
|
||||
|
||||
**Headers (for all requests):**
|
||||
```
|
||||
Authorization: Bearer <CLAUDE_WEBHOOK_SECRET>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
#### 1. Create Session
|
||||
|
||||
Create a new Claude Code session without starting it.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "session.create",
|
||||
"session": {
|
||||
"type": "implementation",
|
||||
"project": {
|
||||
"repository": "owner/repo",
|
||||
"branch": "feature-branch",
|
||||
"requirements": "Implement user authentication with JWT",
|
||||
"context": "Use existing Express framework"
|
||||
},
|
||||
"dependencies": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session created successfully",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": "uuid-123",
|
||||
"type": "implementation",
|
||||
"status": "initializing",
|
||||
"containerId": "claude-implementation-abc123",
|
||||
"project": { ... },
|
||||
"dependencies": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Start Session
|
||||
|
||||
Start a previously created session or queue it if dependencies aren't met.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "session.start",
|
||||
"sessionId": "uuid-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Get Session Status
|
||||
|
||||
Retrieve current status and details of a session.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "session.get",
|
||||
"sessionId": "uuid-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Get Session Output
|
||||
|
||||
Retrieve the output and artifacts from a completed session.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "session.output",
|
||||
"sessionId": "uuid-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"sessionId": "uuid-123",
|
||||
"status": "completed",
|
||||
"output": {
|
||||
"logs": ["Created file: src/auth.js", "Implemented JWT validation"],
|
||||
"artifacts": [
|
||||
{ "type": "file", "path": "src/auth.js" },
|
||||
{ "type": "commit", "sha": "abc123def" }
|
||||
],
|
||||
"summary": "Implemented JWT authentication middleware",
|
||||
"nextSteps": ["Add refresh token support", "Implement rate limiting"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. List Sessions
|
||||
|
||||
List all sessions or filter by orchestration ID.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "session.list",
|
||||
"orchestrationId": "orch-uuid-456" // optional
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Orchestration Endpoint (Simplified)
|
||||
|
||||
Create a single orchestration session that can coordinate other sessions via MCP tools.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"type": "orchestrate",
|
||||
"sessionType": "coordination",
|
||||
"autoStart": false,
|
||||
"project": {
|
||||
"repository": "owner/repo",
|
||||
"requirements": "Orchestrate building a full-stack application with authentication"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Webhook processed",
|
||||
"event": "orchestrate",
|
||||
"handlerCount": 1,
|
||||
"results": [{
|
||||
"success": true,
|
||||
"message": "Orchestration initiated successfully",
|
||||
"data": {
|
||||
"orchestrationId": "uuid",
|
||||
"status": "initiated",
|
||||
"sessions": [
|
||||
{
|
||||
"id": "uuid-analysis",
|
||||
"type": "analysis",
|
||||
"status": "running",
|
||||
"containerId": "claude-analysis-xxxxx",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "uuid-impl-0",
|
||||
"type": "implementation",
|
||||
"status": "pending",
|
||||
"containerId": "claude-implementation-xxxxx",
|
||||
"dependencies": ["uuid-analysis"]
|
||||
}
|
||||
],
|
||||
"summary": "Started 4 Claude sessions for owner/repo"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `CLAUDE_WEBHOOK_SECRET`: Bearer token for webhook authentication
|
||||
- `CLAUDE_CONTAINER_IMAGE`: Docker image for Claude Code (default: `claudecode:latest`)
|
||||
- `GITHUB_TOKEN`: GitHub access token for repository operations
|
||||
- `ANTHROPIC_API_KEY`: Anthropic API key for Claude access
|
||||
|
||||
### Strategy Options
|
||||
|
||||
#### Dependency Modes
|
||||
|
||||
- **`parallel`**: Start all independent sessions simultaneously
|
||||
- **`sequential`**: Start sessions one by one in order
|
||||
- **`wait_for_core`**: Start analysis first, then implementation in parallel, then testing/review
|
||||
|
||||
#### Session Types
|
||||
|
||||
- **`analysis`**: Analyze project and create implementation plan
|
||||
- **`implementation`**: Write code based on requirements
|
||||
- **`testing`**: Create comprehensive tests
|
||||
- **`review`**: Review code and provide feedback
|
||||
- **`coordination`**: Meta-session for orchestrating others
|
||||
|
||||
## Task Decomposition
|
||||
|
||||
The system automatically analyzes requirements to identify components:
|
||||
|
||||
- **API/Backend**: REST endpoints, GraphQL, services
|
||||
- **Frontend**: UI, React, Vue, Angular components
|
||||
- **Authentication**: JWT, OAuth, security features
|
||||
- **Database**: Models, schemas, migrations
|
||||
- **Testing**: Unit tests, integration tests
|
||||
- **Deployment**: Docker, Kubernetes, CI/CD
|
||||
|
||||
Dependencies are automatically determined based on component relationships.
|
||||
|
||||
## Session Management
|
||||
|
||||
Each session runs in an isolated Docker container with:
|
||||
- Dedicated Claude Code instance
|
||||
- Access to repository via GitHub token
|
||||
- Environment variables for configuration
|
||||
- Automatic cleanup on completion
|
||||
|
||||
## Example Use Cases with MCP
|
||||
|
||||
### 1. Full-Stack Application Development
|
||||
|
||||
Claude Desktop orchestrating a complete application build:
|
||||
|
||||
```typescript
|
||||
// Claude Desktop's orchestration logic (pseudocode)
|
||||
async function buildFullStackApp(repo: string) {
|
||||
// 1. Create analysis session
|
||||
const analysisSession = await createClaudeSession({
|
||||
type: "analysis",
|
||||
repository: repo,
|
||||
requirements: "Analyze requirements and create architecture plan for task management app"
|
||||
});
|
||||
|
||||
await startClaudeSession(analysisSession.id);
|
||||
const analysisResult = await waitForCompletion(analysisSession.id);
|
||||
|
||||
// 2. Create parallel implementation sessions based on analysis
|
||||
const sessions = await Promise.all([
|
||||
createClaudeSession({
|
||||
type: "implementation",
|
||||
repository: repo,
|
||||
requirements: "Implement Express backend with PostgreSQL",
|
||||
dependencies: [analysisSession.id]
|
||||
}),
|
||||
createClaudeSession({
|
||||
type: "implementation",
|
||||
repository: repo,
|
||||
requirements: "Implement React frontend",
|
||||
dependencies: [analysisSession.id]
|
||||
}),
|
||||
createClaudeSession({
|
||||
type: "implementation",
|
||||
repository: repo,
|
||||
requirements: "Implement JWT authentication",
|
||||
dependencies: [analysisSession.id]
|
||||
})
|
||||
]);
|
||||
|
||||
// 3. Start all implementation sessions
|
||||
await Promise.all(sessions.map(s => startClaudeSession(s.id)));
|
||||
|
||||
// 4. Create testing session after implementations complete
|
||||
const testSession = await createClaudeSession({
|
||||
type: "testing",
|
||||
repository: repo,
|
||||
requirements: "Write comprehensive tests for all components",
|
||||
dependencies: sessions.map(s => s.id)
|
||||
});
|
||||
|
||||
// 5. Monitor and aggregate results
|
||||
const results = await gatherAllResults([...sessions, testSession]);
|
||||
return synthesizeResults(results);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Intelligent Bug Fix Workflow
|
||||
|
||||
```typescript
|
||||
// Claude Desktop adaptively handling a bug fix
|
||||
async function fixBugWithTests(repo: string, issueDescription: string) {
|
||||
// 1. Analyze the bug
|
||||
const analysisSession = await createClaudeSession({
|
||||
type: "analysis",
|
||||
repository: repo,
|
||||
requirements: `Analyze bug: ${issueDescription}`
|
||||
});
|
||||
|
||||
const analysis = await runAndGetOutput(analysisSession.id);
|
||||
|
||||
// 2. Decide strategy based on analysis
|
||||
if (analysis.complexity === "high") {
|
||||
// Complex bug: separate diagnosis and fix sessions
|
||||
await runDiagnosisFirst(repo, analysis);
|
||||
} else {
|
||||
// Simple bug: fix and test in parallel
|
||||
await runFixAndTestParallel(repo, analysis);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Progressive Enhancement Pattern
|
||||
|
||||
```typescript
|
||||
// Claude Desktop implementing features progressively
|
||||
async function enhanceAPI(repo: string, features: string[]) {
|
||||
let previousSessionId = null;
|
||||
|
||||
for (const feature of features) {
|
||||
const session = await createClaudeSession({
|
||||
type: "implementation",
|
||||
repository: repo,
|
||||
requirements: `Add ${feature} to the API`,
|
||||
dependencies: previousSessionId ? [previousSessionId] : []
|
||||
});
|
||||
|
||||
await startClaudeSession(session.id);
|
||||
await waitForCompletion(session.id);
|
||||
|
||||
// Run tests after each feature
|
||||
const testSession = await createClaudeSession({
|
||||
type: "testing",
|
||||
repository: repo,
|
||||
requirements: `Test ${feature} implementation`,
|
||||
dependencies: [session.id]
|
||||
});
|
||||
|
||||
await runAndVerify(testSession.id);
|
||||
previousSessionId = session.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## MCP Integration Guide
|
||||
|
||||
### Overview
|
||||
|
||||
The Claude orchestration system is designed to be wrapped as MCP Server tools, allowing Claude Desktop to orchestrate multiple Claude Code sessions intelligently.
|
||||
|
||||
### MCP Server Tool Examples
|
||||
|
||||
```typescript
|
||||
// Example MCP Server tool definitions
|
||||
const tools = [
|
||||
{
|
||||
name: "create_claude_session",
|
||||
description: "Create a new Claude Code session for a specific task",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sessionType: {
|
||||
type: "string",
|
||||
enum: ["analysis", "implementation", "testing", "review", "coordination"]
|
||||
},
|
||||
repository: { type: "string" },
|
||||
requirements: { type: "string" },
|
||||
dependencies: { type: "array", items: { type: "string" } }
|
||||
},
|
||||
required: ["sessionType", "repository", "requirements"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "start_claude_session",
|
||||
description: "Start a Claude Code session",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sessionId: { type: "string" }
|
||||
},
|
||||
required: ["sessionId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_session_output",
|
||||
description: "Get the output from a Claude Code session",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sessionId: { type: "string" }
|
||||
},
|
||||
required: ["sessionId"]
|
||||
}
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Orchestration Workflow Example
|
||||
|
||||
Claude Desktop can use these tools to orchestrate complex tasks:
|
||||
|
||||
```markdown
|
||||
# Claude Desktop Orchestration Example
|
||||
|
||||
1. User: "Build a REST API with authentication"
|
||||
|
||||
2. Claude Desktop thinks:
|
||||
- Need to analyze requirements first
|
||||
- Then implement API and auth in parallel
|
||||
- Finally run tests
|
||||
|
||||
3. Claude Desktop executes:
|
||||
a. create_claude_session(type="analysis", repo="user/api", requirements="Analyze and plan REST API with JWT auth")
|
||||
b. start_claude_session(sessionId="analysis-123")
|
||||
c. Wait for completion...
|
||||
d. get_session_output(sessionId="analysis-123")
|
||||
|
||||
e. Based on analysis output:
|
||||
- create_claude_session(type="implementation", requirements="Implement REST endpoints")
|
||||
- create_claude_session(type="implementation", requirements="Implement JWT authentication")
|
||||
|
||||
f. Start both implementation sessions in parallel
|
||||
g. Monitor progress and aggregate results
|
||||
h. Create and run testing session with dependencies
|
||||
```
|
||||
|
||||
### Benefits of MCP Integration
|
||||
|
||||
- **Intelligent Orchestration**: Claude Desktop can dynamically decide how to break down tasks
|
||||
- **Adaptive Workflow**: Can adjust strategy based on intermediate results
|
||||
- **Parallel Execution**: Run multiple specialized Claude instances simultaneously
|
||||
- **Context Preservation**: Each session maintains its own context and state
|
||||
- **Result Aggregation**: Claude Desktop can synthesize outputs from all sessions
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Bearer token authentication required for all endpoints
|
||||
- Each session runs in isolated Docker container
|
||||
- No direct access to host system
|
||||
- Environment variables sanitized before container creation
|
||||
- Automatic container cleanup on completion
|
||||
- Volume mounts isolated per session
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
1. **Creation**: Container created but not started
|
||||
2. **Initialization**: Container started, Claude Code preparing
|
||||
3. **Running**: Claude actively working on the task
|
||||
4. **Completed/Failed**: Task finished, output available
|
||||
5. **Cleanup**: Container removed, volumes optionally preserved
|
||||
|
||||
### Dependency Management
|
||||
|
||||
Sessions can declare dependencies on other sessions:
|
||||
- Dependent sessions wait in queue until dependencies complete
|
||||
- Automatic start when all dependencies are satisfied
|
||||
- Failure of dependency marks dependent sessions as blocked
|
||||
|
||||
### Resource Management
|
||||
|
||||
- Docker volumes for persistent storage across session lifecycle
|
||||
- Separate volumes for project files and Claude configuration
|
||||
- Automatic cleanup of orphaned containers
|
||||
- Resource limits can be configured per session type
|
||||
|
||||
## Best Practices for MCP Integration
|
||||
|
||||
1. **Session Granularity**: Create focused sessions with clear, specific requirements
|
||||
2. **Dependency Design**: Use dependencies to ensure proper execution order
|
||||
3. **Error Handling**: Check session status before retrieving output
|
||||
4. **Resource Awareness**: Limit parallel sessions based on available resources
|
||||
5. **Progress Monitoring**: Poll session status at reasonable intervals
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Session Stuck in Initializing**
|
||||
- Check Docker daemon is running
|
||||
- Verify Claude container image exists
|
||||
- Check container logs for startup errors
|
||||
|
||||
2. **Dependencies Not Met**
|
||||
- Verify dependency session IDs are correct
|
||||
- Check if dependency sessions completed successfully
|
||||
- Use session.list to see all session statuses
|
||||
|
||||
3. **No Output Available**
|
||||
- Ensure session completed successfully
|
||||
- Check if Claude produced any output
|
||||
- Review session logs for errors
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- WebSocket support for real-time session updates
|
||||
- Session templates for common workflows
|
||||
- Resource pooling for faster container startup
|
||||
- Inter-session communication channels
|
||||
- Session result caching and replay
|
||||
- Advanced scheduling algorithms
|
||||
- Cost optimization strategies
|
||||
941
docs/claude-webhook-api.md
Normal file
941
docs/claude-webhook-api.md
Normal file
@@ -0,0 +1,941 @@
|
||||
# Claude Webhook API Documentation
|
||||
|
||||
## Overview
|
||||
The Claude Webhook API provides endpoints for creating and managing Claude Code sessions for automated code generation, analysis, and orchestration. This API is designed to enable parallel execution of multiple Claude instances for complex software engineering tasks.
|
||||
|
||||
## API Design Philosophy
|
||||
This API follows a simple, focused design:
|
||||
- **Single responsibility**: Each session handles one specific task
|
||||
- **Orchestration via MCP/LLM agents**: Complex workflows are managed by the calling agent, not the API
|
||||
- **Consistent response format**: All responses follow the same structure for predictable parsing
|
||||
|
||||
## Base Configuration
|
||||
|
||||
### Base URL
|
||||
```
|
||||
POST https://your-domain.com/api/webhooks/claude
|
||||
```
|
||||
|
||||
### Authentication
|
||||
All requests require Bearer token authentication:
|
||||
```http
|
||||
Authorization: Bearer <CLAUDE_WEBHOOK_SECRET>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Response Format
|
||||
All API responses follow this consistent structure:
|
||||
```json
|
||||
{
|
||||
"success": boolean,
|
||||
"message": "string", // Human-readable status message
|
||||
"data": object, // Response data (when success=true)
|
||||
"error": "string" // Error description (when success=false)
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
- Currently not implemented (planned for future release)
|
||||
- Recommended client-side rate limiting: 10 requests per minute
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Create Session
|
||||
Creates a new Claude Code session. Sessions can be configured with dependencies, metadata, and execution options.
|
||||
|
||||
**Endpoint:** `POST /api/webhooks/claude`
|
||||
**Type:** `session.create`
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{
|
||||
"type": "session.create",
|
||||
"session": {
|
||||
"type": "implementation | analysis | testing | review | coordination",
|
||||
"project": {
|
||||
"repository": "string", // Required: "owner/repo" format
|
||||
"branch": "string", // Optional: target branch
|
||||
"requirements": "string", // Required: task description
|
||||
"context": "string" // Optional: additional context
|
||||
},
|
||||
"dependencies": ["string"], // Optional: array of session IDs to wait for
|
||||
"metadata": { // Optional: custom metadata
|
||||
"batchId": "string", // Group related sessions
|
||||
"tags": ["string"], // Categorization tags
|
||||
"priority": "string" // Priority level
|
||||
}
|
||||
},
|
||||
"options": { // Optional: execution options
|
||||
"autoStart": boolean, // Start when dependencies complete (default: false)
|
||||
"timeout": number, // Custom timeout in seconds (default: 1800)
|
||||
"notifyUrl": "string" // Webhook URL for completion notification
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `type` | string | Yes | Must be "session.create" |
|
||||
| `session` | object | Yes | Session configuration object |
|
||||
| `session.type` | string | Yes | Type of session: `implementation`, `analysis`, `testing`, `review`, or `coordination` |
|
||||
| `session.project` | object | Yes | Project information |
|
||||
| `session.project.repository` | string | Yes | GitHub repository in "owner/repo" format |
|
||||
| `session.project.branch` | string | No | Target branch name (defaults to main/master) |
|
||||
| `session.project.requirements` | string | Yes | Clear description of what Claude should do |
|
||||
| `session.project.context` | string | No | Additional context about the codebase or requirements |
|
||||
| `session.dependencies` | string[] | No | Array of valid UUID session IDs that must complete before this session starts (filters out "none", empty strings) |
|
||||
| `session.metadata` | object | No | Custom metadata for organizing sessions |
|
||||
| `session.metadata.batchId` | string | No | User-provided ID for grouping related sessions |
|
||||
| `session.metadata.tags` | string[] | No | Tags for categorization |
|
||||
| `session.metadata.priority` | string | No | Priority level (high, medium, low) |
|
||||
| `options` | object | No | Execution options |
|
||||
| `options.autoStart` | boolean | No | Automatically start when dependencies complete (default: false) |
|
||||
| `options.timeout` | number | No | Custom timeout in seconds (default: 1800 = 30 minutes) |
|
||||
| `options.notifyUrl` | string | No | Webhook URL to call on completion/failure |
|
||||
|
||||
#### Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session created successfully",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"type": "implementation",
|
||||
"status": "pending",
|
||||
"project": {
|
||||
"repository": "acme/webapp",
|
||||
"branch": "feature/user-auth",
|
||||
"requirements": "Implement JWT authentication middleware",
|
||||
"context": "Use existing User model"
|
||||
},
|
||||
"dependencies": [],
|
||||
"metadata": {
|
||||
"batchId": "auth-feature-batch",
|
||||
"tags": ["feature", "auth"],
|
||||
"priority": "high"
|
||||
},
|
||||
"options": {
|
||||
"autoStart": false,
|
||||
"timeout": 1800,
|
||||
"notifyUrl": null
|
||||
},
|
||||
"containerId": null,
|
||||
"claudeSessionId": null,
|
||||
"createdAt": "2024-01-06T10:00:00Z",
|
||||
"startedAt": null,
|
||||
"completedAt": null,
|
||||
"output": null,
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/webhooks/claude \
|
||||
-H "Authorization: Bearer your-secret-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "session.create",
|
||||
"session": {
|
||||
"type": "implementation",
|
||||
"project": {
|
||||
"repository": "acme/webapp",
|
||||
"branch": "feature/user-auth",
|
||||
"requirements": "Implement JWT authentication middleware for Express.js",
|
||||
"context": "Use existing User model and bcrypt for password hashing"
|
||||
},
|
||||
"dependencies": []
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Start Session
|
||||
Starts a previously created session or queues it if dependencies aren't met.
|
||||
|
||||
**Endpoint:** `POST /api/webhooks/claude`
|
||||
**Type:** `session.start`
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{
|
||||
"type": "session.start",
|
||||
"sessionId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `type` | string | Yes | Must be "session.start" |
|
||||
| `sessionId` | string | Yes | UUID of the session to start |
|
||||
|
||||
#### Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session started successfully",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "initializing", // or "running" if started immediately
|
||||
"containerId": "docker-container-id",
|
||||
"claudeSessionId": "claude-internal-session-id",
|
||||
// ... full session object
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For queued sessions (waiting on dependencies):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session queued",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "pending",
|
||||
// ... full session object
|
||||
},
|
||||
"queueStatus": {
|
||||
"waitingFor": ["dependency-session-id-1", "dependency-session-id-2"],
|
||||
"estimatedStartTime": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/webhooks/claude \
|
||||
-H "Authorization: Bearer your-secret-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "session.start",
|
||||
"sessionId": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Session Status
|
||||
Retrieves the current status and details of a session.
|
||||
|
||||
**Endpoint:** `POST /api/webhooks/claude`
|
||||
**Type:** `session.get`
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{
|
||||
"type": "session.get",
|
||||
"sessionId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `type` | string | Yes | Must be "session.get" |
|
||||
| `sessionId` | string | Yes | UUID of the session to query |
|
||||
|
||||
#### Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session found",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"type": "implementation",
|
||||
"status": "running",
|
||||
"containerId": "docker-container-id",
|
||||
"claudeSessionId": "claude-internal-session-id",
|
||||
"project": {
|
||||
"repository": "acme/webapp",
|
||||
"branch": "feature/user-auth",
|
||||
"requirements": "Implement JWT authentication middleware",
|
||||
"context": "Use existing User model"
|
||||
},
|
||||
"dependencies": [],
|
||||
"metadata": {},
|
||||
"options": {},
|
||||
"createdAt": "2024-01-06T10:00:00Z",
|
||||
"startedAt": "2024-01-06T10:30:00Z",
|
||||
"completedAt": null,
|
||||
"output": null,
|
||||
"error": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Session Status Values
|
||||
- `pending` - Session created but not started
|
||||
- `initializing` - Container is being created
|
||||
- `running` - Session is actively executing
|
||||
- `completed` - Session finished successfully
|
||||
- `failed` - Session encountered an error
|
||||
- `cancelled` - Session was manually cancelled
|
||||
|
||||
---
|
||||
|
||||
### 4. Get Session Output
|
||||
Retrieves the output and artifacts from a completed session.
|
||||
|
||||
**Endpoint:** `POST /api/webhooks/claude`
|
||||
**Type:** `session.output`
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{
|
||||
"type": "session.output",
|
||||
"sessionId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `type` | string | Yes | Must be "session.output" |
|
||||
| `sessionId` | string | Yes | UUID of the session |
|
||||
|
||||
#### Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session output retrieved",
|
||||
"data": {
|
||||
"output": {
|
||||
"logs": ["Container started", "Running Claude command...", "Task completed"],
|
||||
"artifacts": [
|
||||
{
|
||||
"type": "file",
|
||||
"path": "src/middleware/auth.js",
|
||||
"content": "// JWT authentication middleware\n...",
|
||||
"sha": "abc123...",
|
||||
"url": "https://github.com/acme/webapp/blob/feature/user-auth/src/middleware/auth.js",
|
||||
"metadata": {
|
||||
"linesAdded": 150,
|
||||
"linesRemoved": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "Implemented JWT authentication middleware with refresh token support",
|
||||
"nextSteps": ["Add rate limiting", "Implement password reset flow"],
|
||||
"executionTime": 180, // seconds
|
||||
"resourceUsage": {
|
||||
"cpuTime": 45.2,
|
||||
"memoryPeak": "512MB"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: The current implementation returns a simplified structure. Full artifact details and metadata are planned for future releases.
|
||||
|
||||
---
|
||||
|
||||
### 5. List Sessions
|
||||
Lists all sessions, optionally filtered by orchestration ID.
|
||||
|
||||
**Endpoint:** `POST /api/webhooks/claude`
|
||||
**Type:** `session.list`
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{
|
||||
"type": "session.list",
|
||||
"orchestrationId": "string" // Optional
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `type` | string | Yes | Must be "session.list" |
|
||||
| `orchestrationId` | string | No | Filter sessions by orchestration ID |
|
||||
|
||||
#### Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Sessions retrieved",
|
||||
"data": {
|
||||
"sessions": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"type": "implementation",
|
||||
"status": "completed",
|
||||
"project": {
|
||||
"repository": "acme/webapp",
|
||||
"branch": "feature/user-auth",
|
||||
"requirements": "Implement JWT authentication",
|
||||
"context": null
|
||||
},
|
||||
"dependencies": [],
|
||||
"metadata": {
|
||||
"batchId": "auth-feature-batch",
|
||||
"tags": ["feature", "auth"]
|
||||
},
|
||||
"createdAt": "2024-01-06T10:00:00Z",
|
||||
"startedAt": "2024-01-06T10:30:00Z",
|
||||
"completedAt": "2024-01-06T10:45:00Z",
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"id": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"type": "testing",
|
||||
"status": "running",
|
||||
"project": {
|
||||
"repository": "acme/webapp",
|
||||
"branch": "feature/user-auth",
|
||||
"requirements": "Write tests for JWT middleware"
|
||||
},
|
||||
"dependencies": ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
"metadata": {
|
||||
"batchId": "auth-feature-batch",
|
||||
"tags": ["testing"]
|
||||
},
|
||||
"createdAt": "2024-01-06T10:46:00Z",
|
||||
"startedAt": "2024-01-06T10:47:00Z",
|
||||
"completedAt": null,
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Session Types
|
||||
|
||||
### implementation
|
||||
For implementing new features or functionality. Claude will:
|
||||
- Analyze requirements
|
||||
- Write production-ready code
|
||||
- Follow existing patterns and conventions
|
||||
- Create or modify files as needed
|
||||
|
||||
### analysis
|
||||
For analyzing existing code. Claude will:
|
||||
- Review code structure and patterns
|
||||
- Identify potential issues
|
||||
- Suggest improvements
|
||||
- Document findings
|
||||
|
||||
### testing
|
||||
For creating and running tests. Claude will:
|
||||
- Write unit and integration tests
|
||||
- Ensure code coverage
|
||||
- Validate functionality
|
||||
- Fix failing tests
|
||||
|
||||
### review
|
||||
For code review tasks. Claude will:
|
||||
- Review pull requests
|
||||
- Check for security issues
|
||||
- Validate best practices
|
||||
- Provide feedback
|
||||
|
||||
### coordination
|
||||
For orchestrating multiple sessions. Claude will:
|
||||
- Break down complex tasks
|
||||
- Create dependent sessions
|
||||
- Monitor progress
|
||||
- Coordinate results
|
||||
|
||||
## Dependency Management
|
||||
|
||||
Sessions can depend on other sessions using the `dependencies` parameter:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.create",
|
||||
"session": {
|
||||
"type": "testing",
|
||||
"project": {
|
||||
"repository": "acme/webapp",
|
||||
"requirements": "Write tests for the JWT authentication middleware"
|
||||
},
|
||||
"dependencies": ["implementation-session-id"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Behavior
|
||||
- Sessions with dependencies won't start until all dependencies are `completed`
|
||||
- If any dependency fails, the dependent session will be marked as `failed`
|
||||
- Circular dependencies are detected and rejected
|
||||
- Maximum dependency depth is 10 levels
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error description"
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
- `400` - Bad Request (invalid parameters)
|
||||
- `401` - Unauthorized (invalid token)
|
||||
- `404` - Not Found (session doesn't exist)
|
||||
- `409` - Conflict (session already started)
|
||||
- `429` - Too Many Requests (rate limit exceeded)
|
||||
- `500` - Internal Server Error
|
||||
|
||||
### Example Error Response
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Session not found: 550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Clear Requirements
|
||||
Provide detailed, actionable requirements:
|
||||
```json
|
||||
{
|
||||
"requirements": "Implement JWT authentication middleware with:\n- Access token (15min expiry)\n- Refresh token (7 days expiry)\n- Token blacklisting for logout\n- Rate limiting per user"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Dependencies Wisely
|
||||
Chain related tasks:
|
||||
```
|
||||
analysis → implementation → testing → review
|
||||
```
|
||||
|
||||
### 3. Provide Context
|
||||
Include relevant context about your codebase:
|
||||
```json
|
||||
{
|
||||
"context": "We use Express.js with TypeScript, Prisma ORM, and follow REST API conventions. Authentication should integrate with existing User model."
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Monitor Session Status
|
||||
Poll session status every 5-10 seconds:
|
||||
```bash
|
||||
while [ "$status" != "completed" ]; do
|
||||
status=$(curl -s -X POST ... | jq -r '.data.status')
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
### 5. Handle Failures Gracefully
|
||||
Check session status and error messages:
|
||||
```javascript
|
||||
if (response.data.status === 'failed') {
|
||||
console.error('Session failed:', response.data.error);
|
||||
// Implement retry logic or alternative approach
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Node.js/TypeScript
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
|
||||
const CLAUDE_API_URL = 'https://your-domain.com/api/webhooks/claude';
|
||||
const AUTH_TOKEN = process.env.CLAUDE_WEBHOOK_SECRET;
|
||||
|
||||
async function createAndRunSession() {
|
||||
// Create session
|
||||
const createResponse = await axios.post(
|
||||
CLAUDE_API_URL,
|
||||
{
|
||||
type: 'session.create',
|
||||
session: {
|
||||
type: 'implementation',
|
||||
project: {
|
||||
repository: 'acme/webapp',
|
||||
requirements: 'Implement user profile API endpoints',
|
||||
context: 'Use existing auth middleware'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const sessionId = createResponse.data.data.sessionId;
|
||||
|
||||
// Start session
|
||||
await axios.post(
|
||||
CLAUDE_API_URL,
|
||||
{
|
||||
type: 'session.start',
|
||||
sessionId
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Poll for completion
|
||||
let status = 'running';
|
||||
while (status === 'running' || status === 'initializing') {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
const statusResponse = await axios.post(
|
||||
CLAUDE_API_URL,
|
||||
{
|
||||
type: 'session.get',
|
||||
sessionId
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
status = statusResponse.data.data.status;
|
||||
}
|
||||
|
||||
// Get output
|
||||
if (status === 'completed') {
|
||||
const outputResponse = await axios.post(
|
||||
CLAUDE_API_URL,
|
||||
{
|
||||
type: 'session.output',
|
||||
sessionId
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Session completed:', outputResponse.data.data.summary);
|
||||
console.log('Artifacts:', outputResponse.data.data.artifacts);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
|
||||
CLAUDE_API_URL = 'https://your-domain.com/api/webhooks/claude'
|
||||
AUTH_TOKEN = os.environ['CLAUDE_WEBHOOK_SECRET']
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {AUTH_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# Create session
|
||||
create_response = requests.post(
|
||||
CLAUDE_API_URL,
|
||||
json={
|
||||
'type': 'session.create',
|
||||
'session': {
|
||||
'type': 'implementation',
|
||||
'project': {
|
||||
'repository': 'acme/webapp',
|
||||
'requirements': 'Implement user profile API endpoints'
|
||||
}
|
||||
}
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
|
||||
session_id = create_response.json()['data']['sessionId']
|
||||
|
||||
# Start session
|
||||
requests.post(
|
||||
CLAUDE_API_URL,
|
||||
json={
|
||||
'type': 'session.start',
|
||||
'sessionId': session_id
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# Poll for completion
|
||||
status = 'running'
|
||||
while status in ['running', 'initializing']:
|
||||
time.sleep(5)
|
||||
status_response = requests.post(
|
||||
CLAUDE_API_URL,
|
||||
json={
|
||||
'type': 'session.get',
|
||||
'sessionId': session_id
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
status = status_response.json()['data']['status']
|
||||
|
||||
# Get output
|
||||
if status == 'completed':
|
||||
output_response = requests.post(
|
||||
CLAUDE_API_URL,
|
||||
json={
|
||||
'type': 'session.output',
|
||||
'sessionId': session_id
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
output = output_response.json()['data']
|
||||
print(f"Summary: {output['summary']}")
|
||||
print(f"Artifacts: {output['artifacts']}")
|
||||
```
|
||||
|
||||
## LLM Agent Integration Guide
|
||||
|
||||
This section provides specific guidance for LLM agents (via MCP servers or other integrations) consuming this API.
|
||||
|
||||
### Response Parsing
|
||||
All responses follow a consistent structure, making them easy to parse:
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: T; // Present when success=true
|
||||
error?: string; // Present when success=false
|
||||
}
|
||||
```
|
||||
|
||||
### Session Orchestration Pattern
|
||||
Since this API focuses on single-session creation, orchestration should be handled by the LLM agent:
|
||||
|
||||
```python
|
||||
# Example: LLM agent orchestrating a feature implementation
|
||||
async def implement_feature(repo: str, feature_desc: str):
|
||||
# 1. Create analysis session
|
||||
analysis = await create_session(
|
||||
type="analysis",
|
||||
requirements=f"Analyze codebase for implementing: {feature_desc}"
|
||||
)
|
||||
|
||||
# 2. Wait for analysis to complete
|
||||
await wait_for_completion(analysis.id)
|
||||
|
||||
# 3. Create implementation session based on analysis
|
||||
implementation = await create_session(
|
||||
type="implementation",
|
||||
requirements=f"Implement {feature_desc} based on analysis",
|
||||
dependencies=[analysis.id]
|
||||
)
|
||||
|
||||
# 4. Create testing session
|
||||
testing = await create_session(
|
||||
type="testing",
|
||||
requirements=f"Write tests for {feature_desc}",
|
||||
dependencies=[implementation.id],
|
||||
options={"autoStart": true} # Auto-start when ready
|
||||
)
|
||||
|
||||
return {
|
||||
"analysis": analysis.id,
|
||||
"implementation": implementation.id,
|
||||
"testing": testing.id
|
||||
}
|
||||
```
|
||||
|
||||
### Polling Best Practices
|
||||
```javascript
|
||||
async function pollSession(sessionId, maxAttempts = 120) {
|
||||
const pollInterval = 5000; // 5 seconds
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const response = await getSession(sessionId);
|
||||
const status = response.data.session.status;
|
||||
|
||||
if (['completed', 'failed', 'cancelled'].includes(status)) {
|
||||
return response.data.session;
|
||||
}
|
||||
|
||||
// Exponential backoff for long-running sessions
|
||||
const delay = status === 'pending' ? pollInterval * 2 : pollInterval;
|
||||
await sleep(delay);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
throw new Error('Session polling timeout');
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Processing Pattern
|
||||
Use metadata to group related sessions:
|
||||
```json
|
||||
{
|
||||
"type": "session.create",
|
||||
"session": {
|
||||
"type": "implementation",
|
||||
"project": { ... },
|
||||
"metadata": {
|
||||
"batchId": "feature-xyz-batch",
|
||||
"tags": ["feature", "priority-high"],
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then query all sessions in a batch:
|
||||
```json
|
||||
{
|
||||
"type": "session.list",
|
||||
"orchestrationId": "feature-xyz-batch"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```python
|
||||
def handle_api_response(response):
|
||||
if response.status_code == 429:
|
||||
# Rate limited - implement exponential backoff
|
||||
retry_after = int(response.headers.get('Retry-After', 60))
|
||||
time.sleep(retry_after)
|
||||
return retry_request()
|
||||
|
||||
data = response.json()
|
||||
if not data['success']:
|
||||
error = data.get('error', 'Unknown error')
|
||||
if 'not found' in error:
|
||||
# Handle missing session
|
||||
pass
|
||||
elif 'already started' in error:
|
||||
# Session already running - just poll for status
|
||||
pass
|
||||
else:
|
||||
raise ApiError(error)
|
||||
|
||||
return data['data']
|
||||
```
|
||||
|
||||
### Dependency Graph Building
|
||||
```typescript
|
||||
class SessionGraph {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
|
||||
addSession(session: Session) {
|
||||
this.sessions.set(session.id, session);
|
||||
}
|
||||
|
||||
getExecutionOrder(): string[] {
|
||||
// Topological sort to determine execution order
|
||||
const visited = new Set<string>();
|
||||
const order: string[] = [];
|
||||
|
||||
const visit = (id: string) => {
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
|
||||
const session = this.sessions.get(id);
|
||||
if (session?.dependencies) {
|
||||
session.dependencies.forEach(dep => visit(dep));
|
||||
}
|
||||
|
||||
order.push(id);
|
||||
};
|
||||
|
||||
this.sessions.forEach((_, id) => visit(id));
|
||||
return order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optimizing for Claude Code
|
||||
When creating sessions for Claude Code:
|
||||
|
||||
1. **Clear Requirements**: Be specific and actionable
|
||||
```json
|
||||
{
|
||||
"requirements": "Implement REST API endpoint POST /api/users with:\n- Request validation (email, password)\n- Password hashing with bcrypt\n- Store in PostgreSQL users table\n- Return JWT token\n- Handle duplicate email error",
|
||||
"context": "Using Express.js, TypeScript, Prisma ORM. Follow existing auth patterns in src/middleware/auth.ts"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Provide Context**: Reference existing code patterns
|
||||
```json
|
||||
{
|
||||
"context": "Follow patterns in src/controllers/. Use existing error handling middleware. See src/types/user.ts for User interface."
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use Session Types Effectively**:
|
||||
- `analysis` - Before implementing, understand the codebase
|
||||
- `implementation` - Write the actual code
|
||||
- `testing` - Ensure code works and has coverage
|
||||
- `review` - Final quality check
|
||||
- `coordination` - For complex multi-part tasks
|
||||
|
||||
### Performance Tips
|
||||
1. **Parallel Sessions**: Create independent sessions simultaneously
|
||||
2. **Reuse Analysis**: Cache analysis results for similar tasks
|
||||
3. **Smart Dependencies**: Only add dependencies when truly needed
|
||||
4. **Batch Operations**: Group related sessions with metadata
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Session Stuck in "pending"
|
||||
- Check if dependencies are completed
|
||||
- Verify Docker daemon is running
|
||||
- Check system resources (CPU, memory)
|
||||
- Use `session.get` to check dependency status
|
||||
|
||||
### Authentication Errors
|
||||
- Verify Bearer token matches CLAUDE_WEBHOOK_SECRET
|
||||
- Ensure Authorization header is properly formatted
|
||||
- Check token hasn't been rotated
|
||||
|
||||
### Session Failures
|
||||
- Review session output for error messages
|
||||
- Check Docker container logs
|
||||
- Verify repository access permissions
|
||||
- Ensure Claude API credentials are valid
|
||||
|
||||
### Timeout Issues
|
||||
- Default timeout is 30 minutes per session
|
||||
- For longer tasks, break into smaller sessions
|
||||
- Use custom timeout in options: `{"timeout": 3600}`
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2.0.0 (2024-01-08)
|
||||
- **BREAKING**: Removed orchestration endpoint (use session.create with type="coordination")
|
||||
- **BREAKING**: Updated response structures (all data wrapped in `data.session` or `data.sessions`)
|
||||
- Added enhanced session creation with metadata and options
|
||||
- Added autoStart option for dependency-based execution
|
||||
- Added timeout and notification options
|
||||
- Improved dependency validation (filters invalid UUIDs)
|
||||
|
||||
### v1.0.0 (2024-01-06)
|
||||
- Initial release with session management
|
||||
- Support for 5 session types
|
||||
- Dependency management
|
||||
- Orchestration capabilities
|
||||
230
docs/docker-optimization.md
Normal file
230
docs/docker-optimization.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Docker Build Optimization Guide
|
||||
|
||||
This document describes the optimizations implemented in our Docker CI/CD pipeline for faster builds and better caching.
|
||||
|
||||
## Overview
|
||||
|
||||
Our optimized Docker build pipeline includes:
|
||||
- Self-hosted runner support with automatic fallback
|
||||
- Multi-stage builds for efficient layering
|
||||
- Advanced caching strategies
|
||||
- Container-based testing
|
||||
- Parallel builds for multiple images
|
||||
- Security scanning integration
|
||||
|
||||
## Self-Hosted Runners
|
||||
|
||||
### Configuration
|
||||
- **Labels**: `self-hosted, linux, x64, docker`
|
||||
- **Usage**: All Docker builds use self-hosted runners by default for improved performance
|
||||
- **Local Cache**: Self-hosted runners maintain Docker layer cache between builds
|
||||
- **Fallback**: Configurable via `USE_SELF_HOSTED` repository variable
|
||||
|
||||
### Runner Setup
|
||||
Self-hosted runners provide:
|
||||
- Persistent Docker layer cache
|
||||
- Faster builds (no image pull overhead)
|
||||
- Better network throughput for pushing images
|
||||
- Cost savings on GitHub Actions minutes
|
||||
|
||||
### Fallback Strategy
|
||||
The workflow implements a flexible fallback mechanism:
|
||||
|
||||
1. **Default behavior**: Uses self-hosted runners (`self-hosted, linux, x64, docker`)
|
||||
2. **Override option**: Set repository variable `USE_SELF_HOSTED=false` to force GitHub-hosted runners
|
||||
3. **Timeout protection**: 30-minute timeout prevents hanging on unavailable runners
|
||||
4. **Failure detection**: `build-fallback` job provides instructions if self-hosted runners fail
|
||||
|
||||
To manually switch to GitHub-hosted runners:
|
||||
```bash
|
||||
# Via GitHub UI: Settings → Secrets and variables → Actions → Variables
|
||||
# Add: USE_SELF_HOSTED = false
|
||||
|
||||
# Or via GitHub CLI:
|
||||
gh variable set USE_SELF_HOSTED --body "false"
|
||||
```
|
||||
|
||||
The runner selection logic:
|
||||
```yaml
|
||||
runs-on: ${{ fromJSON(format('["{0}"]', (vars.USE_SELF_HOSTED == 'false' && 'ubuntu-latest' || 'self-hosted, linux, x64, docker'))) }}
|
||||
```
|
||||
|
||||
## Multi-Stage Dockerfile
|
||||
|
||||
Our Dockerfile uses multiple stages for optimal caching and smaller images:
|
||||
|
||||
1. **Builder Stage**: Compiles TypeScript
|
||||
2. **Prod-deps Stage**: Installs production dependencies only
|
||||
3. **Test Stage**: Includes dev dependencies and test files
|
||||
4. **Production Stage**: Minimal runtime image
|
||||
|
||||
### Benefits
|
||||
- Parallel builds of independent stages
|
||||
- Smaller final image (no build tools or dev dependencies)
|
||||
- Test stage can run in CI without affecting production image
|
||||
- Better layer caching between builds
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### 1. GitHub Actions Cache (GHA)
|
||||
```yaml
|
||||
cache-from: type=gha,scope=${{ matrix.image }}-prod
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-prod
|
||||
```
|
||||
|
||||
### 2. Registry Cache
|
||||
```yaml
|
||||
cache-from: type=registry,ref=${{ org }}/claude-hub:nightly
|
||||
```
|
||||
|
||||
### 3. Inline Cache
|
||||
```yaml
|
||||
build-args: BUILDKIT_INLINE_CACHE=1
|
||||
outputs: type=inline
|
||||
```
|
||||
|
||||
### 4. Layer Ordering
|
||||
- Package files copied first (changes less frequently)
|
||||
- Source code copied after dependencies
|
||||
- Build artifacts cached between stages
|
||||
|
||||
## Container-Based Testing
|
||||
|
||||
Tests run inside Docker containers for:
|
||||
- Consistent environment
|
||||
- Parallel test execution
|
||||
- Isolation from host system
|
||||
- Same environment as production
|
||||
|
||||
### Test Execution
|
||||
```bash
|
||||
# Unit tests in container
|
||||
docker run --rm claude-hub:test npm test
|
||||
|
||||
# Integration tests with docker-compose
|
||||
docker-compose -f docker-compose.test.yml run integration-test
|
||||
|
||||
# E2E tests against running services
|
||||
docker-compose -f docker-compose.test.yml run e2e-test
|
||||
```
|
||||
|
||||
## Build Performance Optimizations
|
||||
|
||||
### 1. BuildKit Features
|
||||
- `DOCKER_BUILDKIT=1` for improved performance
|
||||
- `--mount=type=cache` for package manager caches
|
||||
- Parallel stage execution
|
||||
|
||||
### 2. Docker Buildx
|
||||
- Multi-platform builds (amd64, arm64)
|
||||
- Advanced caching backends
|
||||
- Build-only stages that don't ship to production
|
||||
|
||||
### 3. Context Optimization
|
||||
- `.dockerignore` excludes unnecessary files
|
||||
- Minimal context sent to Docker daemon
|
||||
- Faster uploads and builds
|
||||
|
||||
### 4. Dependency Caching
|
||||
- Separate stage for production dependencies
|
||||
- npm ci with --omit=dev for smaller images
|
||||
- Cache mount for npm packages
|
||||
|
||||
## Workflow Features
|
||||
|
||||
### PR Builds
|
||||
- Build and test without publishing
|
||||
- Single platform (amd64) for speed
|
||||
- Container-based test execution
|
||||
- Security scanning with Trivy
|
||||
|
||||
### Main Branch Builds
|
||||
- Multi-platform builds (amd64, arm64)
|
||||
- Push to registry with :nightly tag
|
||||
- Update cache images
|
||||
- Full test suite execution
|
||||
|
||||
### Version Tag Builds
|
||||
- Semantic versioning tags
|
||||
- :latest tag update
|
||||
- Multi-platform support
|
||||
- Production-ready images
|
||||
|
||||
## Security Scanning
|
||||
|
||||
### Integrated Scanners
|
||||
1. **Trivy**: Vulnerability scanning for Docker images
|
||||
2. **Hadolint**: Dockerfile linting
|
||||
3. **npm audit**: Dependency vulnerability checks
|
||||
4. **SARIF uploads**: Results visible in GitHub Security tab
|
||||
|
||||
## Monitoring and Metrics
|
||||
|
||||
### Build Performance
|
||||
- Build time per stage
|
||||
- Cache hit rates
|
||||
- Image size tracking
|
||||
- Test execution time
|
||||
|
||||
### Health Checks
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3002/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Building locally
|
||||
```bash
|
||||
# Build with BuildKit
|
||||
DOCKER_BUILDKIT=1 docker build -t claude-hub:local .
|
||||
|
||||
# Build specific stage
|
||||
docker build --target test -t claude-hub:test .
|
||||
|
||||
# Run tests locally
|
||||
docker-compose -f docker-compose.test.yml run test
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
```bash
|
||||
# Clear builder cache
|
||||
docker builder prune
|
||||
|
||||
# Use local cache
|
||||
docker build --cache-from claude-hub:local .
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Order Dockerfile commands** from least to most frequently changing
|
||||
2. **Use specific versions** for base images and dependencies
|
||||
3. **Minimize layers** by combining RUN commands
|
||||
4. **Clean up** package manager caches in the same layer
|
||||
5. **Use multi-stage builds** to reduce final image size
|
||||
6. **Leverage BuildKit** features for better performance
|
||||
7. **Test in containers** for consistency across environments
|
||||
8. **Monitor build times** and optimize bottlenecks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Slow Builds
|
||||
- Check cache hit rates in build logs
|
||||
- Verify .dockerignore is excluding large files
|
||||
- Use `--progress=plain` to see detailed timings
|
||||
- Consider parallelizing independent stages
|
||||
|
||||
### Cache Misses
|
||||
- Ensure consistent base image versions
|
||||
- Check for unnecessary file changes triggering rebuilds
|
||||
- Use cache mounts for package managers
|
||||
- Verify registry cache is accessible
|
||||
|
||||
### Test Failures in Container
|
||||
- Check environment variable differences
|
||||
- Verify volume mounts are correct
|
||||
- Ensure test dependencies are in test stage
|
||||
- Check for hardcoded paths or ports
|
||||
204
docs/environment-variables.md
Normal file
204
docs/environment-variables.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Environment Variables Documentation
|
||||
|
||||
This document provides a comprehensive list of all environment variables used in the Claude GitHub Webhook project.
|
||||
|
||||
## Table of Contents
|
||||
- [Core Application Configuration](#core-application-configuration)
|
||||
- [Bot Configuration](#bot-configuration)
|
||||
- [GitHub Configuration](#github-configuration)
|
||||
- [Claude/Anthropic Configuration](#claudeanthropic-configuration)
|
||||
- [Container Configuration](#container-configuration)
|
||||
- [AWS Configuration](#aws-configuration)
|
||||
- [PR Review Configuration](#pr-review-configuration)
|
||||
- [Security & Secrets Configuration](#security--secrets-configuration)
|
||||
- [Rate Limiting Configuration](#rate-limiting-configuration)
|
||||
- [Health Check Configuration](#health-check-configuration)
|
||||
- [Development/Test Variables](#developmenttest-variables)
|
||||
- [Shell Script Variables](#shell-script-variables)
|
||||
- [Hard-coded Values That Could Be Configurable](#hard-coded-values-that-could-be-configurable)
|
||||
|
||||
## Core Application Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `NODE_ENV` | Application environment (development/production/test) | `development` | No |
|
||||
| `PORT` | Server port | `3002` | No |
|
||||
| `TRUST_PROXY` | Trust proxy headers for X-Forwarded-For | `false` | No |
|
||||
|
||||
## Bot Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `BOT_USERNAME` | GitHub username the bot responds to (e.g., @ClaudeBot) | - | Yes |
|
||||
| `BOT_EMAIL` | Email used for git commits by the bot | - | Yes |
|
||||
| `DEFAULT_AUTHORIZED_USER` | Default authorized GitHub username | - | No |
|
||||
| `AUTHORIZED_USERS` | Comma-separated list of authorized GitHub usernames | - | No |
|
||||
|
||||
## GitHub Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `GITHUB_TOKEN` | GitHub personal access token | - | Yes |
|
||||
| `GITHUB_WEBHOOK_SECRET` | Secret for validating GitHub webhook payloads | - | Yes |
|
||||
| `DEFAULT_GITHUB_OWNER` | Default GitHub organization/owner | - | No |
|
||||
| `DEFAULT_GITHUB_USER` | Default GitHub username | - | No |
|
||||
| `DEFAULT_BRANCH` | Default git branch | `main` | No |
|
||||
| `TEST_REPO_FULL_NAME` | Test repository in owner/repo format | - | No |
|
||||
|
||||
## Claude/Anthropic Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `ANTHROPIC_API_KEY` | Anthropic API key for Claude access | - | Yes* |
|
||||
| `ANTHROPIC_MODEL` | Model name | `us.anthropic.claude-3-7-sonnet-20250219-v1:0` | No |
|
||||
| `CLAUDE_CODE_USE_BEDROCK` | Whether to use AWS Bedrock for Claude (0/1) | `0` | No |
|
||||
| `CLAUDE_HUB_DIR` | Directory for Claude Hub config | `~/.claude-hub` | No |
|
||||
| `CLAUDE_AUTH_HOST_DIR` | Host directory for Claude authentication | - | No |
|
||||
|
||||
*Required unless using AWS Bedrock or setup container authentication
|
||||
|
||||
## Container Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CLAUDE_USE_CONTAINERS` | Enable container execution (0/1) | `1` | No |
|
||||
| `CLAUDE_CONTAINER_IMAGE` | Docker image for Claude containers | `claudecode:latest` | No |
|
||||
| `CLAUDE_CONTAINER_PRIVILEGED` | Run containers in privileged mode | `false` | No |
|
||||
| `CLAUDE_CONTAINER_CAP_NET_RAW` | Add NET_RAW capability | `true` | No |
|
||||
| `CLAUDE_CONTAINER_CAP_SYS_TIME` | Add SYS_TIME capability | `false` | No |
|
||||
| `CLAUDE_CONTAINER_CAP_DAC_OVERRIDE` | Add DAC_OVERRIDE capability | `true` | No |
|
||||
| `CLAUDE_CONTAINER_CAP_AUDIT_WRITE` | Add AUDIT_WRITE capability | `true` | No |
|
||||
| `CLAUDE_CONTAINER_CPU_SHARES` | CPU shares for containers | `1024` | No |
|
||||
| `CLAUDE_CONTAINER_MEMORY_LIMIT` | Memory limit for containers | `2g` | No |
|
||||
| `CLAUDE_CONTAINER_PIDS_LIMIT` | Process limit for containers | `256` | No |
|
||||
| `CONTAINER_LIFETIME_MS` | Container execution timeout in milliseconds | `7200000` (2 hours) | No |
|
||||
| `REPO_CACHE_DIR` | Directory for repository cache | `/tmp/repo-cache` | No |
|
||||
| `REPO_CACHE_MAX_AGE_MS` | Max age for cached repos in milliseconds | `3600000` (1 hour) | No |
|
||||
|
||||
## Claude Code Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `BASH_DEFAULT_TIMEOUT_MS` | Default timeout for bash commands in Claude Code | `600000` (10 minutes) | No |
|
||||
| `BASH_MAX_TIMEOUT_MS` | Maximum timeout Claude can set for bash commands | `1200000` (20 minutes) | No |
|
||||
|
||||
## AWS Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `AWS_ACCESS_KEY_ID` | AWS access key ID | - | No* |
|
||||
| `AWS_SECRET_ACCESS_KEY` | AWS secret access key | - | No* |
|
||||
| `AWS_SESSION_TOKEN` | AWS session token (for temporary credentials) | - | No |
|
||||
| `AWS_SECURITY_TOKEN` | Alternative name for session token | - | No |
|
||||
| `AWS_REGION` | AWS region | `us-east-1` | No |
|
||||
| `AWS_PROFILE` | AWS profile name | - | No |
|
||||
| `USE_AWS_PROFILE` | Use AWS profile instead of direct credentials | `false` | No |
|
||||
| `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` | ECS container credentials URI | - | No |
|
||||
|
||||
*Required if using AWS Bedrock for Claude
|
||||
|
||||
## PR Review Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `PR_REVIEW_WAIT_FOR_ALL_CHECKS` | Wait for all checks before PR review | `true` | No |
|
||||
| `PR_REVIEW_TRIGGER_WORKFLOW` | Specific workflow name to trigger PR review | - | No |
|
||||
| `PR_REVIEW_DEBOUNCE_MS` | Delay before checking all check suites | `5000` | No |
|
||||
| `PR_REVIEW_MAX_WAIT_MS` | Max wait for in-progress checks | `1800000` (30 min) | No |
|
||||
| `PR_REVIEW_CONDITIONAL_TIMEOUT_MS` | Timeout for conditional jobs | `300000` (5 min) | No |
|
||||
|
||||
## Security & Secrets Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `GITHUB_TOKEN_FILE` | Path to file containing GitHub token | `/run/secrets/github_token` | No |
|
||||
| `ANTHROPIC_API_KEY_FILE` | Path to file containing Anthropic API key | `/run/secrets/anthropic_api_key` | No |
|
||||
| `GITHUB_WEBHOOK_SECRET_FILE` | Path to file containing webhook secret | `/run/secrets/webhook_secret` | No |
|
||||
| `DISABLE_LOG_REDACTION` | Disable credential redaction in logs | `false` | No |
|
||||
|
||||
## Rate Limiting Configuration
|
||||
|
||||
These values are currently hard-coded but could be made configurable:
|
||||
|
||||
| Value | Description | Current Value | Location |
|
||||
|-------|-------------|---------------|----------|
|
||||
| Rate limit window | API rate limit time window | 15 minutes | `src/index.ts:32` |
|
||||
| Rate limit max requests | Max API requests per window | 100 | `src/index.ts:41` |
|
||||
| Webhook rate limit window | Webhook rate limit time window | 5 minutes | `src/index.ts:50` |
|
||||
| Webhook rate limit max requests | Max webhook requests per window | 50 | `src/index.ts:51` |
|
||||
|
||||
## Health Check Configuration
|
||||
|
||||
These values are defined in docker-compose.yml:
|
||||
|
||||
| Value | Description | Current Value |
|
||||
|-------|-------------|---------------|
|
||||
| Health check interval | Time between health checks | 30s |
|
||||
| Health check timeout | Timeout for each health check | 10s |
|
||||
| Health check retries | Number of retries before unhealthy | 3 |
|
||||
| Health check start period | Grace period on startup | 10s |
|
||||
|
||||
## Development/Test Variables
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `API_URL` | API URL for testing | `http://localhost:3003` | No |
|
||||
| `WEBHOOK_URL` | Webhook URL for testing | - | No |
|
||||
| `CLAUDE_API_AUTH_REQUIRED` | Require auth for Claude API | `false` | No |
|
||||
| `CLAUDE_API_AUTH_TOKEN` | Auth token for Claude API | - | No |
|
||||
| `HOME` | User home directory | - | No |
|
||||
| `WORKSPACE_PATH` | GitHub Actions workspace path | - | No |
|
||||
| `GITHUB_WORKSPACE` | GitHub Actions workspace | - | No |
|
||||
|
||||
## Shell Script Variables
|
||||
|
||||
| Variable | Description | Used In |
|
||||
|----------|-------------|---------|
|
||||
| `ALLOWED_TOOLS` | Tools allowed for Claude execution | entrypoint scripts |
|
||||
| `OPERATION_TYPE` | Type of operation (tagging, review, etc.) | entrypoint scripts |
|
||||
| `PRODUCTION_BOT` | Production bot username | setup scripts |
|
||||
| `STAGING_BOT` | Staging bot username | setup scripts |
|
||||
| `RUNNER_TOKEN` | GitHub Actions runner token | runner scripts |
|
||||
|
||||
## Hard-coded Values That Could Be Configurable
|
||||
|
||||
The following values are currently hard-coded in the source code but could potentially be made configurable via environment variables:
|
||||
|
||||
### Buffer Sizes
|
||||
- Docker execution buffer: 10MB (`src/services/claudeService.ts:160`)
|
||||
- Container logs buffer: 1MB (`src/services/claudeService.ts:184,590`)
|
||||
|
||||
### External URLs
|
||||
- EC2 metadata endpoint: `http://169.254.169.254/latest/meta-data/` (`src/utils/awsCredentialProvider.ts:94`)
|
||||
- GitHub API meta: `https://api.github.com/meta` (`scripts/security/init-firewall.sh:32`)
|
||||
|
||||
### Allowed Domains (Firewall)
|
||||
- `registry.npmjs.org`
|
||||
- `api.anthropic.com`
|
||||
- `sentry.io`
|
||||
- `statsig.anthropic.com`
|
||||
- `statsig.com`
|
||||
|
||||
### Default Values
|
||||
- Default git email in containers: `claude@example.com` (`scripts/runtime/claudecode-entrypoint.sh:89`)
|
||||
- Default git username in containers: `ClaudeBot` (`scripts/runtime/claudecode-entrypoint.sh:90`)
|
||||
- Health check container image: `claude-code-runner:latest` (`src/index.ts:140`)
|
||||
|
||||
### Docker Base Images
|
||||
- Node base image: `node:24` (`Dockerfile.claudecode:1`)
|
||||
- Delta version: `0.18.2` (`Dockerfile.claudecode:87`)
|
||||
- Zsh-in-docker version: `v1.2.0` (`Dockerfile.claudecode:91`)
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Secret Files**: The application supports loading secrets from files, which takes priority over environment variables. This is more secure for production deployments.
|
||||
|
||||
2. **AWS Authentication**: The service supports multiple AWS authentication methods:
|
||||
- Direct credentials (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY)
|
||||
- AWS profiles (AWS_PROFILE with USE_AWS_PROFILE=true)
|
||||
- Instance profiles (EC2)
|
||||
- Task roles (ECS)
|
||||
|
||||
3. **Container Capabilities**: The container capability flags allow fine-grained control over container permissions for security purposes.
|
||||
|
||||
4. **Staging Environment**: Additional environment variables are defined in `.env.staging` for staging deployments, following the pattern `VARIABLE_NAME_STAGING`.
|
||||
223
docs/setup-container-guide.md
Normal file
223
docs/setup-container-guide.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Setup Container Authentication
|
||||
|
||||
The setup container method captures Claude CLI authentication state for use in automated environments by preserving OAuth tokens and session data.
|
||||
|
||||
## Overview
|
||||
|
||||
Claude CLI requires interactive authentication. This container approach captures the authentication state from an interactive session and makes it available for automated use.
|
||||
|
||||
**Prerequisites**: Requires active Claude Code subscription.
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Setup Container] --> B[Interactive Claude Login]
|
||||
B --> C[OAuth Authentication]
|
||||
C --> D[Capture Auth State]
|
||||
D --> E[Mount in Production]
|
||||
E --> F[Automated Claude Usage]
|
||||
```
|
||||
|
||||
### 1. Interactive Authentication
|
||||
- Clean container environment with Claude CLI installed
|
||||
- User runs `claude --dangerously-skip-permissions` and completes authentication
|
||||
- OAuth tokens and session data stored in `~/.claude`
|
||||
|
||||
### 2. State Capture
|
||||
- Complete `~/.claude` directory copied to persistent storage on container exit
|
||||
- Includes credentials, settings, project data, and session info
|
||||
- Preserves all authentication context
|
||||
|
||||
### 3. Production Mount
|
||||
- Captured authentication mounted in production containers
|
||||
- Working copy created for each execution to avoid state conflicts
|
||||
- OAuth tokens used automatically by Claude CLI
|
||||
|
||||
## Technical Benefits
|
||||
|
||||
- **OAuth Security**: Uses OAuth tokens instead of API keys in environment variables
|
||||
- **Session Persistence**: Maintains Claude CLI session state across executions
|
||||
- **Portable**: Authentication state works across different container environments
|
||||
- **Reusable**: One-time setup supports multiple deployments
|
||||
|
||||
## Files Captured
|
||||
|
||||
The setup container captures all essential Claude authentication files:
|
||||
|
||||
```bash
|
||||
~/.claude/
|
||||
├── .credentials.json # OAuth tokens (primary auth)
|
||||
├── settings.local.json # User preferences
|
||||
├── projects/ # Project history
|
||||
├── todos/ # Task management data
|
||||
├── statsig/ # Analytics and feature flags
|
||||
└── package.json # CLI dependencies
|
||||
```
|
||||
|
||||
### Critical File: .credentials.json
|
||||
```json
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-...",
|
||||
"refreshToken": "sk-ant-ort01-...",
|
||||
"expiresAt": 1748658860401,
|
||||
"scopes": ["user:inference", "user:profile"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Container Implementation
|
||||
|
||||
### Setup Container (`Dockerfile.claude-setup`)
|
||||
- Node.js environment with Claude CLI
|
||||
- Interactive shell for authentication
|
||||
- Signal handling for clean state capture
|
||||
- Automatic file copying on exit
|
||||
|
||||
### Entrypoint Scripts
|
||||
- **Authentication copying**: Comprehensive file transfer
|
||||
- **Permission handling**: Correct ownership for container user
|
||||
- **Debug output**: Detailed logging for troubleshooting
|
||||
|
||||
## Token Lifecycle and Management
|
||||
|
||||
### Token Expiration Timeline
|
||||
Claude OAuth tokens typically expire within **8-12 hours**:
|
||||
- **Access tokens**: Short-lived (8-12 hours)
|
||||
- **Refresh tokens**: Longer-lived but also expire
|
||||
- **Automatic refresh**: Claude CLI attempts to refresh when needed
|
||||
|
||||
### Refresh Token Behavior
|
||||
```json
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-...", // Short-lived
|
||||
"refreshToken": "sk-ant-ort01-...", // Used to get new access tokens
|
||||
"expiresAt": 1748658860401, // Timestamp when access token expires
|
||||
"scopes": ["user:inference", "user:profile"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Refresh Strategy
|
||||
The Claude CLI automatically attempts to refresh tokens when:
|
||||
- Access token is expired or near expiration
|
||||
- API calls return authentication errors
|
||||
- Session state indicates refresh is needed
|
||||
|
||||
However, refresh tokens themselves eventually expire, requiring **full re-authentication**.
|
||||
|
||||
### Maintenance Requirements
|
||||
|
||||
**Monitoring**
|
||||
- Check authentication health regularly
|
||||
- Monitor for expired token errors in logs
|
||||
|
||||
**Re-authentication**
|
||||
- Required when OAuth tokens expire
|
||||
- Test authentication validity after updates
|
||||
|
||||
### Current Limitations
|
||||
|
||||
- Token refresh requires manual intervention
|
||||
- No automated re-authentication when tokens expire
|
||||
- Manual monitoring required for authentication health
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multiple Environments
|
||||
```bash
|
||||
# Development
|
||||
./${CLAUDE_HUB_DIR:-~/.claude-hub} → ~/.claude/
|
||||
|
||||
# Staging
|
||||
./claude-auth-staging → staging container
|
||||
|
||||
# Testing
|
||||
./claude-auth-test → test container
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Protection
|
||||
- OAuth tokens are sensitive credentials
|
||||
- Store in secure, encrypted storage
|
||||
- Rotate regularly by re-authenticating
|
||||
|
||||
### Container Security
|
||||
- Mount authentication with appropriate permissions
|
||||
- Use minimal container privileges
|
||||
- Avoid logging sensitive data
|
||||
|
||||
### Network Security
|
||||
- HTTPS for all Claude API communication
|
||||
- Secure token transmission
|
||||
- Monitor for token abuse
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Test authentication status
|
||||
./scripts/setup/test-claude-auth.sh
|
||||
|
||||
# Verify token validity
|
||||
docker run --rm -v "./${CLAUDE_HUB_DIR:-~/.claude-hub}:/home/node/.claude:ro" \
|
||||
claude-setup:latest claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
### Refresh Workflow
|
||||
```bash
|
||||
# When authentication expires
|
||||
./scripts/setup/setup-claude-interactive.sh
|
||||
|
||||
# Update production environment
|
||||
cp -r ${CLAUDE_HUB_DIR:-~/.claude-hub}/* ~/.claude/
|
||||
docker compose restart webhook
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Empty .credentials.json
|
||||
**Symptom**: Authentication fails, file exists but is 0 bytes
|
||||
**Cause**: Interactive authentication wasn't completed
|
||||
**Solution**: Re-run setup container and complete authentication flow
|
||||
|
||||
#### 2. Permission Errors
|
||||
**Symptom**: "Permission denied" accessing .credentials.json
|
||||
**Cause**: File ownership mismatch in container
|
||||
**Solution**: Entrypoint scripts handle this automatically
|
||||
|
||||
#### 3. OAuth Token Expired
|
||||
**Symptom**: "Invalid API key" or authentication errors
|
||||
**Cause**: Tokens expired (natural expiration)
|
||||
**Solution**: Re-authenticate using setup container
|
||||
|
||||
#### 4. Mount Path Issues
|
||||
**Symptom**: Authentication files not found in container
|
||||
**Cause**: Incorrect volume mount in docker-compose
|
||||
**Solution**: Verify mount path matches captured auth directory
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check captured files
|
||||
ls -la ${CLAUDE_HUB_DIR:-~/.claude-hub}/
|
||||
|
||||
# Test authentication directly
|
||||
docker run --rm -v "$(pwd)/${CLAUDE_HUB_DIR:-~/.claude-hub}:/tmp/auth:ro" \
|
||||
--entrypoint="" claude-setup:latest \
|
||||
bash -c "cp -r /tmp/auth /home/node/.claude &&
|
||||
sudo -u node env HOME=/home/node \
|
||||
/usr/local/share/npm-global/bin/claude --dangerously-skip-permissions --print 'test'"
|
||||
|
||||
# Verify OAuth tokens
|
||||
cat ${CLAUDE_HUB_DIR:-~/.claude-hub}/.credentials.json | jq '.claudeAiOauth'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*The setup container approach provides a technical solution for capturing and reusing Claude CLI authentication in automated environments.*
|
||||
@@ -1,9 +1,11 @@
|
||||
const js = require('@eslint/js');
|
||||
const tseslint = require('@typescript-eslint/eslint-plugin');
|
||||
const tsparser = require('@typescript-eslint/parser');
|
||||
const prettierConfig = require('eslint-config-prettier');
|
||||
|
||||
module.exports = [
|
||||
js.configs.recommended,
|
||||
prettierConfig, // Disable all formatting rules that conflict with Prettier
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
@@ -34,11 +36,7 @@ module.exports = [
|
||||
'no-console': 'warn',
|
||||
'no-debugger': 'error',
|
||||
|
||||
// Code style
|
||||
'indent': ['error', 2],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
// Removed all formatting rules - let Prettier handle them
|
||||
|
||||
// Best practices
|
||||
'eqeqeq': 'error',
|
||||
@@ -105,9 +103,31 @@ module.exports = [
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }]
|
||||
}
|
||||
},
|
||||
// Test files (JavaScript and TypeScript)
|
||||
// Test files (JavaScript)
|
||||
{
|
||||
files: ['test/**/*.js', '**/*.test.js', 'test/**/*.ts', '**/*.test.ts'],
|
||||
files: ['test/**/*.js', '**/*.test.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
jest: 'readonly',
|
||||
describe: 'readonly',
|
||||
test: 'readonly',
|
||||
it: 'readonly',
|
||||
expect: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
beforeAll: 'readonly',
|
||||
afterAll: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
// Test files (TypeScript)
|
||||
{
|
||||
files: ['test/**/*.ts', '**/*.test.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
@@ -127,6 +147,9 @@ module.exports = [
|
||||
afterAll: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off' // Allow any in tests for mocking
|
||||
|
||||
4
get-session.json
Normal file
4
get-session.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "session.get",
|
||||
"sessionId": "d4ac40bf-1290-4237-83fe-53a4a6197dc5"
|
||||
}
|
||||
@@ -8,9 +8,7 @@ module.exports = {
|
||||
'**/test/e2e/scenarios/**/*.test.{js,ts}'
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', {
|
||||
isolatedModules: true
|
||||
}],
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
'^.+\\.js$': 'babel-jest'
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
@@ -20,9 +18,15 @@ module.exports = {
|
||||
collectCoverage: true,
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
coverageDirectory: 'coverage',
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/',
|
||||
'/coverage/'
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,ts}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/types/**/*.ts',
|
||||
'!**/node_modules/**',
|
||||
'!**/dist/**'
|
||||
],
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: claude-webhook-secrets
|
||||
namespace: default
|
||||
type: Opaque
|
||||
stringData:
|
||||
github-token: "YOUR_GITHUB_TOKEN_HERE"
|
||||
anthropic-api-key: "YOUR_ANTHROPIC_API_KEY_HERE"
|
||||
webhook-secret: "YOUR_WEBHOOK_SECRET_HERE"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: claude-webhook
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: claude-webhook
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: claude-webhook
|
||||
spec:
|
||||
containers:
|
||||
- name: webhook
|
||||
image: claude-webhook:latest
|
||||
ports:
|
||||
- containerPort: 3002
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3002"
|
||||
- name: GITHUB_TOKEN_FILE
|
||||
value: "/etc/secrets/github-token"
|
||||
- name: ANTHROPIC_API_KEY_FILE
|
||||
value: "/etc/secrets/anthropic-api-key"
|
||||
- name: GITHUB_WEBHOOK_SECRET_FILE
|
||||
value: "/etc/secrets/webhook-secret"
|
||||
volumeMounts:
|
||||
- name: secrets-volume
|
||||
mountPath: /etc/secrets
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: secrets-volume
|
||||
secret:
|
||||
secretName: claude-webhook-secrets
|
||||
items:
|
||||
- key: github-token
|
||||
path: github-token
|
||||
- key: anthropic-api-key
|
||||
path: anthropic-api-key
|
||||
- key: webhook-secret
|
||||
path: webhook-secret
|
||||
102
package-lock.json
generated
102
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-github-webhook",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-github-webhook",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"axios": "^1.6.2",
|
||||
@@ -17,22 +17,25 @@
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.3",
|
||||
"@babel/core": "^7.27.4",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@jest/globals": "^30.0.0-beta.3",
|
||||
"@types/body-parser": "^1.19.5",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
||||
"@typescript-eslint/parser": "^8.33.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-node": "^4.1.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
@@ -83,20 +86,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz",
|
||||
"integrity": "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA==",
|
||||
"version": "7.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
|
||||
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.27.3",
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-module-transforms": "^7.27.3",
|
||||
"@babel/helpers": "^7.27.3",
|
||||
"@babel/parser": "^7.27.3",
|
||||
"@babel/helpers": "^7.27.4",
|
||||
"@babel/parser": "^7.27.4",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/traverse": "^7.27.3",
|
||||
"@babel/traverse": "^7.27.4",
|
||||
"@babel/types": "^7.27.3",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -363,10 +367,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.3.tgz",
|
||||
"integrity": "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg==",
|
||||
"version": "7.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz",
|
||||
"integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.27.3"
|
||||
@@ -376,10 +381,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.3.tgz",
|
||||
"integrity": "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw==",
|
||||
"version": "7.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
|
||||
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.3"
|
||||
},
|
||||
@@ -1633,14 +1639,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.3.tgz",
|
||||
"integrity": "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ==",
|
||||
"version": "7.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz",
|
||||
"integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.27.3",
|
||||
"@babel/parser": "^7.27.3",
|
||||
"@babel/parser": "^7.27.4",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.27.3",
|
||||
"debug": "^4.3.1",
|
||||
@@ -3105,10 +3112,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
@@ -3308,6 +3316,13 @@
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -5995,6 +6010,22 @@
|
||||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "10.1.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
|
||||
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-config-prettier"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
||||
@@ -8067,6 +8098,16 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-junit/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-leak-detector": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
|
||||
@@ -11056,13 +11097,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache": {
|
||||
|
||||
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-github-webhook",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "A webhook endpoint for Claude to perform git and GitHub actions",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -14,11 +14,15 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest --testPathPattern='test/(unit|integration).*\\.test\\.(js|ts)$'",
|
||||
"test:unit": "jest --testMatch='**/test/unit/**/*.test.{js,ts}'",
|
||||
"test:chatbot": "jest --testMatch='**/test/unit/providers/**/*.test.{js,ts}' --testMatch='**/test/unit/controllers/chatbotController.test.{js,ts}'",
|
||||
"test:integration": "jest --testMatch='**/test/integration/**/*.test.{js,ts}'",
|
||||
"test:e2e": "jest --testMatch='**/test/e2e/**/*.test.{js,ts}'",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
"test:ci": "jest --ci --coverage --testPathPattern='test/(unit|integration).*\\.test\\.(js|ts)$'",
|
||||
"test:combined-coverage": "./scripts/combine-coverage.js",
|
||||
"test:docker": "docker-compose -f docker-compose.test.yml run --rm test",
|
||||
"test:docker:integration": "docker-compose -f docker-compose.test.yml run --rm integration-test",
|
||||
"test:docker:e2e": "docker-compose -f docker-compose.test.yml run --rm e2e-test",
|
||||
"pretest": "./scripts/utils/ensure-test-dirs.sh",
|
||||
"lint": "eslint src/ test/ --fix",
|
||||
"lint:check": "eslint src/ test/",
|
||||
@@ -26,7 +30,9 @@
|
||||
"format:check": "prettier --check src/ test/",
|
||||
"security:audit": "npm audit --audit-level=moderate",
|
||||
"security:fix": "npm audit fix",
|
||||
"setup:dev": "husky install"
|
||||
"setup:dev": "husky install",
|
||||
"setup:hooks": "husky",
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^22.0.0",
|
||||
@@ -38,22 +44,25 @@
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.3",
|
||||
"@babel/core": "^7.27.4",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@jest/globals": "^30.0.0-beta.3",
|
||||
"@types/body-parser": "^1.19.5",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.15.23",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
||||
"@typescript-eslint/parser": "^8.33.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-node": "^4.1.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker Hub publishing script for Claude GitHub Webhook
|
||||
# Usage: ./publish-docker.sh YOUR_DOCKERHUB_USERNAME [VERSION]
|
||||
|
||||
DOCKERHUB_USERNAME=${1:-intelligenceassist}
|
||||
VERSION=${2:-latest}
|
||||
|
||||
# Default to intelligenceassist organization
|
||||
|
||||
IMAGE_NAME="claude-github-webhook"
|
||||
FULL_IMAGE_NAME="$DOCKERHUB_USERNAME/$IMAGE_NAME"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t $IMAGE_NAME:latest .
|
||||
|
||||
echo "Tagging image as $FULL_IMAGE_NAME:$VERSION..."
|
||||
docker tag $IMAGE_NAME:latest $FULL_IMAGE_NAME:$VERSION
|
||||
|
||||
if [ "$VERSION" != "latest" ]; then
|
||||
echo "Also tagging as $FULL_IMAGE_NAME:latest..."
|
||||
docker tag $IMAGE_NAME:latest $FULL_IMAGE_NAME:latest
|
||||
fi
|
||||
|
||||
echo "Logging in to Docker Hub..."
|
||||
docker login
|
||||
|
||||
echo "Pushing to Docker Hub..."
|
||||
docker push $FULL_IMAGE_NAME:$VERSION
|
||||
|
||||
if [ "$VERSION" != "latest" ]; then
|
||||
docker push $FULL_IMAGE_NAME:latest
|
||||
fi
|
||||
|
||||
echo "Successfully published to Docker Hub!"
|
||||
echo "Users can now pull with: docker pull $FULL_IMAGE_NAME:$VERSION"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run claudecode container interactively for testing and debugging
|
||||
docker run -it --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-v ~/.aws:/root/.aws:ro \
|
||||
-v ~/.claude:/root/.claude \
|
||||
-w /workspace \
|
||||
--entrypoint /bin/bash \
|
||||
claudecode:latest
|
||||
@@ -1,263 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to clean up redundant scripts after reorganization
|
||||
echo "Starting script cleanup..."
|
||||
|
||||
# Create a backup directory for redundant scripts
|
||||
BACKUP_DIR="./scripts/archived"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
echo "Created backup directory: $BACKUP_DIR"
|
||||
|
||||
# Function to archive a script instead of deleting it
|
||||
archive_script() {
|
||||
local script=$1
|
||||
if [ -f "$script" ]; then
|
||||
echo "Archiving $script to $BACKUP_DIR"
|
||||
git mv "$script" "$BACKUP_DIR/$(basename $script)"
|
||||
else
|
||||
echo "Warning: $script not found, skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
# Archive redundant test scripts
|
||||
echo "Archiving redundant test scripts..."
|
||||
archive_script "test/claude/test-direct-claude.sh" # Duplicate of test-claude-direct.sh
|
||||
archive_script "test/claude/test-claude-version.sh" # Can be merged with test-claude-installation.sh
|
||||
|
||||
# Archive obsolete AWS credential scripts
|
||||
echo "Archiving obsolete AWS credential scripts..."
|
||||
archive_script "scripts/aws/update-aws-creds.sh" # Obsolete, replaced by profile-based auth
|
||||
|
||||
# Archive temporary/one-time setup scripts
|
||||
echo "Moving one-time setup scripts to archived directory..."
|
||||
mkdir -p "$BACKUP_DIR/one-time"
|
||||
git mv "scripts/utils/prepare-clean-repo.sh" "$BACKUP_DIR/one-time/"
|
||||
git mv "scripts/utils/fix-credential-references.sh" "$BACKUP_DIR/one-time/"
|
||||
|
||||
# Archive redundant container test scripts that can be consolidated
|
||||
echo "Archiving redundant container test scripts..."
|
||||
archive_script "test/container/test-container-privileged.sh" # Can be merged with test-basic-container.sh
|
||||
|
||||
# Archive our temporary reorganization scripts
|
||||
echo "Archiving temporary reorganization scripts..."
|
||||
git mv "reorganize-scripts.sh" "$BACKUP_DIR/one-time/"
|
||||
git mv "script-organization.md" "$BACKUP_DIR/one-time/"
|
||||
|
||||
# After archiving, create a consolidated container test script
|
||||
echo "Creating consolidated container test script..."
|
||||
cat > test/container/test-container.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Consolidated container test script
|
||||
# Usage: ./test-container.sh [basic|privileged|cleanup]
|
||||
|
||||
set -e
|
||||
|
||||
TEST_TYPE=${1:-basic}
|
||||
|
||||
case "$TEST_TYPE" in
|
||||
basic)
|
||||
echo "Running basic container test..."
|
||||
# Basic container test logic from test-basic-container.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Basic container test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
privileged)
|
||||
echo "Running privileged container test..."
|
||||
# Privileged container test logic from test-container-privileged.sh
|
||||
docker run --rm -it \
|
||||
--privileged \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Privileged container test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
cleanup)
|
||||
echo "Running container cleanup test..."
|
||||
# Container cleanup test logic from test-container-cleanup.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Container cleanup test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown test type: $TEST_TYPE"
|
||||
echo "Usage: ./test-container.sh [basic|privileged|cleanup]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Test complete!"
|
||||
EOF
|
||||
chmod +x test/container/test-container.sh
|
||||
|
||||
# Create a consolidated Claude test script
|
||||
echo "Creating consolidated Claude test script..."
|
||||
cat > test/claude/test-claude.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Consolidated Claude test script
|
||||
# Usage: ./test-claude.sh [direct|installation|no-firewall|response]
|
||||
|
||||
set -e
|
||||
|
||||
TEST_TYPE=${1:-direct}
|
||||
|
||||
case "$TEST_TYPE" in
|
||||
direct)
|
||||
echo "Testing direct Claude integration..."
|
||||
# Direct Claude test logic from test-claude-direct.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Direct Claude test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
installation)
|
||||
echo "Testing Claude installation..."
|
||||
# Installation test logic from test-claude-installation.sh and test-claude-version.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="claude-cli --version && claude --version" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
no-firewall)
|
||||
echo "Testing Claude without firewall..."
|
||||
# Test logic from test-claude-no-firewall.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="echo 'Claude without firewall test'" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e DISABLE_FIREWALL=true \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
response)
|
||||
echo "Testing Claude response..."
|
||||
# Test logic from test-claude-response.sh
|
||||
docker run --rm -it \
|
||||
-e REPO_FULL_NAME="owner/test-repo" \
|
||||
-e ISSUE_NUMBER="1" \
|
||||
-e IS_PULL_REQUEST="false" \
|
||||
-e COMMAND="claude \"Tell me a joke\"" \
|
||||
-e GITHUB_TOKEN="${GITHUB_TOKEN:-test-token}" \
|
||||
-e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-test-key}" \
|
||||
claude-code-runner:latest
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown test type: $TEST_TYPE"
|
||||
echo "Usage: ./test-claude.sh [direct|installation|no-firewall|response]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Test complete!"
|
||||
EOF
|
||||
chmod +x test/claude/test-claude.sh
|
||||
|
||||
# Create a consolidated build script
|
||||
echo "Creating consolidated build script..."
|
||||
cat > scripts/build/build.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Consolidated build script
|
||||
# Usage: ./build.sh [claude|claudecode|production]
|
||||
|
||||
set -e
|
||||
|
||||
BUILD_TYPE=${1:-claudecode}
|
||||
|
||||
case "$BUILD_TYPE" in
|
||||
claude)
|
||||
echo "Building Claude container..."
|
||||
docker build -f Dockerfile.claude -t claude-container:latest .
|
||||
;;
|
||||
|
||||
claudecode)
|
||||
echo "Building Claude Code runner Docker image..."
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:latest .
|
||||
;;
|
||||
|
||||
production)
|
||||
if [ ! -d "./claude-config" ]; then
|
||||
echo "Error: claude-config directory not found."
|
||||
echo "Please run ./scripts/setup/setup-claude-auth.sh first and copy the config."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building production image with pre-authenticated config..."
|
||||
cp Dockerfile.claudecode Dockerfile.claudecode.backup
|
||||
# Production build logic from update-production-image.sh
|
||||
# ... (truncated for brevity)
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:production .
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown build type: $BUILD_TYPE"
|
||||
echo "Usage: ./build.sh [claude|claudecode|production]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Build complete!"
|
||||
EOF
|
||||
chmod +x scripts/build/build.sh
|
||||
|
||||
# Update documentation to reflect the changes
|
||||
echo "Updating documentation..."
|
||||
sed -i 's|test-direct-claude.sh|test-claude.sh direct|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-direct.sh|test-claude.sh direct|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-version.sh|test-claude.sh installation|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-installation.sh|test-claude.sh installation|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-no-firewall.sh|test-claude.sh no-firewall|g' SCRIPTS.md
|
||||
sed -i 's|test-claude-response.sh|test-claude.sh response|g' SCRIPTS.md
|
||||
|
||||
sed -i 's|test-basic-container.sh|test-container.sh basic|g' SCRIPTS.md
|
||||
sed -i 's|test-container-privileged.sh|test-container.sh privileged|g' SCRIPTS.md
|
||||
sed -i 's|test-container-cleanup.sh|test-container.sh cleanup|g' SCRIPTS.md
|
||||
|
||||
sed -i 's|build-claude-container.sh|build.sh claude|g' SCRIPTS.md
|
||||
sed -i 's|build-claudecode.sh|build.sh claudecode|g' SCRIPTS.md
|
||||
sed -i 's|update-production-image.sh|build.sh production|g' SCRIPTS.md
|
||||
|
||||
# Create a final wrapper script for backward compatibility
|
||||
cat > build-claudecode.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/build/build.sh"
|
||||
exec scripts/build/build.sh claudecode "$@"
|
||||
EOF
|
||||
chmod +x build-claudecode.sh
|
||||
|
||||
# After all operations are complete, clean up this script too
|
||||
echo "Script cleanup complete!"
|
||||
echo
|
||||
echo "Note: This script (cleanup-scripts.sh) has completed its job and can now be removed."
|
||||
echo "After verifying the changes, you can remove it with:"
|
||||
echo "rm cleanup-scripts.sh"
|
||||
echo
|
||||
echo "To commit these changes, run:"
|
||||
echo "git add ."
|
||||
echo "git commit -m \"Clean up redundant scripts and consolidate functionality\""
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This script prepares a clean repository without sensitive files
|
||||
|
||||
# Set directories
|
||||
CURRENT_REPO="/home/jonflatt/n8n/claude-repo"
|
||||
CLEAN_REPO="/tmp/clean-repo"
|
||||
|
||||
# Create clean repo directory if it doesn't exist
|
||||
mkdir -p "$CLEAN_REPO"
|
||||
|
||||
# Files and patterns to exclude
|
||||
EXCLUDES=(
|
||||
".git"
|
||||
".env"
|
||||
".env.backup"
|
||||
"node_modules"
|
||||
"coverage"
|
||||
"\\"
|
||||
)
|
||||
|
||||
# Build rsync exclude arguments
|
||||
EXCLUDE_ARGS=""
|
||||
for pattern in "${EXCLUDES[@]}"; do
|
||||
EXCLUDE_ARGS="$EXCLUDE_ARGS --exclude='$pattern'"
|
||||
done
|
||||
|
||||
# Sync files to clean repo
|
||||
echo "Copying files to clean repository..."
|
||||
eval "rsync -av $EXCLUDE_ARGS $CURRENT_REPO/ $CLEAN_REPO/"
|
||||
|
||||
# Create a new .gitignore if it doesn't exist
|
||||
if [ ! -f "$CLEAN_REPO/.gitignore" ]; then
|
||||
echo "Creating .gitignore..."
|
||||
cat > "$CLEAN_REPO/.gitignore" << EOF
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.backup
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
|
||||
# Temp directory
|
||||
tmp/
|
||||
|
||||
# Test results
|
||||
test-results/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
/response.txt
|
||||
"\\"
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Clean repository prepared at $CLEAN_REPO"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Create a new GitHub repository"
|
||||
echo "2. Initialize the clean repository with git:"
|
||||
echo " cd $CLEAN_REPO"
|
||||
echo " git init"
|
||||
echo " git add ."
|
||||
echo " git commit -m \"Initial commit\""
|
||||
echo "3. Set the remote origin and push:"
|
||||
echo " git remote add origin <new-repository-url>"
|
||||
echo " git push -u origin main"
|
||||
echo ""
|
||||
echo "Important: Make sure to review the files once more before committing to ensure no sensitive data is included."
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to reorganize the script files according to the proposed structure
|
||||
echo "Starting script reorganization..."
|
||||
|
||||
# Create directory structure
|
||||
echo "Creating directory structure..."
|
||||
mkdir -p scripts/setup
|
||||
mkdir -p scripts/build
|
||||
mkdir -p scripts/aws
|
||||
mkdir -p scripts/runtime
|
||||
mkdir -p scripts/security
|
||||
mkdir -p scripts/utils
|
||||
|
||||
mkdir -p test/integration
|
||||
mkdir -p test/aws
|
||||
mkdir -p test/container
|
||||
mkdir -p test/claude
|
||||
mkdir -p test/security
|
||||
mkdir -p test/utils
|
||||
|
||||
# Move setup scripts
|
||||
echo "Moving setup scripts..."
|
||||
git mv scripts/setup.sh scripts/setup/
|
||||
git mv scripts/setup-precommit.sh scripts/setup/
|
||||
git mv setup-claude-auth.sh scripts/setup/
|
||||
git mv setup-new-repo.sh scripts/setup/
|
||||
git mv create-new-repo.sh scripts/setup/
|
||||
|
||||
# Move build scripts
|
||||
echo "Moving build scripts..."
|
||||
git mv build-claude-container.sh scripts/build/
|
||||
git mv build-claudecode.sh scripts/build/
|
||||
git mv update-production-image.sh scripts/build/
|
||||
|
||||
# Move AWS scripts
|
||||
echo "Moving AWS scripts..."
|
||||
git mv scripts/create-aws-profile.sh scripts/aws/
|
||||
git mv scripts/migrate-aws-credentials.sh scripts/aws/
|
||||
git mv scripts/setup-aws-profiles.sh scripts/aws/
|
||||
git mv update-aws-creds.sh scripts/aws/
|
||||
|
||||
# Move runtime scripts
|
||||
echo "Moving runtime scripts..."
|
||||
git mv start-api.sh scripts/runtime/
|
||||
git mv entrypoint.sh scripts/runtime/
|
||||
git mv claudecode-entrypoint.sh scripts/runtime/
|
||||
git mv startup.sh scripts/runtime/
|
||||
git mv claude-wrapper.sh scripts/runtime/
|
||||
|
||||
# Move security scripts
|
||||
echo "Moving security scripts..."
|
||||
git mv init-firewall.sh scripts/security/
|
||||
git mv accept-permissions.sh scripts/security/
|
||||
git mv fix-credential-references.sh scripts/security/
|
||||
|
||||
# Move utility scripts
|
||||
echo "Moving utility scripts..."
|
||||
git mv scripts/ensure-test-dirs.sh scripts/utils/
|
||||
git mv prepare-clean-repo.sh scripts/utils/
|
||||
git mv volume-test.sh scripts/utils/
|
||||
|
||||
# Move test scripts
|
||||
echo "Moving test scripts..."
|
||||
git mv test/test-full-flow.sh test/integration/
|
||||
git mv test/test-claudecode-docker.sh test/integration/
|
||||
|
||||
git mv test/test-aws-profile.sh test/aws/
|
||||
git mv test/test-aws-mount.sh test/aws/
|
||||
|
||||
git mv test/test-basic-container.sh test/container/
|
||||
git mv test/test-container-cleanup.sh test/container/
|
||||
git mv test/test-container-privileged.sh test/container/
|
||||
|
||||
git mv test/test-claude-direct.sh test/claude/
|
||||
git mv test/test-claude-no-firewall.sh test/claude/
|
||||
git mv test/test-claude-installation.sh test/claude/
|
||||
git mv test/test-claude-version.sh test/claude/
|
||||
git mv test/test-claude-response.sh test/claude/
|
||||
git mv test/test-direct-claude.sh test/claude/
|
||||
|
||||
git mv test/test-firewall.sh test/security/
|
||||
git mv test/test-with-auth.sh test/security/
|
||||
git mv test/test-github-token.sh test/security/
|
||||
|
||||
# Create wrapper scripts for backward compatibility
|
||||
echo "Creating wrapper scripts for backward compatibility..."
|
||||
|
||||
cat > setup-claude-auth.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/setup/setup-claude-auth.sh"
|
||||
exec scripts/setup/setup-claude-auth.sh "$@"
|
||||
EOF
|
||||
chmod +x setup-claude-auth.sh
|
||||
|
||||
cat > build-claudecode.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/build/build-claudecode.sh"
|
||||
exec scripts/build/build-claudecode.sh "$@"
|
||||
EOF
|
||||
chmod +x build-claudecode.sh
|
||||
|
||||
cat > start-api.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Wrapper script for backward compatibility
|
||||
echo "This script is now located at scripts/runtime/start-api.sh"
|
||||
exec scripts/runtime/start-api.sh "$@"
|
||||
EOF
|
||||
chmod +x start-api.sh
|
||||
|
||||
# Update docker-compose.yml file if it references specific script paths
|
||||
echo "Checking for docker-compose.yml updates..."
|
||||
if [ -f docker-compose.yml ]; then
|
||||
sed -i 's#./claudecode-entrypoint.sh#./scripts/runtime/claudecode-entrypoint.sh#g' docker-compose.yml
|
||||
sed -i 's#./entrypoint.sh#./scripts/runtime/entrypoint.sh#g' docker-compose.yml
|
||||
fi
|
||||
|
||||
# Update Dockerfile.claudecode if it references specific script paths
|
||||
echo "Checking for Dockerfile.claudecode updates..."
|
||||
if [ -f Dockerfile.claudecode ]; then
|
||||
sed -i 's#COPY init-firewall.sh#COPY scripts/security/init-firewall.sh#g' Dockerfile.claudecode
|
||||
sed -i 's#COPY claudecode-entrypoint.sh#COPY scripts/runtime/claudecode-entrypoint.sh#g' Dockerfile.claudecode
|
||||
fi
|
||||
|
||||
echo "Script reorganization complete!"
|
||||
echo
|
||||
echo "Please review the changes and test that all scripts still work properly."
|
||||
echo "You may need to update additional references in other files or scripts."
|
||||
echo
|
||||
echo "To commit these changes, run:"
|
||||
echo "git add ."
|
||||
echo "git commit -m \"Reorganize scripts into a more structured directory layout\""
|
||||
@@ -1,128 +0,0 @@
|
||||
# Script Organization Proposal
|
||||
|
||||
## Categories of Scripts
|
||||
|
||||
### 1. Setup and Installation
|
||||
- `scripts/setup.sh` - Main setup script for the project
|
||||
- `scripts/setup-precommit.sh` - Sets up pre-commit hooks
|
||||
- `setup-claude-auth.sh` - Sets up Claude authentication
|
||||
- `setup-new-repo.sh` - Sets up a new clean repository
|
||||
- `create-new-repo.sh` - Creates a new repository
|
||||
|
||||
### 2. Build Scripts
|
||||
- `build-claude-container.sh` - Builds the Claude container
|
||||
- `build-claudecode.sh` - Builds the Claude Code runner Docker image
|
||||
- `update-production-image.sh` - Updates the production Docker image
|
||||
|
||||
### 3. AWS Configuration and Credentials
|
||||
- `scripts/create-aws-profile.sh` - Creates AWS profiles programmatically
|
||||
- `scripts/migrate-aws-credentials.sh` - Migrates AWS credentials
|
||||
- `scripts/setup-aws-profiles.sh` - Sets up AWS profiles
|
||||
- `update-aws-creds.sh` - Updates AWS credentials
|
||||
|
||||
### 4. Runtime and Execution
|
||||
- `start-api.sh` - Starts the API server
|
||||
- `entrypoint.sh` - Container entrypoint script
|
||||
- `claudecode-entrypoint.sh` - Claude Code container entrypoint
|
||||
- `startup.sh` - Startup script
|
||||
- `claude-wrapper.sh` - Wrapper for Claude CLI
|
||||
|
||||
### 5. Network and Security
|
||||
- `init-firewall.sh` - Initializes firewall for containers
|
||||
- `accept-permissions.sh` - Handles permission acceptance
|
||||
- `fix-credential-references.sh` - Fixes credential references
|
||||
|
||||
### 6. Testing
|
||||
- `test/test-full-flow.sh` - Tests the full workflow
|
||||
- `test/test-claudecode-docker.sh` - Tests Claude Code Docker setup
|
||||
- `test/test-github-token.sh` - Tests GitHub token
|
||||
- `test/test-aws-profile.sh` - Tests AWS profile
|
||||
- `test/test-basic-container.sh` - Tests basic container functionality
|
||||
- `test/test-claude-direct.sh` - Tests direct Claude integration
|
||||
- `test/test-firewall.sh` - Tests firewall configuration
|
||||
- `test/test-direct-claude.sh` - Tests direct Claude access
|
||||
- `test/test-claude-no-firewall.sh` - Tests Claude without firewall
|
||||
- `test/test-claude-installation.sh` - Tests Claude installation
|
||||
- `test/test-aws-mount.sh` - Tests AWS mount functionality
|
||||
- `test/test-claude-version.sh` - Tests Claude version
|
||||
- `test/test-container-cleanup.sh` - Tests container cleanup
|
||||
- `test/test-claude-response.sh` - Tests Claude response
|
||||
- `test/test-container-privileged.sh` - Tests container privileged mode
|
||||
- `test/test-with-auth.sh` - Tests with authentication
|
||||
|
||||
### 7. Utility Scripts
|
||||
- `scripts/ensure-test-dirs.sh` - Ensures test directories exist
|
||||
- `prepare-clean-repo.sh` - Prepares a clean repository
|
||||
- `volume-test.sh` - Tests volume mounting
|
||||
|
||||
## Proposed Directory Structure
|
||||
|
||||
```
|
||||
/claude-repo
|
||||
├── scripts/
|
||||
│ ├── setup/
|
||||
│ │ ├── setup.sh
|
||||
│ │ ├── setup-precommit.sh
|
||||
│ │ ├── setup-claude-auth.sh
|
||||
│ │ ├── setup-new-repo.sh
|
||||
│ │ └── create-new-repo.sh
|
||||
│ ├── build/
|
||||
│ │ ├── build-claude-container.sh
|
||||
│ │ ├── build-claudecode.sh
|
||||
│ │ └── update-production-image.sh
|
||||
│ ├── aws/
|
||||
│ │ ├── create-aws-profile.sh
|
||||
│ │ ├── migrate-aws-credentials.sh
|
||||
│ │ ├── setup-aws-profiles.sh
|
||||
│ │ └── update-aws-creds.sh
|
||||
│ ├── runtime/
|
||||
│ │ ├── start-api.sh
|
||||
│ │ ├── entrypoint.sh
|
||||
│ │ ├── claudecode-entrypoint.sh
|
||||
│ │ ├── startup.sh
|
||||
│ │ └── claude-wrapper.sh
|
||||
│ ├── security/
|
||||
│ │ ├── init-firewall.sh
|
||||
│ │ ├── accept-permissions.sh
|
||||
│ │ └── fix-credential-references.sh
|
||||
│ └── utils/
|
||||
│ ├── ensure-test-dirs.sh
|
||||
│ ├── prepare-clean-repo.sh
|
||||
│ └── volume-test.sh
|
||||
├── test/
|
||||
│ ├── integration/
|
||||
│ │ ├── test-full-flow.sh
|
||||
│ │ ├── test-claudecode-docker.sh
|
||||
│ │ └── ...
|
||||
│ ├── aws/
|
||||
│ │ ├── test-aws-profile.sh
|
||||
│ │ ├── test-aws-mount.sh
|
||||
│ │ └── ...
|
||||
│ ├── container/
|
||||
│ │ ├── test-basic-container.sh
|
||||
│ │ ├── test-container-cleanup.sh
|
||||
│ │ ├── test-container-privileged.sh
|
||||
│ │ └── ...
|
||||
│ ├── claude/
|
||||
│ │ ├── test-claude-direct.sh
|
||||
│ │ ├── test-claude-no-firewall.sh
|
||||
│ │ ├── test-claude-installation.sh
|
||||
│ │ ├── test-claude-version.sh
|
||||
│ │ ├── test-claude-response.sh
|
||||
│ │ └── ...
|
||||
│ ├── security/
|
||||
│ │ ├── test-firewall.sh
|
||||
│ │ ├── test-with-auth.sh
|
||||
│ │ └── test-github-token.sh
|
||||
│ └── utils/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Create the new directory structure
|
||||
2. Move scripts to their appropriate categories
|
||||
3. Update references in scripts to point to new locations
|
||||
4. Update documentation to reflect new organization
|
||||
5. Create wrapper scripts if needed to maintain backward compatibility
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Testing if Claude executable runs..."
|
||||
|
||||
docker run --rm \
|
||||
--entrypoint /bin/bash \
|
||||
claude-code-runner:latest \
|
||||
-c "cd /workspace && /usr/local/share/npm-global/bin/claude --version 2>&1 || echo 'Exit code: $?'"
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Testing Claude directly without entrypoint..."
|
||||
|
||||
docker run --rm \
|
||||
--privileged \
|
||||
-v $HOME/.aws:/home/node/.aws:ro \
|
||||
--entrypoint /bin/bash \
|
||||
claude-code-runner:latest \
|
||||
-c "cd /workspace && export HOME=/home/node && export PATH=/usr/local/share/npm-global/bin:\$PATH && export AWS_PROFILE=claude-webhook && export AWS_REGION=us-east-2 && export AWS_CONFIG_FILE=/home/node/.aws/config && export AWS_SHARED_CREDENTIALS_FILE=/home/node/.aws/credentials && export CLAUDE_CODE_USE_BEDROCK=1 && export ANTHROPIC_MODEL=us.anthropic.claude-3-7-sonnet-20250219-v1:0 && /usr/local/bin/init-firewall.sh && claude --print 'Hello world' 2>&1"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update AWS credentials in the environment
|
||||
export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-dummy-access-key}"
|
||||
export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-dummy-secret-key}"
|
||||
|
||||
# Create or update .env file with the new credentials
|
||||
if [ -f .env ]; then
|
||||
# Update existing .env file
|
||||
sed -i "s/^AWS_ACCESS_KEY_ID=.*/AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID/" .env
|
||||
sed -i "s/^AWS_SECRET_ACCESS_KEY=.*/AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY/" .env
|
||||
else
|
||||
# Create new .env file from example
|
||||
cp .env.example .env
|
||||
sed -i "s/^AWS_ACCESS_KEY_ID=.*/AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID/" .env
|
||||
sed -i "s/^AWS_SECRET_ACCESS_KEY=.*/AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY/" .env
|
||||
fi
|
||||
|
||||
echo "AWS credentials updated successfully."
|
||||
echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:0:3}...${AWS_SECRET_ACCESS_KEY:(-3)}"
|
||||
|
||||
# Export the credentials for current session
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
echo "Credentials exported to current shell environment."
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Migration script to transition from static AWS credentials to best practices
|
||||
|
||||
echo "AWS Credential Migration Script"
|
||||
echo "=============================="
|
||||
echo
|
||||
|
||||
# Function to check if running on EC2
|
||||
check_ec2() {
|
||||
if curl -s -m 1 http://169.254.169.254/latest/meta-data/ > /dev/null 2>&1; then
|
||||
echo "✅ Running on EC2 instance"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Not running on EC2 instance"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if running in ECS
|
||||
check_ecs() {
|
||||
if [ -n "${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}" ]; then
|
||||
echo "✅ Running in ECS with task role"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Not running in ECS"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check for static credentials
|
||||
check_static_credentials() {
|
||||
if [ -n "${AWS_ACCESS_KEY_ID}" ] && [ -n "${AWS_SECRET_ACCESS_KEY}" ]; then
|
||||
echo "⚠️ Found static AWS credentials in environment"
|
||||
return 0
|
||||
else
|
||||
echo "✅ No static credentials in environment"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to update .env file
|
||||
update_env_file() {
|
||||
if [ -f .env ]; then
|
||||
echo "Updating .env file..."
|
||||
|
||||
# Comment out static credentials
|
||||
sed -i 's/^AWS_ACCESS_KEY_ID=/#AWS_ACCESS_KEY_ID=/' .env
|
||||
sed -i 's/^AWS_SECRET_ACCESS_KEY=/#AWS_SECRET_ACCESS_KEY=/' .env
|
||||
|
||||
# Add migration notes
|
||||
echo "" >> .env
|
||||
echo "# AWS Credentials migrated to use IAM roles/instance profiles" >> .env
|
||||
echo "# See docs/aws-authentication-best-practices.md for details" >> .env
|
||||
echo "" >> .env
|
||||
|
||||
echo "✅ Updated .env file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main migration process
|
||||
echo "1. Checking current environment..."
|
||||
echo
|
||||
|
||||
if check_ec2; then
|
||||
echo " Recommendation: Use IAM instance profile"
|
||||
echo " The application will automatically use instance metadata"
|
||||
elif check_ecs; then
|
||||
echo " Recommendation: Use ECS task role"
|
||||
echo " The application will automatically use task credentials"
|
||||
else
|
||||
echo " Recommendation: Use temporary credentials with STS AssumeRole"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "2. Checking for static credentials..."
|
||||
echo
|
||||
|
||||
if check_static_credentials; then
|
||||
echo " ⚠️ WARNING: Static credentials should be replaced with temporary credentials"
|
||||
echo
|
||||
read -p " Do you want to disable static credentials? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
update_env_file
|
||||
echo
|
||||
echo " To use temporary credentials, configure:"
|
||||
echo " - AWS_ROLE_ARN: The IAM role to assume"
|
||||
echo " - Or use AWS CLI profiles with assume role"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "3. Testing new credential provider..."
|
||||
echo
|
||||
|
||||
# Test the credential provider
|
||||
node test/test-aws-credential-provider.js
|
||||
|
||||
echo
|
||||
echo "Migration complete!"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. Review docs/aws-authentication-best-practices.md"
|
||||
echo "2. Update your deployment configuration"
|
||||
echo "3. Test the application with new credential provider"
|
||||
echo "4. Remove update-aws-creds.sh script (no longer needed)"
|
||||
echo
|
||||
|
||||
# Check if update-aws-creds.sh exists and suggest removal
|
||||
if [ -f update-aws-creds.sh ]; then
|
||||
echo "⚠️ Found update-aws-creds.sh - this script is no longer needed"
|
||||
read -p "Do you want to remove it? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
rm update-aws-creds.sh
|
||||
echo "✅ Removed update-aws-creds.sh"
|
||||
fi
|
||||
fi
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build the Claude Code container
|
||||
echo "Building Claude Code container..."
|
||||
docker build -t claudecode:latest -f Dockerfile.claude .
|
||||
|
||||
echo "Container built successfully. You can run it with:"
|
||||
echo "docker run --rm claudecode:latest \"claude --help\""
|
||||
|
||||
# Enable container mode in the .env file if it's not already set
|
||||
if ! grep -q "CLAUDE_USE_CONTAINERS=1" .env 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Enabling container mode in .env file..."
|
||||
echo "CLAUDE_USE_CONTAINERS=1" >> .env
|
||||
echo "CLAUDE_CONTAINER_IMAGE=claudecode:latest" >> .env
|
||||
echo "Container mode enabled in .env file"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! You can now use the Claude API with container mode."
|
||||
echo "To test it, run:"
|
||||
echo "node test-claude-api.js owner/repo container \"Your command here\""
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build the Claude Code runner Docker image
|
||||
|
||||
echo "Building Claude Code runner Docker image..."
|
||||
docker build -f Dockerfile.claudecode -t claudecode:latest .
|
||||
|
||||
# Also tag it with the old name for backward compatibility
|
||||
docker tag claudecode:latest claude-code-runner:latest
|
||||
|
||||
echo "Build complete!"
|
||||
echo "Image tagged as:"
|
||||
echo " - claudecode:latest (primary)"
|
||||
echo " - claude-code-runner:latest (backward compatibility)"
|
||||
@@ -14,7 +14,7 @@ case "$BUILD_TYPE" in
|
||||
|
||||
claudecode)
|
||||
echo "Building Claude Code runner Docker image..."
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:latest .
|
||||
docker build -f Dockerfile.claudecode -t claudecode:latest .
|
||||
;;
|
||||
|
||||
production)
|
||||
@@ -25,10 +25,106 @@ case "$BUILD_TYPE" in
|
||||
fi
|
||||
|
||||
echo "Building production image with pre-authenticated config..."
|
||||
cp Dockerfile.claudecode Dockerfile.claudecode.backup
|
||||
# Production build logic from update-production-image.sh
|
||||
# ... (truncated for brevity)
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:production .
|
||||
|
||||
# Create a temporary production Dockerfile with claude-config enabled
|
||||
cat > Dockerfile.claudecode.prod << 'EOF'
|
||||
FROM node:24
|
||||
|
||||
# Install dependencies
|
||||
RUN apt update && apt install -y less \
|
||||
git \
|
||||
procps \
|
||||
sudo \
|
||||
fzf \
|
||||
zsh \
|
||||
man-db \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq
|
||||
|
||||
# Set up npm global directory
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R node:node /usr/local/share
|
||||
|
||||
# Configure zsh and command history
|
||||
ENV USERNAME=node
|
||||
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
&& mkdir /commandhistory \
|
||||
&& touch /commandhistory/.bash_history \
|
||||
&& chown -R $USERNAME /commandhistory
|
||||
|
||||
# Create workspace and config directories
|
||||
RUN mkdir -p /workspace /home/node/.claude && \
|
||||
chown -R node:node /workspace /home/node/.claude
|
||||
|
||||
# Switch to node user temporarily for npm install
|
||||
USER node
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Switch back to root
|
||||
USER root
|
||||
|
||||
# Copy the pre-authenticated Claude config to BOTH root and node user (PRODUCTION ONLY)
|
||||
COPY claude-config /root/.claude
|
||||
COPY claude-config /home/node/.claude
|
||||
RUN chown -R node:node /home/node/.claude
|
||||
|
||||
# Copy the rest of the setup
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install delta and zsh
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget "https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_${ARCH}.deb" && \
|
||||
sudo dpkg -i "git-delta_0.18.2_${ARCH}.deb" && \
|
||||
rm "git-delta_0.18.2_${ARCH}.deb"
|
||||
|
||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.0/zsh-in-docker.sh)" -- \
|
||||
-p git \
|
||||
-p fzf \
|
||||
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
|
||||
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
|
||||
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
-x
|
||||
|
||||
# Copy firewall and entrypoint scripts
|
||||
COPY scripts/security/init-firewall.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/init-firewall.sh && \
|
||||
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
|
||||
chmod 0440 /etc/sudoers.d/node-firewall
|
||||
|
||||
# Create scripts directory and copy unified entrypoint script
|
||||
RUN mkdir -p /scripts/runtime
|
||||
COPY scripts/runtime/claudecode-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY scripts/runtime/claudecode-entrypoint.sh /scripts/runtime/claudecode-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh && \
|
||||
chmod +x /scripts/runtime/claudecode-entrypoint.sh
|
||||
|
||||
# Set the default shell to bash
|
||||
ENV SHELL /bin/zsh
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
# Run as root to allow permission management
|
||||
USER root
|
||||
|
||||
# Use the custom entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
EOF
|
||||
|
||||
# Build the production image
|
||||
docker build -f Dockerfile.claudecode.prod -t claudecode:production .
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f Dockerfile.claudecode.prod
|
||||
;;
|
||||
|
||||
*)
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/bin/bash
|
||||
if [ ! -d "./claude-config" ]; then
|
||||
echo "Error: claude-config directory not found."
|
||||
echo "Please run ./setup-claude-auth.sh first and copy the config."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating Dockerfile.claudecode to include pre-authenticated config..."
|
||||
|
||||
# Create a backup of the original Dockerfile
|
||||
cp Dockerfile.claudecode Dockerfile.claudecode.backup
|
||||
|
||||
# Update the Dockerfile to copy the claude config
|
||||
cat > Dockerfile.claudecode.tmp << 'EOF'
|
||||
FROM node:20
|
||||
|
||||
# Install dependencies
|
||||
RUN apt update && apt install -y less \
|
||||
git \
|
||||
procps \
|
||||
sudo \
|
||||
fzf \
|
||||
zsh \
|
||||
man-db \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq
|
||||
|
||||
# Set up npm global directory
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R node:node /usr/local/share
|
||||
|
||||
# Configure zsh and command history
|
||||
ENV USERNAME=node
|
||||
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
&& mkdir /commandhistory \
|
||||
&& touch /commandhistory/.bash_history \
|
||||
&& chown -R $USERNAME /commandhistory
|
||||
|
||||
# Create workspace and config directories
|
||||
RUN mkdir -p /workspace /home/node/.claude && \
|
||||
chown -R node:node /workspace /home/node/.claude
|
||||
|
||||
# Switch to node user temporarily for npm install
|
||||
USER node
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Switch back to root
|
||||
USER root
|
||||
|
||||
# Copy the pre-authenticated Claude config
|
||||
COPY claude-config /root/.claude
|
||||
|
||||
# Copy the rest of the setup
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install delta and zsh
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget "https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_${ARCH}.deb" && \
|
||||
sudo dpkg -i "git-delta_0.18.2_${ARCH}.deb" && \
|
||||
rm "git-delta_0.18.2_${ARCH}.deb"
|
||||
|
||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.0/zsh-in-docker.sh)" -- \
|
||||
-p git \
|
||||
-p fzf \
|
||||
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
|
||||
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
|
||||
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
-x
|
||||
|
||||
# Copy firewall and entrypoint scripts
|
||||
COPY init-firewall.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/init-firewall.sh && \
|
||||
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
|
||||
chmod 0440 /etc/sudoers.d/node-firewall
|
||||
|
||||
COPY claudecode-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Set the default shell to bash
|
||||
ENV SHELL /bin/zsh
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
# Run as root to allow permission management
|
||||
USER root
|
||||
|
||||
# Use the custom entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
EOF
|
||||
|
||||
mv Dockerfile.claudecode.tmp Dockerfile.claudecode
|
||||
|
||||
echo "Building new production image..."
|
||||
docker build -f Dockerfile.claudecode -t claude-code-runner:latest .
|
||||
|
||||
echo "Production image updated successfully!"
|
||||
88
scripts/combine-coverage.js
Executable file
88
scripts/combine-coverage.js
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Combine coverage reports from main project and CLI
|
||||
*/
|
||||
|
||||
// Ensure coverage directories exist
|
||||
const mainCoverageDir = path.join(__dirname, '..', 'coverage');
|
||||
const cliCoverageDir = path.join(__dirname, '..', 'cli', 'coverage');
|
||||
const combinedCoverageDir = path.join(__dirname, '..', 'coverage-combined');
|
||||
|
||||
// Create combined coverage directory
|
||||
if (!fs.existsSync(combinedCoverageDir)) {
|
||||
fs.mkdirSync(combinedCoverageDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('Generating main project coverage...');
|
||||
try {
|
||||
execSync('npm run test:ci', { stdio: 'inherit', cwd: path.join(__dirname, '..') });
|
||||
} catch (error) {
|
||||
console.error('Failed to generate main project coverage');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nGenerating CLI coverage...');
|
||||
try {
|
||||
execSync('npm run test:coverage', { stdio: 'inherit', cwd: path.join(__dirname, '..', 'cli') });
|
||||
} catch (error) {
|
||||
console.error('Failed to generate CLI coverage');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if both coverage files exist
|
||||
const mainLcov = path.join(mainCoverageDir, 'lcov.info');
|
||||
const cliLcov = path.join(cliCoverageDir, 'lcov.info');
|
||||
|
||||
if (!fs.existsSync(mainLcov)) {
|
||||
console.error('Main project lcov.info not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(cliLcov)) {
|
||||
console.error('CLI lcov.info not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read both lcov files
|
||||
const mainLcovContent = fs.readFileSync(mainLcov, 'utf8');
|
||||
const cliLcovContent = fs.readFileSync(cliLcov, 'utf8');
|
||||
|
||||
// Adjust CLI paths to be relative to project root
|
||||
const adjustedCliLcov = cliLcovContent.replace(/SF:src\//g, 'SF:cli/src/');
|
||||
|
||||
// Combine lcov files
|
||||
const combinedLcov = mainLcovContent + '\n' + adjustedCliLcov;
|
||||
|
||||
// Write combined lcov file
|
||||
const combinedLcovPath = path.join(combinedCoverageDir, 'lcov.info');
|
||||
fs.writeFileSync(combinedLcovPath, combinedLcov);
|
||||
|
||||
console.log('\nCombined coverage report written to:', combinedLcovPath);
|
||||
|
||||
// Copy coverage-final.json files as well for better reporting
|
||||
if (fs.existsSync(path.join(mainCoverageDir, 'coverage-final.json'))) {
|
||||
const mainJson = JSON.parse(fs.readFileSync(path.join(mainCoverageDir, 'coverage-final.json'), 'utf8'));
|
||||
const cliJson = JSON.parse(fs.readFileSync(path.join(cliCoverageDir, 'coverage-final.json'), 'utf8'));
|
||||
|
||||
// Adjust CLI paths in JSON
|
||||
const adjustedCliJson = {};
|
||||
for (const [key, value] of Object.entries(cliJson)) {
|
||||
const adjustedKey = key.replace(/^src\//, 'cli/src/');
|
||||
adjustedCliJson[adjustedKey] = value;
|
||||
}
|
||||
|
||||
// Combine JSON coverage
|
||||
const combinedJson = { ...mainJson, ...adjustedCliJson };
|
||||
fs.writeFileSync(
|
||||
path.join(combinedCoverageDir, 'coverage-final.json'),
|
||||
JSON.stringify(combinedJson, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\nCoverage combination complete!');
|
||||
console.log('Upload coverage-combined/lcov.info to Codecov for full project coverage.');
|
||||
@@ -1,336 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# GitHub Actions Runner Management Script
|
||||
# Manage the webhook deployment runner service
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
SERVICE_NAME="webhook-deployment-runner"
|
||||
RUNNER_DIR="/home/jonflatt/github-actions-runner"
|
||||
RUNNER_USER="jonflatt"
|
||||
|
||||
# Function to print usage
|
||||
usage() {
|
||||
echo -e "${BLUE}GitHub Actions Runner Management Tool${NC}"
|
||||
echo -e "${BLUE}=====================================${NC}"
|
||||
echo -e "\nUsage: $0 [command]"
|
||||
echo -e "\nCommands:"
|
||||
echo -e " ${GREEN}start${NC} - Start the runner service"
|
||||
echo -e " ${GREEN}stop${NC} - Stop the runner service"
|
||||
echo -e " ${GREEN}restart${NC} - Restart the runner service"
|
||||
echo -e " ${GREEN}status${NC} - Check runner service status"
|
||||
echo -e " ${GREEN}logs${NC} - View runner logs (live)"
|
||||
echo -e " ${GREEN}logs-tail${NC} - View last 50 lines of logs"
|
||||
echo -e " ${GREEN}update${NC} - Update runner to latest version"
|
||||
echo -e " ${GREEN}config${NC} - Show runner configuration"
|
||||
echo -e " ${GREEN}health${NC} - Check runner health"
|
||||
echo -e " ${GREEN}jobs${NC} - Show recent job history"
|
||||
echo -e " ${GREEN}cleanup${NC} - Clean up work directory"
|
||||
echo -e " ${GREEN}info${NC} - Show runner information"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if running with correct permissions
|
||||
check_permissions() {
|
||||
if [[ $EUID -ne 0 ]] && [[ "$1" =~ ^(start|stop|restart|update)$ ]]; then
|
||||
echo -e "${RED}Error: This command requires sudo privileges${NC}"
|
||||
echo -e "${YELLOW}Run: sudo $0 $1${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start the runner
|
||||
start_runner() {
|
||||
echo -e "${YELLOW}Starting runner service...${NC}"
|
||||
systemctl start $SERVICE_NAME
|
||||
sleep 2
|
||||
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||
echo -e "${GREEN}✓ Runner started successfully${NC}"
|
||||
systemctl status $SERVICE_NAME --no-pager | head -n 10
|
||||
else
|
||||
echo -e "${RED}✗ Failed to start runner${NC}"
|
||||
systemctl status $SERVICE_NAME --no-pager
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop the runner
|
||||
stop_runner() {
|
||||
echo -e "${YELLOW}Stopping runner service...${NC}"
|
||||
systemctl stop $SERVICE_NAME
|
||||
echo -e "${GREEN}✓ Runner stopped${NC}"
|
||||
}
|
||||
|
||||
# Restart the runner
|
||||
restart_runner() {
|
||||
echo -e "${YELLOW}Restarting runner service...${NC}"
|
||||
systemctl restart $SERVICE_NAME
|
||||
sleep 2
|
||||
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||
echo -e "${GREEN}✓ Runner restarted successfully${NC}"
|
||||
systemctl status $SERVICE_NAME --no-pager | head -n 10
|
||||
else
|
||||
echo -e "${RED}✗ Failed to restart runner${NC}"
|
||||
systemctl status $SERVICE_NAME --no-pager
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check runner status
|
||||
check_status() {
|
||||
echo -e "${BLUE}Runner Service Status${NC}"
|
||||
echo -e "${BLUE}===================${NC}"
|
||||
systemctl status $SERVICE_NAME --no-pager
|
||||
|
||||
echo -e "\n${BLUE}Runner Process Info${NC}"
|
||||
echo -e "${BLUE}===================${NC}"
|
||||
ps aux | grep -E "(Runner.Listener|run.sh)" | grep -v grep || echo "No runner processes found"
|
||||
}
|
||||
|
||||
# View logs
|
||||
view_logs() {
|
||||
echo -e "${YELLOW}Viewing live logs (Ctrl+C to exit)...${NC}"
|
||||
journalctl -u $SERVICE_NAME -f
|
||||
}
|
||||
|
||||
# View last 50 lines of logs
|
||||
view_logs_tail() {
|
||||
echo -e "${BLUE}Last 50 lines of runner logs${NC}"
|
||||
echo -e "${BLUE}===========================${NC}"
|
||||
journalctl -u $SERVICE_NAME -n 50 --no-pager
|
||||
}
|
||||
|
||||
# Update runner
|
||||
update_runner() {
|
||||
echo -e "${YELLOW}Updating GitHub Actions Runner...${NC}"
|
||||
|
||||
# Stop the service
|
||||
systemctl stop $SERVICE_NAME
|
||||
|
||||
# Get current version
|
||||
CURRENT_VERSION=$($RUNNER_DIR/bin/Runner.Listener --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo "unknown")
|
||||
echo -e "Current version: ${YELLOW}$CURRENT_VERSION${NC}"
|
||||
|
||||
# Get latest version
|
||||
LATEST_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
|
||||
echo -e "Latest version: ${GREEN}$LATEST_VERSION${NC}"
|
||||
|
||||
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
|
||||
echo -e "${GREEN}✓ Runner is already up to date${NC}"
|
||||
systemctl start $SERVICE_NAME
|
||||
return
|
||||
fi
|
||||
|
||||
# Backup current runner
|
||||
echo -e "${YELLOW}Backing up current runner...${NC}"
|
||||
cd $RUNNER_DIR
|
||||
tar -czf runner-backup-$(date +%Y%m%d-%H%M%S).tar.gz bin externals
|
||||
|
||||
# Download and extract new version
|
||||
echo -e "${YELLOW}Downloading new version...${NC}"
|
||||
curl -o actions-runner-linux-x64.tar.gz -L "https://github.com/actions/runner/releases/download/v${LATEST_VERSION}/actions-runner-linux-x64-${LATEST_VERSION}.tar.gz"
|
||||
tar xzf ./actions-runner-linux-x64.tar.gz
|
||||
rm actions-runner-linux-x64.tar.gz
|
||||
|
||||
# Start the service
|
||||
systemctl start $SERVICE_NAME
|
||||
echo -e "${GREEN}✓ Runner updated to version $LATEST_VERSION${NC}"
|
||||
}
|
||||
|
||||
# Show configuration
|
||||
show_config() {
|
||||
echo -e "${BLUE}Runner Configuration${NC}"
|
||||
echo -e "${BLUE}===================${NC}"
|
||||
|
||||
if [ -f "$RUNNER_DIR/.runner" ]; then
|
||||
echo -e "\n${GREEN}Runner Settings:${NC}"
|
||||
cat "$RUNNER_DIR/.runner" | jq '.' 2>/dev/null || cat "$RUNNER_DIR/.runner"
|
||||
fi
|
||||
|
||||
if [ -f "$RUNNER_DIR/.credentials" ]; then
|
||||
echo -e "\n${GREEN}Runner Registration:${NC}"
|
||||
echo "Runner is registered (credentials file exists)"
|
||||
else
|
||||
echo -e "\n${RED}Runner is not configured${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}Service Configuration:${NC}"
|
||||
systemctl show $SERVICE_NAME | grep -E "(LoadState|ActiveState|SubState|MainPID|Environment)"
|
||||
}
|
||||
|
||||
# Check health
|
||||
check_health() {
|
||||
echo -e "${BLUE}Runner Health Check${NC}"
|
||||
echo -e "${BLUE}==================${NC}"
|
||||
|
||||
# Check service status
|
||||
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||
echo -e "${GREEN}✓ Service is running${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Service is not running${NC}"
|
||||
fi
|
||||
|
||||
# Check disk space
|
||||
DISK_USAGE=$(df -h $RUNNER_DIR | awk 'NR==2 {print $5}' | sed 's/%//')
|
||||
if [ "$DISK_USAGE" -lt 80 ]; then
|
||||
echo -e "${GREEN}✓ Disk usage: ${DISK_USAGE}%${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Disk usage: ${DISK_USAGE}% (High)${NC}"
|
||||
fi
|
||||
|
||||
# Check work directory size
|
||||
if [ -d "$RUNNER_DIR/_work" ]; then
|
||||
WORK_SIZE=$(du -sh "$RUNNER_DIR/_work" 2>/dev/null | cut -f1)
|
||||
echo -e "${BLUE}Work directory size: $WORK_SIZE${NC}"
|
||||
fi
|
||||
|
||||
# Check runner connectivity
|
||||
if [ -f "$RUNNER_DIR/.runner" ]; then
|
||||
GITHUB_URL=$(cat "$RUNNER_DIR/.runner" | jq -r '.gitHubUrl' 2>/dev/null || echo "")
|
||||
if [ -n "$GITHUB_URL" ] && curl -s -o /dev/null -w "%{http_code}" "$GITHUB_URL" | grep -q "200"; then
|
||||
echo -e "${GREEN}✓ GitHub connectivity OK${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Cannot verify GitHub connectivity${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Show recent jobs
|
||||
show_jobs() {
|
||||
echo -e "${BLUE}Recent Runner Jobs${NC}"
|
||||
echo -e "${BLUE}=================${NC}"
|
||||
|
||||
# Check for job history in work directory
|
||||
if [ -d "$RUNNER_DIR/_work" ]; then
|
||||
echo -e "\n${GREEN}Recent job directories:${NC}"
|
||||
ls -la "$RUNNER_DIR/_work" 2>/dev/null | tail -n 10 || echo "No job directories found"
|
||||
fi
|
||||
|
||||
# Show recent log entries
|
||||
echo -e "\n${GREEN}Recent job activity:${NC}"
|
||||
journalctl -u $SERVICE_NAME --since "1 hour ago" | grep -E "(Running job|Job .* completed|Completed request)" | tail -n 20 || echo "No recent job activity"
|
||||
}
|
||||
|
||||
# Cleanup work directory
|
||||
cleanup_work() {
|
||||
echo -e "${YELLOW}Cleaning up work directory...${NC}"
|
||||
|
||||
if [ ! -d "$RUNNER_DIR/_work" ]; then
|
||||
echo -e "${GREEN}Work directory doesn't exist${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Show current size
|
||||
BEFORE_SIZE=$(du -sh "$RUNNER_DIR/_work" 2>/dev/null | cut -f1)
|
||||
echo -e "Current size: ${YELLOW}$BEFORE_SIZE${NC}"
|
||||
|
||||
# Confirm
|
||||
read -p "Are you sure you want to clean the work directory? (y/N): " confirm
|
||||
if [ "$confirm" != "y" ]; then
|
||||
echo -e "${YELLOW}Cleanup cancelled${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Stop runner
|
||||
systemctl stop $SERVICE_NAME
|
||||
|
||||
# Clean work directory
|
||||
rm -rf "$RUNNER_DIR/_work"/*
|
||||
|
||||
# Start runner
|
||||
systemctl start $SERVICE_NAME
|
||||
|
||||
echo -e "${GREEN}✓ Work directory cleaned${NC}"
|
||||
}
|
||||
|
||||
# Show runner info
|
||||
show_info() {
|
||||
echo -e "${BLUE}GitHub Actions Runner Information${NC}"
|
||||
echo -e "${BLUE}=================================${NC}"
|
||||
|
||||
echo -e "\n${GREEN}Basic Info:${NC}"
|
||||
echo -e "Service Name: ${YELLOW}$SERVICE_NAME${NC}"
|
||||
echo -e "Runner Directory: ${YELLOW}$RUNNER_DIR${NC}"
|
||||
echo -e "Runner User: ${YELLOW}$RUNNER_USER${NC}"
|
||||
|
||||
if [ -f "$RUNNER_DIR/bin/Runner.Listener" ]; then
|
||||
VERSION=$($RUNNER_DIR/bin/Runner.Listener --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' || echo "unknown")
|
||||
echo -e "Runner Version: ${YELLOW}$VERSION${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}System Info:${NC}"
|
||||
echo -e "Hostname: ${YELLOW}$(hostname)${NC}"
|
||||
echo -e "OS: ${YELLOW}$(lsb_release -d | cut -f2)${NC}"
|
||||
echo -e "Kernel: ${YELLOW}$(uname -r)${NC}"
|
||||
echo -e "Architecture: ${YELLOW}$(uname -m)${NC}"
|
||||
|
||||
echo -e "\n${GREEN}Docker Info:${NC}"
|
||||
if command -v docker &> /dev/null; then
|
||||
DOCKER_VERSION=$(docker --version | awk '{print $3}' | sed 's/,$//')
|
||||
echo -e "Docker Version: ${YELLOW}$DOCKER_VERSION${NC}"
|
||||
|
||||
if groups $RUNNER_USER | grep -q docker; then
|
||||
echo -e "Docker Access: ${GREEN}✓ User in docker group${NC}"
|
||||
else
|
||||
echo -e "Docker Access: ${RED}✗ User not in docker group${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}Docker not installed${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}Labels:${NC}"
|
||||
echo -e "${YELLOW}self-hosted,linux,x64,deployment,webhook-cd${NC}"
|
||||
}
|
||||
|
||||
# Main logic
|
||||
check_permissions "$1"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start_runner
|
||||
;;
|
||||
stop)
|
||||
stop_runner
|
||||
;;
|
||||
restart)
|
||||
restart_runner
|
||||
;;
|
||||
status)
|
||||
check_status
|
||||
;;
|
||||
logs)
|
||||
view_logs
|
||||
;;
|
||||
logs-tail)
|
||||
view_logs_tail
|
||||
;;
|
||||
update)
|
||||
update_runner
|
||||
;;
|
||||
config)
|
||||
show_config
|
||||
;;
|
||||
health)
|
||||
check_health
|
||||
;;
|
||||
jobs)
|
||||
show_jobs
|
||||
;;
|
||||
cleanup)
|
||||
cleanup_work
|
||||
;;
|
||||
info)
|
||||
show_info
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Unified entrypoint for Claude Code operations
|
||||
# Handles both auto-tagging (minimal tools) and general operations (full tools)
|
||||
# Operation type is controlled by OPERATION_TYPE environment variable
|
||||
|
||||
# Initialize firewall - must be done as root
|
||||
# Temporarily disabled to test Claude Code
|
||||
# /usr/local/bin/init-firewall.sh
|
||||
@@ -13,27 +17,67 @@ set -e
|
||||
mkdir -p /workspace
|
||||
chown -R node:node /workspace
|
||||
|
||||
# Configure GitHub authentication
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
export GH_TOKEN="${GITHUB_TOKEN}"
|
||||
echo "${GITHUB_TOKEN}" | sudo -u node gh auth login --with-token
|
||||
sudo -u node gh auth setup-git
|
||||
# Set up Claude authentication by syncing from captured auth directory
|
||||
if [ -d "/home/node/.claude" ]; then
|
||||
echo "Setting up Claude authentication from mounted auth directory..." >&2
|
||||
|
||||
# Create a writable copy of Claude configuration in workspace
|
||||
CLAUDE_WORK_DIR="/workspace/.claude"
|
||||
mkdir -p "$CLAUDE_WORK_DIR"
|
||||
|
||||
echo "DEBUG: Source auth directory contents:" >&2
|
||||
ls -la /home/node/.claude/ >&2 || echo "DEBUG: Source auth directory not accessible" >&2
|
||||
|
||||
# Sync entire auth directory to writable location (including database files, project state, etc.)
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -av /home/node/.claude/ "$CLAUDE_WORK_DIR/" 2>/dev/null || echo "rsync failed, trying cp" >&2
|
||||
else
|
||||
# Fallback to cp with comprehensive copying
|
||||
cp -r /home/node/.claude/* "$CLAUDE_WORK_DIR/" 2>/dev/null || true
|
||||
cp -r /home/node/.claude/.* "$CLAUDE_WORK_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "DEBUG: Working directory contents after sync:" >&2
|
||||
ls -la "$CLAUDE_WORK_DIR/" >&2 || echo "DEBUG: Working directory not accessible" >&2
|
||||
|
||||
# Set proper ownership and permissions for the node user
|
||||
chown -R node:node "$CLAUDE_WORK_DIR"
|
||||
chmod 600 "$CLAUDE_WORK_DIR"/.credentials.json 2>/dev/null || true
|
||||
chmod 755 "$CLAUDE_WORK_DIR" 2>/dev/null || true
|
||||
|
||||
echo "DEBUG: Final permissions check:" >&2
|
||||
ls -la "$CLAUDE_WORK_DIR/.credentials.json" >&2 || echo "DEBUG: .credentials.json not found" >&2
|
||||
|
||||
echo "Claude authentication directory synced to $CLAUDE_WORK_DIR" >&2
|
||||
else
|
||||
echo "No GitHub token provided, skipping GitHub authentication"
|
||||
echo "WARNING: No Claude authentication source found at /home/node/.claude." >&2
|
||||
fi
|
||||
|
||||
# Configure Gitea authentication
|
||||
if [ -n "${GITEA_TOKEN}" ] && [ -n "${GITEA_API_URL}" ]; then
|
||||
GIT_HOST=$(echo "${GITEA_API_URL}" | sed -E 's|https?://([^/]+).*|\1|')
|
||||
echo "Using Gitea token for ${GIT_HOST}" >&2
|
||||
else
|
||||
echo "No Gitea token provided, skipping Git authentication" >&2
|
||||
GIT_HOST=""
|
||||
fi
|
||||
|
||||
# Clone the repository as node user
|
||||
if [ -n "${GITHUB_TOKEN}" ] && [ -n "${REPO_FULL_NAME}" ]; then
|
||||
echo "Cloning repository ${REPO_FULL_NAME}..." >&2
|
||||
sudo -u node git clone "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO_FULL_NAME}.git" /workspace/repo >&2
|
||||
if [ -n "${GITEA_TOKEN}" ] && [ -n "${REPO_FULL_NAME}" ] && [ -n "${GIT_HOST}" ]; then
|
||||
echo "Cloning repository ${REPO_FULL_NAME} from ${GIT_HOST}..." >&2
|
||||
sudo -u node git clone "https://ClaudeBot:${GITEA_TOKEN}@${GIT_HOST}/${REPO_FULL_NAME}.git" /workspace/repo >&2
|
||||
cd /workspace/repo
|
||||
else
|
||||
echo "Skipping repository clone - missing GitHub token or repository name" >&2
|
||||
echo "Skipping repository clone - missing token or repository name" >&2
|
||||
cd /workspace
|
||||
fi
|
||||
|
||||
# Checkout the correct branch
|
||||
if [ "${IS_PULL_REQUEST}" = "true" ] && [ -n "${BRANCH_NAME}" ]; then
|
||||
# Checkout the correct branch based on operation type
|
||||
if [ "${OPERATION_TYPE}" = "auto-tagging" ]; then
|
||||
# Auto-tagging always uses main branch (doesn't need specific branches)
|
||||
echo "Using main branch for auto-tagging" >&2
|
||||
sudo -u node git checkout main >&2 || sudo -u node git checkout master >&2
|
||||
elif [ "${IS_PULL_REQUEST}" = "true" ] && [ -n "${BRANCH_NAME}" ]; then
|
||||
echo "Checking out PR branch: ${BRANCH_NAME}" >&2
|
||||
sudo -u node git checkout "${BRANCH_NAME}" >&2
|
||||
else
|
||||
@@ -45,16 +89,46 @@ fi
|
||||
sudo -u node git config --global user.email "${BOT_EMAIL:-claude@example.com}"
|
||||
sudo -u node git config --global user.name "${BOT_USERNAME:-ClaudeBot}"
|
||||
|
||||
# Configure Anthropic API key
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
|
||||
# Configure Claude authentication
|
||||
# Support both API key and interactive auth methods
|
||||
echo "DEBUG: Checking authentication options..." >&2
|
||||
echo "DEBUG: ANTHROPIC_API_KEY set: $([ -n "${ANTHROPIC_API_KEY}" ] && echo 'YES' || echo 'NO')" >&2
|
||||
echo "DEBUG: /workspace/.claude/.credentials.json exists: $([ -f "/workspace/.claude/.credentials.json" ] && echo 'YES' || echo 'NO')" >&2
|
||||
echo "DEBUG: /workspace/.claude contents:" >&2
|
||||
ls -la /workspace/.claude/ >&2 || echo "DEBUG: /workspace/.claude directory not found" >&2
|
||||
|
||||
if [ -n "${ANTHROPIC_API_KEY}" ]; then
|
||||
echo "Using Anthropic API key for authentication..." >&2
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
|
||||
elif [ -f "/workspace/.claude/.credentials.json" ]; then
|
||||
echo "Using Claude interactive authentication from working directory..." >&2
|
||||
# No need to set ANTHROPIC_API_KEY - Claude CLI will use the credentials file
|
||||
# Set HOME to point to our working directory for Claude CLI
|
||||
export CLAUDE_HOME="/workspace/.claude"
|
||||
echo "DEBUG: Set CLAUDE_HOME to $CLAUDE_HOME" >&2
|
||||
else
|
||||
echo "WARNING: No Claude authentication found. Please set ANTHROPIC_API_KEY or ensure ~/.claude is mounted." >&2
|
||||
fi
|
||||
|
||||
# Create response file with proper permissions
|
||||
RESPONSE_FILE="/workspace/response.txt"
|
||||
touch "${RESPONSE_FILE}"
|
||||
chown node:node "${RESPONSE_FILE}"
|
||||
|
||||
# Run Claude Code with full GitHub CLI access as node user
|
||||
echo "Running Claude Code..." >&2
|
||||
# Determine allowed tools based on operation type
|
||||
if [ "${OPERATION_TYPE}" = "auto-tagging" ]; then
|
||||
ALLOWED_TOOLS="Read,GitHub,Bash(gh issue edit:*),Bash(gh issue view:*),Bash(gh label list:*)" # Minimal tools for auto-tagging (security)
|
||||
echo "Running Claude Code for auto-tagging with minimal tools..." >&2
|
||||
elif [ "${OPERATION_TYPE}" = "pr-review" ] || [ "${OPERATION_TYPE}" = "manual-pr-review" ]; then
|
||||
# PR Review: Broad research access + controlled write access
|
||||
# Read access: Full file system, git history, GitHub data
|
||||
# Write access: GitHub comments/reviews, PR labels, but no file deletion/modification
|
||||
ALLOWED_TOOLS="Read,GitHub,Bash(gh:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(find:*),Bash(grep:*),Bash(rg:*),Bash(cat:*),Bash(head:*),Bash(tail:*),Bash(ls:*),Bash(tree:*)"
|
||||
echo "Running Claude Code for PR review with broad research access..." >&2
|
||||
else
|
||||
ALLOWED_TOOLS="Bash,Create,Edit,Read,Write,GitHub" # Full tools for general operations
|
||||
echo "Running Claude Code with full tool access..." >&2
|
||||
fi
|
||||
|
||||
# Check if command exists
|
||||
if [ -z "${COMMAND}" ]; then
|
||||
@@ -65,16 +139,47 @@ fi
|
||||
# Log the command length for debugging
|
||||
echo "Command length: ${#COMMAND}" >&2
|
||||
|
||||
# Run Claude Code
|
||||
sudo -u node -E env \
|
||||
HOME="/home/node" \
|
||||
PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \
|
||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \
|
||||
GH_TOKEN="${GITHUB_TOKEN}" \
|
||||
/usr/local/share/npm-global/bin/claude \
|
||||
--allowedTools Bash,Create,Edit,Read,Write,GitHub \
|
||||
--print "${COMMAND}" \
|
||||
> "${RESPONSE_FILE}" 2>&1
|
||||
# Run Claude Code with proper HOME environment
|
||||
# If we synced Claude auth to workspace, use workspace as HOME
|
||||
if [ -f "/workspace/.claude/.credentials.json" ]; then
|
||||
CLAUDE_USER_HOME="/workspace"
|
||||
echo "DEBUG: Using /workspace as HOME for Claude CLI (synced auth)" >&2
|
||||
else
|
||||
CLAUDE_USER_HOME="${CLAUDE_HOME:-/home/node}"
|
||||
echo "DEBUG: Using $CLAUDE_USER_HOME as HOME for Claude CLI (fallback)" >&2
|
||||
fi
|
||||
|
||||
if [ "${OUTPUT_FORMAT}" = "stream-json" ]; then
|
||||
# For stream-json, output directly to stdout for real-time processing
|
||||
exec sudo -u node -E env \
|
||||
HOME="$CLAUDE_USER_HOME" \
|
||||
PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \
|
||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \
|
||||
GH_TOKEN="${GITHUB_TOKEN}" \
|
||||
GITHUB_TOKEN="${GITHUB_TOKEN}" \
|
||||
BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \
|
||||
BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \
|
||||
/usr/local/share/npm-global/bin/claude \
|
||||
--allowedTools "${ALLOWED_TOOLS}" \
|
||||
--output-format stream-json \
|
||||
--verbose \
|
||||
--print "${COMMAND}"
|
||||
else
|
||||
# Default behavior - write to file
|
||||
sudo -u node -E env \
|
||||
HOME="$CLAUDE_USER_HOME" \
|
||||
PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \
|
||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \
|
||||
GH_TOKEN="${GITHUB_TOKEN}" \
|
||||
GITHUB_TOKEN="${GITHUB_TOKEN}" \
|
||||
BASH_DEFAULT_TIMEOUT_MS="${BASH_DEFAULT_TIMEOUT_MS}" \
|
||||
BASH_MAX_TIMEOUT_MS="${BASH_MAX_TIMEOUT_MS}" \
|
||||
/usr/local/share/npm-global/bin/claude \
|
||||
--allowedTools "${ALLOWED_TOOLS}" \
|
||||
--verbose \
|
||||
--print "${COMMAND}" \
|
||||
> "${RESPONSE_FILE}" 2>&1
|
||||
fi
|
||||
|
||||
# Check for errors
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Minimal entrypoint for auto-tagging workflow
|
||||
# Only allows Read and GitHub tools for security
|
||||
|
||||
# Environment variables (passed from service)
|
||||
# Simply reference the variables directly - no need to reassign
|
||||
# They are already available in the environment
|
||||
|
||||
# Ensure workspace directory exists and has proper permissions
|
||||
mkdir -p /workspace
|
||||
chown -R node:node /workspace
|
||||
|
||||
# Configure GitHub authentication
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
export GH_TOKEN="${GITHUB_TOKEN}"
|
||||
echo "${GITHUB_TOKEN}" | sudo -u node gh auth login --with-token
|
||||
sudo -u node gh auth setup-git
|
||||
else
|
||||
echo "No GitHub token provided, skipping GitHub authentication"
|
||||
fi
|
||||
|
||||
# Clone the repository as node user (needed for context)
|
||||
if [ -n "${GITHUB_TOKEN}" ] && [ -n "${REPO_FULL_NAME}" ]; then
|
||||
echo "Cloning repository ${REPO_FULL_NAME}..." >&2
|
||||
sudo -u node git clone "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO_FULL_NAME}.git" /workspace/repo >&2
|
||||
cd /workspace/repo
|
||||
else
|
||||
echo "Skipping repository clone - missing GitHub token or repository name" >&2
|
||||
cd /workspace
|
||||
fi
|
||||
|
||||
# Checkout main branch (tagging doesn't need specific branches)
|
||||
echo "Using main branch" >&2
|
||||
sudo -u node git checkout main >&2 || sudo -u node git checkout master >&2
|
||||
|
||||
# Configure git for minimal operations
|
||||
sudo -u node git config --global user.email "${BOT_EMAIL:-claude@example.com}"
|
||||
sudo -u node git config --global user.name "${BOT_USERNAME:-ClaudeBot}"
|
||||
|
||||
# Configure Anthropic API key
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
|
||||
|
||||
# Create response file with proper permissions
|
||||
RESPONSE_FILE="/workspace/response.txt"
|
||||
touch "${RESPONSE_FILE}"
|
||||
chown node:node "${RESPONSE_FILE}"
|
||||
|
||||
# Run Claude Code with minimal tools for auto-tagging
|
||||
echo "Running Claude Code for auto-tagging..." >&2
|
||||
|
||||
# Check if command exists
|
||||
if [ -z "${COMMAND}" ]; then
|
||||
echo "ERROR: No command provided. COMMAND environment variable is empty." | tee -a "${RESPONSE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Log the command length for debugging
|
||||
echo "Command length: ${#COMMAND}" >&2
|
||||
|
||||
# Run Claude Code with minimal tool set: Read (for repository context) and GitHub (for label operations)
|
||||
sudo -u node -E env \
|
||||
HOME="/home/node" \
|
||||
PATH="/usr/local/bin:/usr/local/share/npm-global/bin:$PATH" \
|
||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \
|
||||
GH_TOKEN="${GITHUB_TOKEN}" \
|
||||
/usr/local/share/npm-global/bin/claude \
|
||||
--allowedTools Read,GitHub \
|
||||
--print "${COMMAND}" \
|
||||
> "${RESPONSE_FILE}" 2>&1
|
||||
|
||||
# Check for errors
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: Claude Code execution failed. See logs for details." | tee -a "${RESPONSE_FILE}" >&2
|
||||
fi
|
||||
|
||||
# Output the response
|
||||
cat "${RESPONSE_FILE}"
|
||||
@@ -1,7 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get port from environment or default to 3003
|
||||
DEFAULT_PORT=${PORT:-3003}
|
||||
# Load environment variables from .env file if it exists
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Get port from environment or default to 3002
|
||||
DEFAULT_PORT=${PORT:-3002}
|
||||
|
||||
# Kill any processes using the port
|
||||
echo "Checking for existing processes on port $DEFAULT_PORT..."
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
echo "Starting Claude GitHub webhook service..."
|
||||
|
||||
# Build the Claude Code runner image
|
||||
echo "Building Claude Code runner image..."
|
||||
if docker build -f Dockerfile.claudecode -t claude-code-runner:latest .; then
|
||||
echo "Claude Code runner image built successfully."
|
||||
# Build the Claude Code runner image if we have access to Dockerfile.claudecode
|
||||
if [ -f "Dockerfile.claudecode" ]; then
|
||||
echo "Building Claude Code runner image..."
|
||||
if docker build -f Dockerfile.claudecode -t claude-code-runner:latest .; then
|
||||
echo "Claude Code runner image built successfully."
|
||||
else
|
||||
echo "Warning: Failed to build Claude Code runner image. Service will attempt to build on first use."
|
||||
fi
|
||||
else
|
||||
echo "Warning: Failed to build Claude Code runner image. Service will attempt to build on first use."
|
||||
echo "Dockerfile.claudecode not found, skipping Claude Code runner image build."
|
||||
fi
|
||||
|
||||
# Ensure dependencies are installed (in case volume mount affected node_modules)
|
||||
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.bin/tsc" ]; then
|
||||
echo "Installing dependencies..."
|
||||
npm ci
|
||||
# In production, dist directory is already built in the Docker image
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "Error: dist directory not found. Please rebuild the Docker image."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Always compile TypeScript to ensure we have the latest compiled source
|
||||
echo "Compiling TypeScript..."
|
||||
npm run build
|
||||
|
||||
# Start the webhook service
|
||||
echo "Starting webhook service..."
|
||||
exec node dist/index.js
|
||||
@@ -32,8 +32,8 @@ report_success() {
|
||||
|
||||
# 1. Check for .env files that shouldn't be committed
|
||||
echo "🔍 Checking for exposed .env files..."
|
||||
if find . -name ".env*" -not -path "./node_modules/*" -not -name ".env.example" -not -name ".env.template" | grep -q .; then
|
||||
find . -name ".env*" -not -path "./node_modules/*" -not -name ".env.example" -not -name ".env.template" | while read file; do
|
||||
if find . -name ".env*" -not -path "./node_modules/*" -not -name ".env.example" -not -name ".env.template" -not -name ".env.quickstart" | grep -q .; then
|
||||
find . -name ".env*" -not -path "./node_modules/*" -not -name ".env.example" -not -name ".env.template" -not -name ".env.quickstart" | while read file; do
|
||||
report_issue "Found .env file that may contain secrets: $file"
|
||||
done
|
||||
else
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to fix potential credential references in the clean repository
|
||||
|
||||
CLEAN_REPO="/tmp/clean-repo"
|
||||
cd "$CLEAN_REPO" || exit 1
|
||||
|
||||
echo "Fixing potential credential references..."
|
||||
|
||||
# 1. Fix test files with example tokens
|
||||
echo "Updating test-credential-leak.js..."
|
||||
sed -i 's/ghp_verySecretGitHubToken123456789/github_token_example_1234567890/g' test-credential-leak.js
|
||||
|
||||
echo "Updating test-logger-redaction.js..."
|
||||
sed -i 's/ghp_verySecretGitHubToken123456789/github_token_example_1234567890/g' test/test-logger-redaction.js
|
||||
sed -i 's/ghp_nestedSecretToken/github_token_example_nested/g' test/test-logger-redaction.js
|
||||
sed -i 's/ghp_inCommand/github_token_example_command/g' test/test-logger-redaction.js
|
||||
sed -i 's/ghp_errorToken/github_token_example_error/g' test/test-logger-redaction.js
|
||||
sed -i 's/AKIAIOSFODNN7NESTED/EXAMPLE_NESTED_KEY_ID/g' test/test-logger-redaction.js
|
||||
|
||||
echo "Updating test-secrets.js..."
|
||||
sed -i 's/ghp_1234567890abcdefghijklmnopqrstuvwxy/github_token_example_1234567890/g' test/test-secrets.js
|
||||
|
||||
# 2. Fix references in documentation
|
||||
echo "Updating docs/container-setup.md..."
|
||||
sed -i 's/GITHUB_TOKEN=ghp_yourgithubtoken/GITHUB_TOKEN=your_github_token/g' docs/container-setup.md
|
||||
|
||||
echo "Updating docs/complete-workflow.md..."
|
||||
sed -i 's/`ghp_xxxxx`/`your_github_token`/g' docs/complete-workflow.md
|
||||
sed -i 's/`AKIA...`/`your_access_key_id`/g' docs/complete-workflow.md
|
||||
|
||||
# 3. Update AWS profile references in scripts
|
||||
echo "Updating aws profile scripts..."
|
||||
sed -i 's/aws_secret_access_key/aws_secret_key/g' scripts/create-aws-profile.sh
|
||||
sed -i 's/aws_secret_access_key/aws_secret_key/g' scripts/setup-aws-profiles.sh
|
||||
|
||||
# 4. Make awsCredentialProvider test use clearly labeled example values
|
||||
echo "Updating unit test files..."
|
||||
sed -i 's/aws_secret_access_key = default-secret-key/aws_secret_key = example-default-secret-key/g' test/unit/utils/awsCredentialProvider.test.js
|
||||
sed -i 's/aws_secret_access_key = test-secret-key/aws_secret_key = example-test-secret-key/g' test/unit/utils/awsCredentialProvider.test.js
|
||||
|
||||
echo "Updates completed. Running check again..."
|
||||
|
||||
# Check if any sensitive patterns remain (excluding clearly labeled examples)
|
||||
SENSITIVE_FILES=$(grep -r "ghp_\|AKIA\|aws_secret_access_key" --include="*.js" --include="*.sh" --include="*.json" --include="*.md" . | grep -v "EXAMPLE\|example\|REDACTED\|dummy\|\${\|ENV\|process.env\|context.env\|mock\|pattern" || echo "No sensitive data found")
|
||||
|
||||
if [ -n "$SENSITIVE_FILES" ] && [ "$SENSITIVE_FILES" != "No sensitive data found" ]; then
|
||||
echo "⚠️ Some potential sensitive patterns remain:"
|
||||
echo "$SENSITIVE_FILES"
|
||||
echo "Please review manually."
|
||||
else
|
||||
echo "✅ No sensitive patterns found. The repository is ready!"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user