diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8114c2b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +src/web/node_modules/ + +# Build outputs (will be rebuilt in container) +dist/ +src/web/dist/ + +# Development files +.git/ +.gitignore +*.md +!README.md + +# IDE +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Local config (user should mount their own) +config.json + +# Docker files (no need to copy) +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..668e2a3 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,58 @@ +name: Build and Publish Docker Image + +on: + # 当创建 tag 时自动构建(如 v2.2.0) + push: + tags: + - 'v*' + # 手动触发 + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Repository name to lowercase + id: repo + run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.repo.outputs.name }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f4298b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Coding-Tool Docker Image +# Claude Code / Codex / Gemini CLI Enhancement Tool +# https://github.com/CooperJiang/cc-tool + +FROM node:18-alpine + +LABEL maintainer="CooperJiang" +LABEL description="Vibe Coding Enhancement Tool - Session Management, Channel Switching, Token Monitoring" +LABEL org.opencontainers.image.source="https://github.com/CooperJiang/cc-tool" + +# Install dependencies for native modules +RUN apk add --no-cache \ + python3 \ + make \ + g++ \ + git \ + bash + +# Set working directory +WORKDIR /app + +# Copy package files first for better caching +COPY package*.json ./ + +# Install dependencies +RUN npm install -g pm2 && \ + npm install + +# Copy web package files and install +COPY src/web/package*.json ./src/web/ +RUN cd src/web && npm install + +# Copy source code +COPY . . + +# Patch: Change proxy listen address from 127.0.0.1 to 0.0.0.0 for Docker networking +RUN sed -i "s/listen(port, '127.0.0.1'/listen(port, '0.0.0.0'/g" src/server/proxy-server.js && \ + sed -i "s/listen(port, '127.0.0.1'/listen(port, '0.0.0.0'/g" src/server/codex-proxy-server.js && \ + sed -i "s/listen(port, '127.0.0.1'/listen(port, '0.0.0.0'/g" src/server/gemini-proxy-server.js + +# Build web frontend +RUN cd src/web && npm run build + +# Create data directories +RUN mkdir -p /data/.claude/cc-tool && \ + mkdir -p /data/.claude/logs && \ + mkdir -p /data/.claude/projects && \ + mkdir -p /data/.codex && \ + mkdir -p /data/.gemini + +# Environment variables +ENV NODE_ENV=production +ENV HOME=/data + +# Expose ports +EXPOSE 10099 10088 10089 10090 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:10099/api/version || exit 1 + +# Start server +CMD ["node", "docker-start.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c53f671 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + coding-tool: + # 使用官方镜像(推荐) + image: ghcr.io/cooperjiang/coding-tool:latest + # 或本地构建: 注释上面的 image,取消下面的注释 + # build: . + container_name: coding-tool + restart: unless-stopped + ports: + - "10099:10099" # Web UI + - "10088:10088" # Claude Proxy + - "10089:10089" # Codex Proxy + - "10090:10090" # Gemini Proxy + volumes: + # 数据持久化 + - ./data/claude:/data/.claude + - ./data/codex:/data/.codex + - ./data/gemini:/data/.gemini + environment: + - NODE_ENV=production + - HOME=/data + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:10099/api/version"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + labels: + - "com.centurylinklabs.watchtower.enable=true" + + # Watchtower - 自动监控并拉取最新镜像 + coding-tool-watchtower: + image: containrrr/watchtower + container_name: coding-tool-watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + # 每6小时检查一次更新 + - WATCHTOWER_POLL_INTERVAL=21600 + # 只更新有标签的容器 + - WATCHTOWER_LABEL_ENABLE=true + # 更新后清理旧镜像 + - WATCHTOWER_CLEANUP=true + command: --interval 21600 diff --git a/docker-start.js b/docker-start.js new file mode 100644 index 0000000..82e2ec1 --- /dev/null +++ b/docker-start.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +/** + * Docker container startup script + * Starts the web server directly without interactive prompts + */ + +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const chalk = require('chalk'); + +const { loadConfig } = require('./src/config/loader'); +const { startWebSocketServer: attachWebSocketServer } = require('./src/server/websocket-server'); +const { startProxyServer } = require('./src/server/proxy-server'); +const { startCodexProxyServer } = require('./src/server/codex-proxy-server'); +const { startGeminiProxyServer } = require('./src/server/gemini-proxy-server'); + +// Docker mode flag +process.env.CT_DOCKER = 'true'; + +// Ensure required directories exist +function ensureDirectories() { + const os = require('os'); + const dirs = [ + path.join(os.homedir(), '.claude', 'cc-tool'), + path.join(os.homedir(), '.claude', 'logs'), + path.join(os.homedir(), '.claude', 'projects'), + path.join(os.homedir(), '.codex'), + path.join(os.homedir(), '.gemini') + ]; + + dirs.forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(chalk.gray(`Created directory: ${dir}`)); + } + }); +} + +async function startDockerServer() { + ensureDirectories(); + const config = loadConfig(); + const port = parseInt(process.env.CT_WEB_PORT) || config.ports?.webUI || 10099; + + console.log(chalk.cyan('\n🐳 Starting Coding-Tool in Docker mode...\n')); + + const app = express(); + + // Middleware + app.use(express.json({ limit: '100mb' })); + app.use(express.urlencoded({ limit: '100mb', extended: true })); + + // CORS + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } + next(); + }); + + // API Routes + app.use('/api/projects', require('./src/server/api/projects')(config)); + app.use('/api/sessions', require('./src/server/api/sessions')(config)); + app.use('/api/codex/projects', require('./src/server/api/codex-projects')(config)); + app.use('/api/codex/sessions', require('./src/server/api/codex-sessions')(config)); + app.use('/api/codex/channels', require('./src/server/api/codex-channels')(config)); + app.use('/api/gemini/projects', require('./src/server/api/gemini-projects')(config)); + app.use('/api/gemini/sessions', require('./src/server/api/gemini-sessions')(config)); + app.use('/api/gemini/channels', require('./src/server/api/gemini-channels')(config)); + app.use('/api/gemini/proxy', require('./src/server/api/gemini-proxy')); + app.use('/api/aliases', require('./src/server/api/aliases')()); + app.use('/api/favorites', require('./src/server/api/favorites')); + app.use('/api/ui-config', require('./src/server/api/ui-config')); + app.use('/api/channels', require('./src/server/api/channels')); + app.use('/api/proxy', require('./src/server/api/proxy')); + app.use('/api/codex/proxy', require('./src/server/api/codex-proxy')); + app.use('/api/settings', require('./src/server/api/settings')); + app.use('/api/config', require('./src/server/api/config')); + app.use('/api/statistics', require('./src/server/api/statistics')); + app.use('/api/codex/statistics', require('./src/server/api/codex-statistics')); + app.use('/api/gemini/statistics', require('./src/server/api/gemini-statistics')); + app.use('/api/version', require('./src/server/api/version')); + app.use('/api/pm2-autostart', require('./src/server/api/pm2-autostart')()); + app.use('/api/dashboard', require('./src/server/api/dashboard')); + app.use('/api/mcp', require('./src/server/api/mcp')); + app.use('/api/prompts', require('./src/server/api/prompts')); + app.use('/api/env', require('./src/server/api/env')); + app.use('/api/skills', require('./src/server/api/skills')); + const claudeHooks = require('./src/server/api/claude-hooks'); + app.use('/api/claude/hooks', claudeHooks); + claudeHooks.initDefaultHooks(); + + // Serve static files + const distPath = path.join(__dirname, 'dist/web'); + if (fs.existsSync(distPath)) { + app.use(express.static(distPath)); + app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + } else { + console.log(chalk.yellow('⚠️ Web UI not built. Run: npm run build:web')); + } + + // Start server + const server = app.listen(port, '0.0.0.0', () => { + console.log(chalk.green(`\n🚀 Coding-Tool Web UI running at:`)); + console.log(chalk.cyan(` http://0.0.0.0:${port}`)); + + attachWebSocketServer(server); + console.log(chalk.cyan(` ws://0.0.0.0:${port}/ws\n`)); + + // Auto-start proxies + autoStartProxies(config); + }); + + server.on('error', (err) => { + console.error(chalk.red('Server error:'), err); + process.exit(1); + }); +} + +async function autoStartProxies(config) { + const os = require('os'); + const ccToolDir = path.join(os.homedir(), '.claude', 'cc-tool'); + + // Claude proxy + const claudeActiveFile = path.join(ccToolDir, 'active-channel.json'); + if (fs.existsSync(claudeActiveFile)) { + const proxyPort = parseInt(process.env.CT_CLAUDE_PROXY_PORT) || config.ports?.proxy || 10088; + try { + await startProxyServer(proxyPort); + console.log(chalk.green(`✅ Claude proxy started on port ${proxyPort}`)); + } catch (err) { + console.error(chalk.red(`❌ Claude proxy failed: ${err.message}`)); + } + } + + // Codex proxy + const codexActiveFile = path.join(ccToolDir, 'codex-active-channel.json'); + if (fs.existsSync(codexActiveFile)) { + const codexPort = parseInt(process.env.CT_CODEX_PROXY_PORT) || config.ports?.codexProxy || 10089; + try { + await startCodexProxyServer(codexPort); + console.log(chalk.green(`✅ Codex proxy started on port ${codexPort}`)); + } catch (err) { + console.error(chalk.red(`❌ Codex proxy failed: ${err.message}`)); + } + } + + // Gemini proxy + const geminiActiveFile = path.join(ccToolDir, 'gemini-active-channel.json'); + if (fs.existsSync(geminiActiveFile)) { + const geminiPort = parseInt(process.env.CT_GEMINI_PROXY_PORT) || config.ports?.geminiProxy || 10090; + try { + const result = await startGeminiProxyServer(geminiPort); + if (result.success) { + console.log(chalk.green(`✅ Gemini proxy started on port ${result.port}`)); + } + } catch (err) { + console.error(chalk.red(`❌ Gemini proxy failed: ${err.message}`)); + } + } +} + +// Handle signals +process.on('SIGTERM', () => { + console.log(chalk.yellow('\n👋 Received SIGTERM, shutting down...')); + process.exit(0); +}); + +process.on('SIGINT', () => { + console.log(chalk.yellow('\n👋 Received SIGINT, shutting down...')); + process.exit(0); +}); + +// Start +startDockerServer().catch((err) => { + console.error(chalk.red('Failed to start:'), err); + process.exit(1); +});