From df8b24d3f8e9684897f806c966f5db264ad13924 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:24:03 +1100 Subject: [PATCH 01/17] Add health checks and GHCR deploy flow --- .dockerignore | 27 +++++++++ .github/workflows/ci.yml | 33 ++++++++++- .github/workflows/deploy.yml | 101 +++++++++++++++++++++++++++++++++ .github/workflows/pr-check.yml | 48 ++++++++++++++++ .gitignore | 3 + Dockerfile | 25 ++++++++ README.md | 23 ++++++++ docker-compose.yml | 19 +++++++ server/main.py | 10 ++++ test_health.py | 14 +++++ 10 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/pr-check.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 test_health.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b8efaa77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.gitignore +.code +__pycache__/ +*.pyc +*.pyo +*.pyd +*.swp +*.swo +*.tmp +.env +.env.* +env/ +venv/ +.venv/ +ENV/ +node_modules/ +ui/node_modules/ +ui/dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +coverage/ +dist/ +build/ +tmp/ +*.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0a6eb4..43d3b873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ -name: CI +name: Push CI on: - pull_request: - branches: [master, main] push: branches: [master, main] @@ -39,3 +37,32 @@ jobs: run: npm run lint - name: Type check & Build run: npm run build + + docker-image: + needs: [python, ui] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMAGE_NAME: ghcr.io/${{ toLower(github.repository) }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..2f56c8e0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,101 @@ +name: Deploy to VPS + +on: + workflow_run: + workflows: ["Push CI"] + branches: [main, master] + types: + - completed + +permissions: + contents: read + +concurrency: + group: deploy-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + env: + DEPLOY_PATH: ${{ secrets.VPS_DEPLOY_PATH || '/opt/autocoder' }} + TARGET_BRANCH: ${{ secrets.VPS_BRANCH || 'master' }} + VPS_PORT: ${{ secrets.VPS_PORT || '22' }} + IMAGE_LATEST: ghcr.io/${{ toLower(github.repository) }}:latest + IMAGE_SHA: ghcr.io/${{ toLower(github.repository) }}:${{ github.event.workflow_run.head_sha }} + steps: + - name: Deploy over SSH with Docker Compose + uses: appleboy/ssh-action@v1.2.4 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ env.VPS_PORT }} + envs: DEPLOY_PATH,TARGET_BRANCH,IMAGE_LATEST,IMAGE_SHA + script: | + set -euo pipefail + + if [ -z "${DEPLOY_PATH:-}" ]; then + echo "VPS_DEPLOY_PATH secret is required"; exit 1; + fi + + if [ ! -d "$DEPLOY_PATH/.git" ]; then + echo "ERROR: $DEPLOY_PATH is missing a git repo. Clone the repository there and keep your .env file."; exit 1; + fi + + cd "$DEPLOY_PATH" + + if [ ! -f .env ]; then + echo "WARNING: .env not found in $DEPLOY_PATH. Deployment will continue without it."; + fi + + git fetch --all + if ! git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then + git fetch origin "$TARGET_BRANCH" || true + fi + + if git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then + git checkout "$TARGET_BRANCH" + git pull --ff-only origin "$TARGET_BRANCH" + else + echo "Branch $TARGET_BRANCH not found, trying main or master" + git checkout main 2>/dev/null || git checkout master + git pull --ff-only origin main 2>/dev/null || git pull --ff-only origin master + fi + + if command -v docker &>/dev/null && docker compose version &>/dev/null; then + DOCKER_CMD="docker compose" + elif command -v docker-compose &>/dev/null; then + DOCKER_CMD="docker-compose" + else + echo "Docker Compose is not installed on the VPS."; exit 1; + fi + + export IMAGE="${IMAGE_SHA:-$IMAGE_LATEST}" + + $DOCKER_CMD down --remove-orphans || true + docker image prune -af || true + docker builder prune -af || true + + echo "Pulling image ${IMAGE} ..." + if ! $DOCKER_CMD pull; then + echo "SHA tag pull failed, falling back to latest..." + export IMAGE="$IMAGE_LATEST" + $DOCKER_CMD pull || { echo "Image pull failed"; exit 1; } + fi + + $DOCKER_CMD up -d --remove-orphans + + echo "Running smoke test on http://127.0.0.1:8888/health ..." + retries=12 + until curl -fsS --max-time 5 http://127.0.0.1:8888/health >/dev/null; do + retries=$((retries - 1)) + if [ "$retries" -le 0 ]; then + echo "Health check failed after retries." + exit 1 + fi + echo "Waiting for service... ($retries retries left)" + sleep 5 + done + echo "Service responded successfully." diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 00000000..a487e076 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,48 @@ +name: PR Check + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + +concurrency: + group: pr-check-${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: requirements.txt + - name: Install dependencies + run: pip install -r requirements.txt + - name: Lint with ruff + run: ruff check . + - name: Run security tests + run: python test_security.py + + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + - name: Type check & Build + run: npm run build diff --git a/.gitignore b/.gitignore index bb201186..4ed7e9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Local Codex/Claude configuration (do not commit) +.code/ + # =================== # Node.js # =================== diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e28d2eb6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Build frontend and backend for production + +# 1) Build the React UI +FROM node:20-alpine AS ui-builder +WORKDIR /app/ui +COPY ui/package*.json ./ +RUN npm ci +COPY ui/ . +RUN npm run build + +# 2) Build the Python backend with the compiled UI assets +FROM python:3.11-slim AS runtime +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code and built UI +COPY . . +COPY --from=ui-builder /app/ui/dist ./ui/dist + +EXPOSE 8888 +CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8888"] diff --git a/README.md b/README.md index 3ed7f153..9fe4588c 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,29 @@ The agent tried to run a command not in the allowlist. This is the security syst --- +## CI/CD and Deployment + +- PR Check workflow (`.github/workflows/pr-check.yml`) runs Python lint/security tests and UI lint/build on every PR to `main` or `master`. +- Push CI (`.github/workflows/ci.yml`) runs the same validations on direct pushes to `main` and `master`, then builds and pushes a Docker image to GHCR (`ghcr.io//:latest` and `:sha`). +- Deploy to VPS (`.github/workflows/deploy.yml`) runs after Push CI succeeds, SSHes into your VPS, prunes old Docker artifacts, pulls the target branch, pulls the GHCR `:sha` image (falls back to `:latest`), restarts with `docker compose up -d`, and leaves any existing `.env` untouched. It finishes with an HTTP smoke check on `http://127.0.0.1:8888/health`. +- Repo secrets required: `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `VPS_DEPLOY_PATH` (use an absolute path like `/opt/autocoder`); optional `VPS_BRANCH` (defaults to `master`) and `VPS_PORT` (defaults to `22`). The VPS needs git, Docker + Compose plugin installed, and the repo cloned at `VPS_DEPLOY_PATH` with your `.env` present. +- Local Docker run: `docker compose up -d --build` exposes the app on `http://localhost:8888`; data under `~/.autocoder` persists via the `autocoder-data` volume. + +### Branch protection +To require the “PR Check” workflow before merging: +- GitHub UI: Settings → Branches → Add rule for `main` (and `master` if used) → enable **Require status checks to pass before merging** → select `PR Check` → save. +- GitHub CLI: + ```bash + gh api -X PUT repos///branches/main/protection \ + -F required_status_checks.strict=true \ + -F required_status_checks.contexts[]="PR Check" \ + -F enforce_admins=true \ + -F required_pull_request_reviews.dismiss_stale_reviews=true \ + -F restrictions= + ``` + +--- + ## License This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE.md](LICENSE.md) file for details. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..2cce84e6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3.9" + +services: + autocoder: + image: ${IMAGE:-autocoder-local:latest} + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + ports: + - "8888:8888" + restart: unless-stopped + volumes: + - autocoder-data:/root/.autocoder + command: uvicorn server.main:app --host 0.0.0.0 --port 8888 + +volumes: + autocoder-data: diff --git a/server/main.py b/server/main.py index 1b01f79a..00985cdb 100644 --- a/server/main.py +++ b/server/main.py @@ -116,6 +116,16 @@ async def lifespan(app: FastAPI): ) +# ============================================================================ +# Health Endpoint +# ============================================================================ + +@app.get("/health") +async def health(): + """Lightweight liveness probe used by deploy smoke tests.""" + return {"status": "ok"} + + # ============================================================================ # Security Middleware # ============================================================================ diff --git a/test_health.py b/test_health.py new file mode 100644 index 00000000..b9e7bc34 --- /dev/null +++ b/test_health.py @@ -0,0 +1,14 @@ +"""Lightweight tests for the /health endpoint.""" + +from fastapi.testclient import TestClient + +from server.main import app + + +client = TestClient(app) + + +def test_health_returns_ok(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json().get("status") == "ok" From e3c713dd44b07a9b7b750a877a1ce133141ab1fc Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:27:01 +1100 Subject: [PATCH 02/17] Add readiness probe and extend deploy smoke tests --- .github/workflows/deploy.yml | 18 +++++++++++++++--- server/main.py | 10 ++++++++++ test_health.py | 8 +++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f56c8e0..7f8043bb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -87,7 +87,7 @@ jobs: $DOCKER_CMD up -d --remove-orphans - echo "Running smoke test on http://127.0.0.1:8888/health ..." + echo "Running smoke test on http://127.0.0.1:8888/health and /readiness ..." retries=12 until curl -fsS --max-time 5 http://127.0.0.1:8888/health >/dev/null; do retries=$((retries - 1)) @@ -95,7 +95,19 @@ jobs: echo "Health check failed after retries." exit 1 fi - echo "Waiting for service... ($retries retries left)" + echo "Waiting for health... ($retries retries left)" sleep 5 done - echo "Service responded successfully." + + retries=12 + until curl -fsS --max-time 5 http://127.0.0.1:8888/readiness >/dev/null; do + retries=$((retries - 1)) + if [ "$retries" -le 0 ]; then + echo "Readiness check failed after retries." + exit 1 + fi + echo "Waiting for readiness... ($retries retries left)" + sleep 5 + done + + echo "Service responded successfully to health and readiness." diff --git a/server/main.py b/server/main.py index 00985cdb..0e091648 100644 --- a/server/main.py +++ b/server/main.py @@ -126,6 +126,16 @@ async def health(): return {"status": "ok"} +@app.get("/readiness") +async def readiness(): + """ + Readiness probe placeholder. + + Add dependency checks (DB, external APIs, queues) here when introduced. + """ + return {"status": "ready"} + + # ============================================================================ # Security Middleware # ============================================================================ diff --git a/test_health.py b/test_health.py index b9e7bc34..0700ee52 100644 --- a/test_health.py +++ b/test_health.py @@ -1,4 +1,4 @@ -"""Lightweight tests for the /health endpoint.""" +"""Lightweight tests for health and readiness endpoints.""" from fastapi.testclient import TestClient @@ -12,3 +12,9 @@ def test_health_returns_ok(): response = client.get("/health") assert response.status_code == 200 assert response.json().get("status") == "ok" + + +def test_readiness_returns_ready(): + response = client.get("/readiness") + assert response.status_code == 200 + assert response.json().get("status") == "ready" From 5dafd8ec645814ef42d0bad32a0dc67a5efd1953 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:52:50 +1100 Subject: [PATCH 03/17] Ignore .code and add CI guard --- .github/workflows/ci.yml | 14 ++++++++++++++ .gitignore | 3 +++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43d3b873..b3634196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,20 @@ on: branches: [master, main] jobs: + repo-guards: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Ensure .code is not tracked + shell: bash + run: | + tracked="$(git ls-files -- .code)" + if [ -n "$tracked" ]; then + echo "The .code/ directory must not be tracked." + echo "$tracked" + exit 1 + fi + python: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 4ed7e9e2..9013331f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ temp/ nul issues/ +# Repository-specific +.code/ + # Browser profiles for parallel agent execution .browser-profiles/ From fbc88b8860766fb7246873d824fb154e751ddd57 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:55:41 +1100 Subject: [PATCH 04/17] Fix import order in health tests --- test_health.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test_health.py b/test_health.py index 0700ee52..014288a6 100644 --- a/test_health.py +++ b/test_health.py @@ -1,7 +1,6 @@ """Lightweight tests for health and readiness endpoints.""" from fastapi.testclient import TestClient - from server.main import app From c9c9006fc78a632973afc9d0ada4b68b9fc69c45 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:58:44 +1100 Subject: [PATCH 05/17] Normalize import block spacing in health tests --- test_health.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test_health.py b/test_health.py index 014288a6..5aa79ed2 100644 --- a/test_health.py +++ b/test_health.py @@ -3,7 +3,6 @@ from fastapi.testclient import TestClient from server.main import app - client = TestClient(app) From 64f9a4ac45d6ed86d59d5ef65680f33fbf737bf5 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:58:58 +1100 Subject: [PATCH 06/17] Add Gemini assistant chat support --- README.md | 7 ++ requirements.txt | 1 + server/gemini_client.py | 80 +++++++++++++++++ server/main.py | 7 +- server/schemas.py | 1 + server/services/assistant_chat_session.py | 100 ++++++++++++++-------- 6 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 server/gemini_client.py diff --git a/README.md b/README.md index 9fe4588c..4543cd2b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ You need one of the following: - **Claude Pro/Max Subscription** - Use `claude login` to authenticate (recommended) - **Anthropic API Key** - Pay-per-use from https://console.anthropic.com/ +### Optional: Gemini API (assistant chat only) +- `GEMINI_API_KEY` (required) +- `GEMINI_MODEL` (optional, default `gemini-1.5-flash`) +- `GEMINI_BASE_URL` (optional, default `https://generativelanguage.googleapis.com/v1beta/openai`) + +Notes: Gemini is used for assistant chat when configured; coding agents still run on Claude/Anthropic (tools are not available in Gemini mode). + --- ## Quick Start diff --git a/requirements.txt b/requirements.txt index 9cf420e0..51413362 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ aiofiles>=24.0.0 apscheduler>=3.10.0,<4.0.0 pywinpty>=2.0.0; sys_platform == "win32" pyyaml>=6.0.0 +openai>=1.52.0 # Dev dependencies ruff>=0.8.0 diff --git a/server/gemini_client.py b/server/gemini_client.py new file mode 100644 index 00000000..c794dfc5 --- /dev/null +++ b/server/gemini_client.py @@ -0,0 +1,80 @@ +""" +Lightweight Gemini API client (OpenAI-compatible endpoint). + +Uses Google's OpenAI-compatible Gemini endpoint: +https://generativelanguage.googleapis.com/v1beta/openai + +Environment variables: +- GEMINI_API_KEY (required) +- GEMINI_MODEL (optional, default: gemini-1.5-flash) +- GEMINI_BASE_URL (optional, default: official OpenAI-compatible endpoint) +""" + +import os +from typing import AsyncGenerator, Iterable, Optional + +from openai import AsyncOpenAI + +# Default OpenAI-compatible base URL for Gemini +DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" +DEFAULT_GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-1.5-flash") + + +def is_gemini_configured() -> bool: + """Return True if a Gemini API key is available.""" + return bool(os.getenv("GEMINI_API_KEY")) + + +def _build_client() -> AsyncOpenAI: + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + raise RuntimeError("GEMINI_API_KEY is not set") + + base_url = os.getenv("GEMINI_BASE_URL", DEFAULT_GEMINI_BASE_URL) + return AsyncOpenAI(api_key=api_key, base_url=base_url) + + +async def stream_chat( + user_message: str, + *, + system_prompt: Optional[str] = None, + model: Optional[str] = None, + extra_messages: Optional[Iterable[dict]] = None, +) -> AsyncGenerator[str, None]: + """ + Stream a chat completion from Gemini. + + Args: + user_message: Primary user input + system_prompt: Optional system prompt to prepend + model: Optional model name; defaults to GEMINI_MODEL env or fallback constant + extra_messages: Optional prior messages (list of {"role","content"}) + Yields: + Text chunks as they arrive. + """ + client = _build_client() + messages = [] + + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + if extra_messages: + messages.extend(extra_messages) + + messages.append({"role": "user", "content": user_message}) + + completion = await client.chat.completions.create( + model=model or DEFAULT_GEMINI_MODEL, + messages=messages, + stream=True, + ) + + async for chunk in completion: + for choice in chunk.choices: + delta = choice.delta + if delta and delta.content: + # delta.content is a list of content parts + for part in delta.content: + text = getattr(part, "text", None) or part.get("text") if isinstance(part, dict) else None + if text: + yield text diff --git a/server/main.py b/server/main.py index 0e091648..0600e3c3 100644 --- a/server/main.py +++ b/server/main.py @@ -204,7 +204,11 @@ async def setup_status(): # If GLM mode is configured via .env, we have alternative credentials glm_configured = bool(os.getenv("ANTHROPIC_BASE_URL") and os.getenv("ANTHROPIC_AUTH_TOKEN")) - credentials = has_claude_config or glm_configured + + # Gemini configuration (OpenAI-compatible Gemini API) + gemini_configured = bool(os.getenv("GEMINI_API_KEY")) + + credentials = has_claude_config or glm_configured or gemini_configured # Check for Node.js and npm node = shutil.which("node") is not None @@ -215,6 +219,7 @@ async def setup_status(): credentials=credentials, node=node, npm=npm, + gemini=gemini_configured, ) diff --git a/server/schemas.py b/server/schemas.py index 0a2807cc..b06cc9ec 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -227,6 +227,7 @@ class SetupStatus(BaseModel): credentials: bool node: bool npm: bool + gemini: bool = False # ============================================================================ diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index f15eee8a..de70838d 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -25,6 +25,7 @@ create_conversation, get_messages, ) +from ..gemini_client import is_gemini_configured, stream_chat # Load environment variables from .env file if present load_dotenv() @@ -182,6 +183,8 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option self._client_entered: bool = False self.created_at = datetime.now() self._history_loaded: bool = False # Track if we've loaded history for resumed conversations + self.provider: str = "gemini" if is_gemini_configured() else "claude" + self._system_prompt: str | None = None async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -249,6 +252,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) + self._system_prompt = system_prompt # Write system prompt to CLAUDE.md file to avoid Windows command line length limit # The SDK will read this via setting_sources=["project"] @@ -257,42 +261,46 @@ async def start(self) -> AsyncGenerator[dict, None]: f.write(system_prompt) logger.info(f"Wrote assistant system prompt to {claude_md_path}") - # Use system Claude CLI - system_cli = shutil.which("claude") + if self.provider == "gemini": + logger.info("Assistant session using Gemini provider (no tools).") + self.client = None + else: + # Use system Claude CLI + system_cli = shutil.which("claude") - # Build environment overrides for API configuration - sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + # Build environment overrides for API configuration + sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} - # Determine model from environment or use default - # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names - model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") + # Determine model from environment or use default + # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names + model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") - try: - logger.info("Creating ClaudeSDKClient...") - self.client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - cli_path=system_cli, - # System prompt loaded from CLAUDE.md via setting_sources - # This avoids Windows command line length limit (~8191 chars) - setting_sources=["project"], - allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], - mcp_servers=mcp_servers, - permission_mode="bypassPermissions", - max_turns=100, - cwd=str(self.project_dir.resolve()), - settings=str(settings_file.resolve()), - env=sdk_env, + try: + logger.info("Creating ClaudeSDKClient...") + self.client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model=model, + cli_path=system_cli, + # System prompt loaded from CLAUDE.md via setting_sources + # This avoids Windows command line length limit (~8191 chars) + setting_sources=["project"], + allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], + mcp_servers=mcp_servers, + permission_mode="bypassPermissions", + max_turns=100, + cwd=str(self.project_dir.resolve()), + settings=str(settings_file.resolve()), + env=sdk_env, + ) ) - ) - logger.info("Entering Claude client context...") - await self.client.__aenter__() - self._client_entered = True - logger.info("Claude client ready") - except Exception as e: - logger.exception("Failed to create Claude client") - yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} - return + logger.info("Entering Claude client context...") + await self.client.__aenter__() + self._client_entered = True + logger.info("Claude client ready") + except Exception as e: + logger.exception("Failed to create Claude client") + yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} + return # Send initial greeting only for NEW conversations # Resumed conversations already have history loaded from the database @@ -329,7 +337,7 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: - {"type": "response_done"} - {"type": "error", "content": str} """ - if not self.client: + if self.provider != "gemini" and not self.client: yield {"type": "error", "content": "Session not initialized. Call start() first."} return @@ -365,11 +373,15 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: logger.info(f"Loaded {len(history)} messages from conversation history") try: - async for chunk in self._query_claude(message_to_send): - yield chunk + if self.provider == "gemini": + async for chunk in self._query_gemini(message_to_send): + yield chunk + else: + async for chunk in self._query_claude(message_to_send): + yield chunk yield {"type": "response_done"} except Exception as e: - logger.exception("Error during Claude query") + logger.exception("Error during assistant query") yield {"type": "error", "content": f"Error: {str(e)}"} async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]: @@ -413,6 +425,22 @@ async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]: if full_response and self.conversation_id: add_message(self.project_dir, self.conversation_id, "assistant", full_response) + async def _query_gemini(self, message: str) -> AsyncGenerator[dict, None]: + """ + Query Gemini and stream plain-text responses (no tool calls). + """ + full_response = "" + async for text in stream_chat( + message, + system_prompt=self._system_prompt, + model=os.getenv("GEMINI_MODEL"), + ): + full_response += text + yield {"type": "text", "content": text} + + if full_response and self.conversation_id: + add_message(self.project_dir, self.conversation_id, "assistant", full_response) + def get_conversation_id(self) -> Optional[int]: """Get the current conversation ID.""" return self.conversation_id From aa0d7e3d7e956690f846d9a423ff04b622f9fb65 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 19:59:57 +1100 Subject: [PATCH 07/17] Format imports in health tests --- test_health.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test_health.py b/test_health.py index 5aa79ed2..d43d4750 100644 --- a/test_health.py +++ b/test_health.py @@ -1,6 +1,7 @@ """Lightweight tests for health and readiness endpoints.""" from fastapi.testclient import TestClient + from server.main import app client = TestClient(app) From 080831abe737e35d8cd8db3a8c0b0439f620abde Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 20:03:58 +1100 Subject: [PATCH 08/17] Add Gemini UI notice and improve Gemini error handling --- server/services/assistant_chat_session.py | 19 ++++++++++++------- ui/src/components/SetupWizard.tsx | 18 ++++++++++++++++++ ui/src/lib/types.ts | 1 + 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index de70838d..9fd9197f 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -430,13 +430,18 @@ async def _query_gemini(self, message: str) -> AsyncGenerator[dict, None]: Query Gemini and stream plain-text responses (no tool calls). """ full_response = "" - async for text in stream_chat( - message, - system_prompt=self._system_prompt, - model=os.getenv("GEMINI_MODEL"), - ): - full_response += text - yield {"type": "text", "content": text} + try: + async for text in stream_chat( + message, + system_prompt=self._system_prompt, + model=os.getenv("GEMINI_MODEL"), + ): + full_response += text + yield {"type": "text", "content": text} + except Exception as e: + logger.exception("Gemini query failed") + yield {"type": "error", "content": f"Gemini error: {e}"} + return if full_response and self.conversation_id: add_message(self.project_dir, self.conversation_id, "assistant", full_response) diff --git a/ui/src/components/SetupWizard.tsx b/ui/src/components/SetupWizard.tsx index 79d009ee..95a11a3a 100644 --- a/ui/src/components/SetupWizard.tsx +++ b/ui/src/components/SetupWizard.tsx @@ -98,6 +98,24 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { helpText="Install Node.js" optional /> + + {/* Gemini (chat-only) */} + {/* Continue Button */} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index d883432f..4afe0d04 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -144,6 +144,7 @@ export interface SetupStatus { credentials: boolean node: boolean npm: boolean + gemini: boolean } // Dev Server types From 78bf56365291852e044b43ac3a19c317b8690155 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 20:11:32 +1100 Subject: [PATCH 09/17] Fix workflow expressions and add repo guard to PR checks --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 6 +++--- .github/workflows/pr-check.yml | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3634196..e92f2121 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: contents: read packages: write env: - IMAGE_NAME: ghcr.io/${{ toLower(github.repository) }} + IMAGE_NAME: ghcr.io/${{ github.repository }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7f8043bb..ef9c1254 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest env: DEPLOY_PATH: ${{ secrets.VPS_DEPLOY_PATH || '/opt/autocoder' }} - TARGET_BRANCH: ${{ secrets.VPS_BRANCH || 'master' }} + TARGET_BRANCH: ${{ secrets.VPS_BRANCH || 'main' }} VPS_PORT: ${{ secrets.VPS_PORT || '22' }} - IMAGE_LATEST: ghcr.io/${{ toLower(github.repository) }}:latest - IMAGE_SHA: ghcr.io/${{ toLower(github.repository) }}:${{ github.event.workflow_run.head_sha }} + IMAGE_LATEST: ghcr.io/${{ github.repository }}:latest + IMAGE_SHA: ghcr.io/${{ github.repository }}:${{ github.event.workflow_run.head_sha }} steps: - name: Deploy over SSH with Docker Compose uses: appleboy/ssh-action@v1.2.4 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index a487e076..d8a52fe4 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -12,6 +12,20 @@ concurrency: cancel-in-progress: true jobs: + repo-guards: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Ensure .code is not tracked + shell: bash + run: | + tracked="$(git ls-files -- .code)" + if [ -n "$tracked" ]; then + echo "The .code/ directory must not be tracked." + echo "$tracked" + exit 1 + fi + python: runs-on: ubuntu-latest steps: From 2f4f1358114b1baafc166883a99f18b77ab4bd77 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 20:20:07 +1100 Subject: [PATCH 10/17] Add one-click VPS deploy script with Traefik, DuckDNS, and Let's Encrypt --- docker-compose.traefik.yml | 40 +++++++++++ scripts/deploy.sh | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 docker-compose.traefik.yml create mode 100644 scripts/deploy.sh diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml new file mode 100644 index 00000000..29d79632 --- /dev/null +++ b/docker-compose.traefik.yml @@ -0,0 +1,40 @@ +version: "3.9" + +services: + traefik: + image: traefik:v3.1 + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./letsencrypt:/letsencrypt + networks: + - traefik-proxy + + autocoder: + networks: + - traefik-proxy + labels: + - traefik.enable=true + - traefik.http.routers.autocoder.rule=Host(`${DOMAIN}`) + - traefik.http.routers.autocoder.entrypoints=websecure + - traefik.http.routers.autocoder.tls.certresolver=le + - traefik.http.services.autocoder.loadbalancer.server.port=${APP_PORT:-8888} + - traefik.http.routers.autocoder-web.rule=Host(`${DOMAIN}`) + - traefik.http.routers.autocoder-web.entrypoints=web + - traefik.http.routers.autocoder-web.middlewares=redirect-to-https + - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https + +networks: + traefik-proxy: + external: true diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 00000000..7315321a --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +# One-click Docker deploy for AutoCoder on a VPS with DuckDNS + Traefik + Let's Encrypt. +# Prompts for domain, DuckDNS token, email, repo, branch, and target install path. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "Please run as root (sudo)." >&2 + exit 1 +fi + +prompt_required() { + local var_name="$1" prompt_msg="$2" + local value + while true; do + read -r -p "$prompt_msg: " value + if [[ -n "$value" ]]; then + printf -v "$var_name" '%s' "$value" + export "$var_name" + return + fi + echo "Value cannot be empty." + done +} + +echo "=== AutoCoder VPS Deploy (Docker + Traefik + DuckDNS + Let's Encrypt) ===" + +prompt_required DOMAIN "Enter your DuckDNS domain (e.g., myapp.duckdns.org)" +prompt_required DUCKDNS_TOKEN "Enter your DuckDNS token" +prompt_required LETSENCRYPT_EMAIL "Enter email for Let's Encrypt notifications" + +read -r -p "Git repo URL [https://github.com/heidi-dang/autocoder.git]: " REPO_URL +REPO_URL=${REPO_URL:-https://github.com/heidi-dang/autocoder.git} + +read -r -p "Git branch to deploy [main]: " DEPLOY_BRANCH +DEPLOY_BRANCH=${DEPLOY_BRANCH:-main} + +read -r -p "Install path [/opt/autocoder]: " APP_DIR +APP_DIR=${APP_DIR:-/opt/autocoder} + +read -r -p "App internal port (container) [8888]: " APP_PORT +APP_PORT=${APP_PORT:-8888} + +echo +echo "Domain: $DOMAIN" +echo "Repo: $REPO_URL" +echo "Branch: $DEPLOY_BRANCH" +echo "Path: $APP_DIR" +echo +read -r -p "Proceed? [y/N]: " CONFIRM +if [[ "${CONFIRM,,}" != "y" ]]; then + echo "Aborted." + exit 1 +fi + +ensure_packages() { + echo "Installing Docker & prerequisites..." + apt-get update -y + apt-get install -y ca-certificates curl git gnupg + install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list + apt-get update -y + fi + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker +} + +configure_duckdns() { + echo "Configuring DuckDNS..." + local cron_file="/etc/cron.d/duckdns" + cat > "$cron_file" </var/log/duckdns.log 2>&1 +EOF + chmod 644 "$cron_file" + # Run once immediately + curl -fsS "https://www.duckdns.org/update?domains=$DOMAIN&token=$DUCKDNS_TOKEN&ip=" >/var/log/duckdns.log 2>&1 || true +} + +clone_repo() { + if [[ -d "$APP_DIR/.git" ]]; then + echo "Repo already exists, pulling latest..." + git -C "$APP_DIR" fetch --all + git -C "$APP_DIR" checkout "$DEPLOY_BRANCH" + git -C "$APP_DIR" pull --ff-only origin "$DEPLOY_BRANCH" + else + echo "Cloning repository..." + mkdir -p "$APP_DIR" + git clone --branch "$DEPLOY_BRANCH" "$REPO_URL" "$APP_DIR" + fi +} + +write_env() { + echo "Writing deploy env (.env.deploy)..." + cat > "$APP_DIR/.env.deploy" </dev/null 2>&1 || docker network create traefik-proxy + docker compose --env-file .env.deploy -f docker-compose.yml -f docker-compose.traefik.yml pull || true + docker compose --env-file .env.deploy -f docker-compose.yml -f docker-compose.traefik.yml up -d --build +} + +ensure_packages +configure_duckdns +clone_repo +write_env +prepare_ssl_storage +run_compose + +echo +echo "Deployment complete." +echo "Check: http://$DOMAIN (will redirect to https after cert is issued)." +echo "Logs: docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f" +echo "To update: rerun this script; it will git pull and restart." From 4c7df1e31c80ad2fee9077ea36fda985605349b4 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 20:28:21 +1100 Subject: [PATCH 11/17] Add DEVELOPMENT roadmap with phased plan --- DEVELOPMENT.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..f7d05ed7 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,63 @@ +# AutoCoder Development Roadmap + +This roadmap breaks work into clear phases so you can pick the next most valuable items quickly. + +## Phase 0 — Baseline (ship ASAP) +- **PR discipline:** Enforce branch protection requiring “PR Check” (already configured in workflows; ensure GitHub rule is on). +- **Secrets hygiene:** Move all deploy secrets into repo/environment secrets; prohibit `.env` commits via pre-commit hook. +- **Smoke tests:** Keep `/health` and `/readiness` endpoints green; add UI smoke (landing page loads) to CI. + +## Phase 1 — Reliability & Observability +- **Structured logging:** Add JSON logging for FastAPI (uvicorn access + app logs) with request IDs; forward to stdout for Docker/Traefik. +- **Error reporting:** Wire Sentry (or OpenTelemetry + OTLP) for backend exceptions and front-end errors. +- **Metrics:** Expose `/metrics` (Prometheus) for FastAPI; Traefik already exposes metrics option—enable when scraping is available. +- **Tracing:** Add OTEL middleware to FastAPI; propagate trace IDs through to Claude/Gemini calls when possible. + +## Phase 2 — Platform & DevX +- **Local dev parity:** Add `docker-compose.dev.yml` with hot-reload for FastAPI + Vite UI; document one-command setup. +- **Makefile/taskfile:** Common commands (`make dev`, `make test`, `make lint`, `make format`, `make seed`). +- **Pre-commit:** Ruff, mypy, black (if adopted), eslint/prettier for `ui/`. +- **Typed APIs:** Add mypy strict mode to `server/` and type `schemas.py` fully (Pydantic v2 ConfigDict). + +## Phase 3 — Product & Agent Quality +- **Model selection UI:** Let users choose assistant provider (Claude/Gemini) in settings; display active provider badge in chat. +- **Tooling guardrails:** For Gemini (chat-only), show “no tools” notice in UI and fallback logic to Claude when tools needed. +- **Conversation persistence:** Add pagination/search over assistant history; export conversation to file. +- **Feature board:** Surface feature stats/graph from MCP in the UI (read-only dashboard). + +## Phase 4 — Security & Compliance +- **AuthN/AuthZ:** Add optional login (JWT/OIDC) gate for UI/API; role for “admin” vs “viewer” at least. +- **Rate limiting:** Enable per-IP rate limits at Traefik and per-token limits in FastAPI. +- **Audit trails:** Log agent actions and feature state changes with user identity. +- **Headers/HTTPS:** HSTS via Traefik, content-security-policy header from FastAPI. + +## Phase 5 — Performance & Scale +- **Caching:** CDN/Traefik static cache for UI assets; server-side cache for model list/status endpoints. +- **Worker separation:** Optionally split agent runner from API via separate services and queues (e.g., Redis/RQ or Celery). +- **Background jobs:** Move long-running tasks to scheduler/worker with backoff and retries. + +## Phase 6 — Testing & Quality Gates +- **Backend tests:** Add pytest suite for key routers (`/api/setup/status`, assistant chat happy-path with mock Claude/Gemini). +- **Frontend tests:** Add Vitest + React Testing Library smoke tests for core pages (dashboard loads, settings save). +- **E2E:** Playwright happy-path (login optional, start agent, view logs). +- **Coverage:** Fail CI if coverage drops below threshold (start at 60–70%). + +## Phase 7 — Deployment & Ops +- **Blue/green deploy:** Add image tagging `:sha` + `:latest` (already for CI) with Traefik service labels to toggle. +- **Backups:** Snapshot `~/.autocoder` data volume; document restore. +- **Runbooks:** Add `RUNBOOK.md` for common ops (restart, rotate keys, renew certs, roll back). + +## Phase 8 — Documentation & Onboarding +- **Getting started:** Short path for “run locally in 5 minutes” (scripted). +- **Config matrix:** Document required/optional env vars (Claude, Gemini, DuckDNS, Traefik, TLS). +- **Architecture:** One-page diagram: UI ↔ FastAPI ↔ Agent subprocess ↔ Claude/Gemini; MCP servers; Traefik front. + +## Stretch Ideas +- **Telemetry-driven tuning:** Auto-select model/provider based on latency/cost SLA. +- **Cost controls:** Show per-run token/cost estimates; configurable budgets. +- **Offline/edge mode:** Ollama provider toggle with cached models. + +## How to use this roadmap +- Pick the next phase that unblocks your current goal (reliability → platform → product). +- Keep PRs small and scoped to one bullet. +- Update this document when a bullet ships or is reprioritized. From 4e44fb2a7f019da21fc0c367041aeb2acbbf8e86 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 20:35:32 +1100 Subject: [PATCH 12/17] Sort imports in assistant chat session --- server/services/assistant_chat_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 9fd9197f..190e8207 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -20,12 +20,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from dotenv import load_dotenv +from ..gemini_client import is_gemini_configured, stream_chat from .assistant_database import ( add_message, create_conversation, get_messages, ) -from ..gemini_client import is_gemini_configured, stream_chat # Load environment variables from .env file if present load_dotenv() From 349837d93556b535df52b0633f2a6abcc2ff5da4 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 20:59:26 +1100 Subject: [PATCH 13/17] Limit workflows to main branch --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 8 ++++---- .github/workflows/pr-check.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e92f2121..ca3d2866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Push CI on: push: - branches: [master, main] + branches: [main] jobs: repo-guards: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ef9c1254..ce124ba6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: Deploy to VPS on: workflow_run: workflows: ["Push CI"] - branches: [main, master] + branches: [main] types: - completed @@ -59,9 +59,9 @@ jobs: git checkout "$TARGET_BRANCH" git pull --ff-only origin "$TARGET_BRANCH" else - echo "Branch $TARGET_BRANCH not found, trying main or master" - git checkout main 2>/dev/null || git checkout master - git pull --ff-only origin main 2>/dev/null || git pull --ff-only origin master + echo "Branch $TARGET_BRANCH not found, trying main" + git checkout main + git pull --ff-only origin main fi if command -v docker &>/dev/null && docker compose version &>/dev/null; then diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index d8a52fe4..83d7ec31 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -2,7 +2,7 @@ name: PR Check on: pull_request: - branches: [main, master] + branches: [main] permissions: contents: read From ef33257db4a7d26a21bfa7a1f402cf82351481d9 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 21:24:31 +1100 Subject: [PATCH 14/17] Guard against tracked .env and .code --- .github/workflows/ci.yml | 19 ++++++++++++++----- .github/workflows/pr-check.yml | 19 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca3d2866..0950cb38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,22 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Ensure .code is not tracked + - name: Ensure .code/ and .env are not tracked shell: bash run: | - tracked="$(git ls-files -- .code)" - if [ -n "$tracked" ]; then - echo "The .code/ directory must not be tracked." - echo "$tracked" + tracked_code="$(git ls-files -- .code)" + tracked_env="$(git ls-files -- .env)" + + if [ -n "$tracked_code" ] || [ -n "$tracked_env" ]; then + echo "Local-only policy and secrets files must not be tracked." + if [ -n "$tracked_code" ]; then + echo "Tracked .code/ entries:" + echo "$tracked_code" + fi + if [ -n "$tracked_env" ]; then + echo "Tracked .env entries:" + echo "$tracked_env" + fi exit 1 fi diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 83d7ec31..7174b667 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -16,13 +16,22 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Ensure .code is not tracked + - name: Ensure .code/ and .env are not tracked shell: bash run: | - tracked="$(git ls-files -- .code)" - if [ -n "$tracked" ]; then - echo "The .code/ directory must not be tracked." - echo "$tracked" + tracked_code="$(git ls-files -- .code)" + tracked_env="$(git ls-files -- .env)" + + if [ -n "$tracked_code" ] || [ -n "$tracked_env" ]; then + echo "Local-only policy and secrets files must not be tracked." + if [ -n "$tracked_code" ]; then + echo "Tracked .code/ entries:" + echo "$tracked_code" + fi + if [ -n "$tracked_env" ]; then + echo "Tracked .env entries:" + echo "$tracked_env" + fi exit 1 fi From c4d0112b262af30eb107d3ec6460d1a0fbf46b2b Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 21:39:36 +1100 Subject: [PATCH 15/17] Fix Traefik Docker API version mismatch --- docker-compose.traefik.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 29d79632..c2b94d8f 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -3,6 +3,11 @@ version: "3.9" services: traefik: image: traefik:v3.1 + environment: + # Force a modern Docker API version. Some VPS environments set + # DOCKER_API_VERSION=1.24 globally, which breaks Traefik's Docker provider + # when the daemon requires >= 1.44. + - DOCKER_API_VERSION=1.44 command: - --providers.docker=true - --providers.docker.exposedbydefault=false From 3fb4a23c00f160219e057a551fdcb8ee9139bea6 Mon Sep 17 00:00:00 2001 From: heidi-dang Date: Tue, 27 Jan 2026 21:52:25 +1100 Subject: [PATCH 16/17] Automate VPS deploy via deploy.sh --- .github/workflows/deploy.yml | 74 +++---- deploy.sh | 371 +++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 37 deletions(-) create mode 100644 deploy.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce124ba6..337c3944 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,11 @@ jobs: DEPLOY_PATH: ${{ secrets.VPS_DEPLOY_PATH || '/opt/autocoder' }} TARGET_BRANCH: ${{ secrets.VPS_BRANCH || 'main' }} VPS_PORT: ${{ secrets.VPS_PORT || '22' }} + DOMAIN: ${{ secrets.VPS_DOMAIN }} + DUCKDNS_TOKEN: ${{ secrets.VPS_DUCKDNS_TOKEN }} + LETSENCRYPT_EMAIL: ${{ secrets.VPS_LETSENCRYPT_EMAIL }} + APP_PORT: ${{ secrets.VPS_APP_PORT || '8888' }} + REPO_URL: https://github.com/${{ github.repository }}.git IMAGE_LATEST: ghcr.io/${{ github.repository }}:latest IMAGE_SHA: ghcr.io/${{ github.repository }}:${{ github.event.workflow_run.head_sha }} steps: @@ -32,7 +37,7 @@ jobs: username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} port: ${{ env.VPS_PORT }} - envs: DEPLOY_PATH,TARGET_BRANCH,IMAGE_LATEST,IMAGE_SHA + envs: DEPLOY_PATH,TARGET_BRANCH,VPS_PORT,DOMAIN,DUCKDNS_TOKEN,LETSENCRYPT_EMAIL,APP_PORT,REPO_URL,IMAGE_LATEST,IMAGE_SHA script: | set -euo pipefail @@ -40,56 +45,51 @@ jobs: echo "VPS_DEPLOY_PATH secret is required"; exit 1; fi + if [ -z "${DOMAIN:-}" ] || [ -z "${DUCKDNS_TOKEN:-}" ] || [ -z "${LETSENCRYPT_EMAIL:-}" ]; then + echo "VPS_DOMAIN, VPS_DUCKDNS_TOKEN, and VPS_LETSENCRYPT_EMAIL secrets are required."; exit 1; + fi + if [ ! -d "$DEPLOY_PATH/.git" ]; then echo "ERROR: $DEPLOY_PATH is missing a git repo. Clone the repository there and keep your .env file."; exit 1; fi cd "$DEPLOY_PATH" - if [ ! -f .env ]; then - echo "WARNING: .env not found in $DEPLOY_PATH. Deployment will continue without it."; + if [ ! -f ./deploy.sh ]; then + echo "ERROR: deploy.sh not found in $DEPLOY_PATH. Ensure the repo is up to date."; exit 1; fi - git fetch --all - if ! git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then - git fetch origin "$TARGET_BRANCH" || true - fi + chmod +x ./deploy.sh - if git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then - git checkout "$TARGET_BRANCH" - git pull --ff-only origin "$TARGET_BRANCH" - else - echo "Branch $TARGET_BRANCH not found, trying main" - git checkout main - git pull --ff-only origin main + if [ ! -f .env ]; then + echo "WARNING: .env not found in $DEPLOY_PATH. Deployment will continue without it."; fi - if command -v docker &>/dev/null && docker compose version &>/dev/null; then - DOCKER_CMD="docker compose" - elif command -v docker-compose &>/dev/null; then - DOCKER_CMD="docker-compose" + if [ "$(id -u)" -eq 0 ]; then + RUNNER="" else - echo "Docker Compose is not installed on the VPS."; exit 1; - fi - - export IMAGE="${IMAGE_SHA:-$IMAGE_LATEST}" - - $DOCKER_CMD down --remove-orphans || true - docker image prune -af || true - docker builder prune -af || true - - echo "Pulling image ${IMAGE} ..." - if ! $DOCKER_CMD pull; then - echo "SHA tag pull failed, falling back to latest..." - export IMAGE="$IMAGE_LATEST" - $DOCKER_CMD pull || { echo "Image pull failed"; exit 1; } + if ! command -v sudo >/dev/null 2>&1; then + echo "sudo is required to run deploy.sh as root."; exit 1; + fi + RUNNER="sudo" fi - $DOCKER_CMD up -d --remove-orphans - - echo "Running smoke test on http://127.0.0.1:8888/health and /readiness ..." + $RUNNER env \ + AUTOCODER_AUTOMATED=1 \ + AUTOCODER_ASSUME_YES=1 \ + DOMAIN="${DOMAIN}" \ + DUCKDNS_TOKEN="${DUCKDNS_TOKEN}" \ + LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL}" \ + REPO_URL="${REPO_URL}" \ + DEPLOY_BRANCH="${TARGET_BRANCH}" \ + APP_DIR="${DEPLOY_PATH}" \ + APP_PORT="${APP_PORT}" \ + IMAGE="${IMAGE_SHA:-$IMAGE_LATEST}" \ + ./deploy.sh + + echo "Running smoke test on http://127.0.0.1:${APP_PORT}/health and /readiness ..." retries=12 - until curl -fsS --max-time 5 http://127.0.0.1:8888/health >/dev/null; do + until curl -fsS --max-time 5 "http://127.0.0.1:${APP_PORT}/health" >/dev/null; do retries=$((retries - 1)) if [ "$retries" -le 0 ]; then echo "Health check failed after retries." @@ -100,7 +100,7 @@ jobs: done retries=12 - until curl -fsS --max-time 5 http://127.0.0.1:8888/readiness >/dev/null; do + until curl -fsS --max-time 5 "http://127.0.0.1:${APP_PORT}/readiness" >/dev/null; do retries=$((retries - 1)) if [ "$retries" -le 0 ]; then echo "Readiness check failed after retries." diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..5e7edac2 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash + +# One-click Docker deploy for AutoCoder on a VPS with DuckDNS + Traefik + Let's Encrypt. +# Prompts for domain, DuckDNS token, email, repo, branch, and target install path. + +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root (sudo)." >&2 + exit 1 +fi + +is_truthy() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +# Automation switches for CI/CD usage +AUTOMATED_MODE=0 +ASSUME_YES_MODE=0 +CLEANUP_REQUESTED=0 +CLEANUP_VOLUMES_REQUESTED=0 + +if is_truthy "${AUTOCODER_AUTOMATED:-0}"; then + AUTOMATED_MODE=1 +fi +if is_truthy "${AUTOCODER_ASSUME_YES:-0}"; then + ASSUME_YES_MODE=1 +fi +if is_truthy "${AUTOCODER_CLEANUP:-0}"; then + CLEANUP_REQUESTED=1 +fi +if is_truthy "${AUTOCODER_CLEANUP_VOLUMES:-0}"; then + CLEANUP_VOLUMES_REQUESTED=1 +fi + +prompt_required() { + local var_name="$1" + local prompt_msg="$2" + local value="" + + # Allow pre-seeding via environment variables in automated runs. + if [[ -n "${!var_name:-}" ]]; then + export "${var_name}" + return + fi + + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + echo "Missing required environment variable: ${var_name}" >&2 + exit 1 + fi + + while true; do + read -r -p "${prompt_msg}: " value + if [[ -n "${value}" ]]; then + printf -v "${var_name}" "%s" "${value}" + export "${var_name}" + return + fi + echo "Value cannot be empty." + done +} + +derive_duckdns_subdomain() { + # DuckDNS expects only the subdomain (e.g., "myapp"), but users often + # provide the full domain (e.g., "myapp.duckdns.org"). This supports both. + if [[ "${DOMAIN}" == *.duckdns.org ]]; then + DUCKDNS_SUBDOMAIN="${DOMAIN%.duckdns.org}" + else + DUCKDNS_SUBDOMAIN="${DOMAIN}" + fi + export DUCKDNS_SUBDOMAIN +} + +confirm_yes() { + local prompt_msg="$1" + local reply="" + + if [[ "${ASSUME_YES_MODE}" -eq 1 ]]; then + return 0 + fi + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + return 1 + fi + + read -r -p "${prompt_msg} [y/N]: " reply + [[ "${reply,,}" == "y" ]] +} + +echo "=== AutoCoder VPS Deploy (Docker + Traefik + DuckDNS + Let's Encrypt) ===" +echo "This will install Docker, configure DuckDNS, and deploy via docker compose." +echo + +prompt_required DOMAIN "Enter your DuckDNS domain (e.g., myapp.duckdns.org)" +prompt_required DUCKDNS_TOKEN "Enter your DuckDNS token" +prompt_required LETSENCRYPT_EMAIL "Enter email for Let's Encrypt notifications" + +derive_duckdns_subdomain + +if [[ -z "${REPO_URL:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "Git repo URL [https://github.com/heidi-dang/autocoder.git]: " REPO_URL + fi +fi +REPO_URL=${REPO_URL:-https://github.com/heidi-dang/autocoder.git} + +if [[ -z "${DEPLOY_BRANCH:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "Git branch to deploy [main]: " DEPLOY_BRANCH + fi +fi +DEPLOY_BRANCH=${DEPLOY_BRANCH:-main} + +if [[ -z "${APP_DIR:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "Install path [/opt/autocoder]: " APP_DIR + fi +fi +APP_DIR=${APP_DIR:-/opt/autocoder} + +if [[ -z "${APP_PORT:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "App internal port (container) [8888]: " APP_PORT + fi +fi +APP_PORT=${APP_PORT:-8888} + +echo +echo "Domain: ${DOMAIN}" +echo "DuckDNS domain: ${DUCKDNS_SUBDOMAIN}" +echo "Repo: ${REPO_URL}" +echo "Branch: ${DEPLOY_BRANCH}" +echo "Path: ${APP_DIR}" +echo "App port: ${APP_PORT}" +echo +if ! confirm_yes "Proceed?"; then + echo "Aborted." + exit 1 +fi + +ensure_packages() { + echo + echo "==> Installing Docker & prerequisites..." + apt-get update -y + apt-get install -y ca-certificates curl git gnupg + + install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${VERSION_CODENAME}") stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -y + fi + + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker +} + +configure_duckdns() { + echo + echo "==> Configuring DuckDNS..." + local cron_file="/etc/cron.d/duckdns" + cat > "${cron_file}" </var/log/duckdns.log 2>&1 +EOF + chmod 644 "${cron_file}" + + # Run once immediately. + curl -fsS "https://www.duckdns.org/update?domains=${DUCKDNS_SUBDOMAIN}&token=${DUCKDNS_TOKEN}&ip=" \ + >/var/log/duckdns.log 2>&1 || true +} + +clone_repo() { + echo + echo "==> Preparing repository..." + if [[ -d "${APP_DIR}/.git" ]]; then + echo "Repo already exists, pulling latest..." + git -C "${APP_DIR}" fetch --all --prune + git -C "${APP_DIR}" checkout "${DEPLOY_BRANCH}" + git -C "${APP_DIR}" pull --ff-only origin "${DEPLOY_BRANCH}" + else + echo "Cloning repository..." + mkdir -p "${APP_DIR}" + git clone --branch "${DEPLOY_BRANCH}" "${REPO_URL}" "${APP_DIR}" + fi +} + +assert_compose_files() { + echo + echo "==> Validating compose files..." + if [[ ! -f "${APP_DIR}/docker-compose.yml" ]]; then + echo "Missing ${APP_DIR}/docker-compose.yml" >&2 + exit 1 + fi + if [[ ! -f "${APP_DIR}/docker-compose.traefik.yml" ]]; then + echo "Missing ${APP_DIR}/docker-compose.traefik.yml" >&2 + exit 1 + fi +} + +preserve_env_file() { + echo + echo "==> Checking for production .env..." + ENV_PRESENT=0 + ENV_BACKUP="" + + if [[ -d "${APP_DIR}" && -f "${APP_DIR}/.env" ]]; then + ENV_PRESENT=1 + ENV_BACKUP="${APP_DIR}/.env.production.bak" + cp -f "${APP_DIR}/.env" "${ENV_BACKUP}" + chmod 600 "${ENV_BACKUP}" || true + echo "Found existing .env. Backed it up to ${ENV_BACKUP} and will preserve it." + else + echo "No existing .env found in ${APP_DIR}." + fi +} + +verify_env_preserved() { + if [[ "${ENV_PRESENT:-0}" -eq 1 && ! -f "${APP_DIR}/.env" ]]; then + echo "ERROR: .env was removed during deployment. Restoring from backup." >&2 + if [[ -n "${ENV_BACKUP:-}" && -f "${ENV_BACKUP}" ]]; then + cp -f "${ENV_BACKUP}" "${APP_DIR}/.env" + chmod 600 "${APP_DIR}/.env" || true + fi + exit 1 + fi + + if git -C "${APP_DIR}" ls-files --error-unmatch .env >/dev/null 2>&1; then + echo "WARNING: .env appears to be tracked by git. Consider untracking it." >&2 + fi +} + +write_env() { + echo + echo "==> Writing deploy env (.env.deploy)..." + cat > "${APP_DIR}/.env.deploy" < Preparing Let's Encrypt storage..." + mkdir -p "${APP_DIR}/letsencrypt" + touch "${APP_DIR}/letsencrypt/acme.json" + chmod 600 "${APP_DIR}/letsencrypt/acme.json" +} + +run_compose() { + echo + echo "==> Bringing up stack with Traefik reverse proxy and TLS..." + cd "${APP_DIR}" + + docker network inspect traefik-proxy >/dev/null 2>&1 || docker network create traefik-proxy + + docker compose \ + --env-file .env.deploy \ + -f docker-compose.yml \ + -f docker-compose.traefik.yml \ + pull || true + + docker compose \ + --env-file .env.deploy \ + -f docker-compose.yml \ + -f docker-compose.traefik.yml \ + up -d --build +} + +cleanup_vps_safe() { + echo + echo "==> Optional VPS cleanup (safe scope only)..." + echo "This will prune unused Docker artifacts, clean apt caches, and trim old logs." + echo "It will NOT delete arbitrary files and will not touch ${APP_DIR}/.env." + + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + if [[ "${CLEANUP_REQUESTED}" -ne 1 ]]; then + echo "Skipping cleanup in automated mode." + return + fi + echo "Cleanup requested in automated mode." + else + if ! confirm_yes "Run safe cleanup now?"; then + echo "Skipping cleanup." + return + fi + fi + + if command -v docker >/dev/null 2>&1; then + echo "--> Pruning unused Docker containers/images/build cache..." + docker container prune -f || true + docker image prune -f || true + docker builder prune -f || true + + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + if [[ "${CLEANUP_VOLUMES_REQUESTED}" -eq 1 ]]; then + docker volume prune -f || true + else + echo "Skipping Docker volume prune in automated mode." + fi + elif confirm_yes "Also prune unused Docker volumes? (may delete data)"; then + docker volume prune -f || true + else + echo "Skipping Docker volume prune." + fi + fi + + echo "--> Cleaning apt caches..." + apt-get autoremove -y || true + apt-get autoclean -y || true + + if command -v journalctl >/dev/null 2>&1; then + echo "--> Trimming systemd journal logs older than 14 days..." + journalctl --vacuum-time=14d || true + fi +} + +post_checks() { + echo + echo "==> Post-deploy checks (non-fatal)..." + cd "${APP_DIR}" + + docker compose -f docker-compose.yml -f docker-compose.traefik.yml ps || true + + # These checks may fail briefly while the certificate is being issued. + curl -fsS "http://${DOMAIN}/api/health" >/dev/null 2>&1 && \ + echo "Health check over HTTP: OK" || \ + echo "Health check over HTTP: not ready yet" + + curl -fsS "https://${DOMAIN}/api/health" >/dev/null 2>&1 && \ + echo "Health check over HTTPS: OK" || \ + echo "Health check over HTTPS: not ready yet (TLS may still be issuing)" +} + +print_notes() { + cat <<'EOF' + +Deployment complete. + +If the domain does not come up immediately: +1. Ensure ports 80 and 443 are open on the VPS firewall/security group. +2. Confirm DuckDNS points to this VPS IP. +3. Check logs: + docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f +4. Confirm backend health locally: + curl -fsS http://127.0.0.1:8888/api/health || true + +To update later, rerun this script. It will git pull and restart. +EOF +} + +ensure_packages +configure_duckdns +clone_repo +assert_compose_files +preserve_env_file +write_env +prepare_ssl_storage +run_compose +verify_env_preserved +cleanup_vps_safe +post_checks +print_notes From 9153a57d5f7552681a9f33a5358427110665d22a Mon Sep 17 00:00:00 2001 From: Heidi Dang Date: Tue, 27 Jan 2026 11:26:35 +0000 Subject: [PATCH 17/17] Fix Traefik routing and allow Docker access --- docker-compose.traefik.yml | 5 +++-- docker-compose.yml | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index c2b94d8f..9d75c411 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -2,12 +2,13 @@ version: "3.9" services: traefik: - image: traefik:v3.1 + image: traefik:latest environment: # Force a modern Docker API version. Some VPS environments set # DOCKER_API_VERSION=1.24 globally, which breaks Traefik's Docker provider # when the daemon requires >= 1.44. - - DOCKER_API_VERSION=1.44 + # Use the server's current API version to avoid a too-old client default. + - DOCKER_API_VERSION=1.53 command: - --providers.docker=true - --providers.docker.exposedbydefault=false diff --git a/docker-compose.yml b/docker-compose.yml index 2cce84e6..fb1023aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,11 @@ services: dockerfile: Dockerfile env_file: - .env + environment: + # Docker port-forwarded requests appear from the bridge gateway + # (e.g., 172.17.0.1), so strict localhost-only mode blocks them. + # Allow overriding via AUTOCODER_ALLOW_REMOTE=0/false in .env. + AUTOCODER_ALLOW_REMOTE: ${AUTOCODER_ALLOW_REMOTE:-1} ports: - "8888:8888" restart: unless-stopped