diff --git a/bin/disinto b/bin/disinto index f40d589..5487d75 100755 --- a/bin/disinto +++ b/bin/disinto @@ -4,12 +4,19 @@ # # Commands: # disinto init [options] Bootstrap a new project +# disinto up Start the full stack (docker compose) +# disinto down Stop the full stack +# disinto logs [service] Tail service logs +# disinto shell Shell into the agent container # disinto status Show factory status # disinto secrets Manage encrypted secrets # # Usage: # disinto init https://github.com/user/repo # disinto init user/repo --branch main --ci-id 3 +# disinto init user/repo --bare (bare-metal, no compose) +# disinto up +# disinto down # disinto status # ============================================================================= set -euo pipefail @@ -25,6 +32,10 @@ disinto — autonomous code factory CLI Usage: disinto init [options] Bootstrap a new project + disinto up Start the full stack (docker compose) + disinto down Stop the full stack + disinto logs [service] Tail service logs + disinto shell Shell into the agent container disinto status Show factory status disinto secrets Manage encrypted secrets (.env.enc) @@ -33,6 +44,7 @@ Init options: --repo-root Local clone path (default: ~/name) --ci-id Woodpecker CI repo ID (default: 0 = no CI) --forge-url Forge base URL (default: http://localhost:3000) + --bare Skip compose generation (bare-metal setup) --yes Skip confirmation prompts EOF exit 1 @@ -136,15 +148,128 @@ write_secrets_encrypted() { FORGEJO_DATA_DIR="${HOME}/.disinto/forgejo" +# Generate docker-compose.yml in the factory root. +generate_compose() { + local forge_port="${1:-3000}" + local compose_file="${FACTORY_ROOT}/docker-compose.yml" + + cat > "$compose_file" <<'COMPOSEEOF' +# docker-compose.yml — generated by disinto init +# Brings up Forgejo, Woodpecker, and the agent runtime. + +services: + forgejo: + image: forgejo/forgejo:latest + restart: unless-stopped + volumes: + - forgejo-data:/data + environment: + FORGEJO__database__DB_TYPE: sqlite3 + FORGEJO__server__ROOT_URL: http://forgejo:3000/ + FORGEJO__server__HTTP_PORT: "3000" + FORGEJO__service__DISABLE_REGISTRATION: "true" + networks: + - disinto-net + + woodpecker: + image: woodpeckerci/woodpecker-server:latest + restart: unless-stopped + volumes: + - woodpecker-data:/var/lib/woodpecker + environment: + WOODPECKER_FORGEJO: "true" + WOODPECKER_FORGEJO_URL: http://forgejo:3000 + WOODPECKER_FORGEJO_CLIENT: ${WP_FORGEJO_CLIENT:-} + WOODPECKER_FORGEJO_SECRET: ${WP_FORGEJO_SECRET:-} + WOODPECKER_HOST: http://woodpecker:8000 + WOODPECKER_DATABASE_DRIVER: sqlite3 + WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite + depends_on: + - forgejo + networks: + - disinto-net + + agents: + build: ./docker/agents + restart: unless-stopped + volumes: + - agent-data:/home/agent/data + - project-repos:/home/agent/repos + - ./:/home/agent/disinto:ro + - claude-auth:/home/agent/.claude:ro + environment: + FORGE_URL: http://forgejo:3000 + WOODPECKER_SERVER: http://woodpecker:8000 + DISINTO_CONTAINER: "1" + env_file: + - .env + depends_on: + - forgejo + - woodpecker + networks: + - disinto-net + +volumes: + forgejo-data: + woodpecker-data: + agent-data: + project-repos: + claude-auth: + +networks: + disinto-net: + driver: bridge +COMPOSEEOF + + # Patch the forgejo port mapping into the file if non-default + if [ "$forge_port" != "3000" ]; then + # Add port mapping to forgejo service so it's reachable from host during init + sed -i "/image: forgejo\/forgejo:latest/a\\ ports:\\n - \"${forge_port}:3000\"" "$compose_file" + else + sed -i "/image: forgejo\/forgejo:latest/a\\ ports:\\n - \"3000:3000\"" "$compose_file" + fi + + echo "Created: ${compose_file}" +} + +# Generate docker/agents/ files if they don't already exist. +generate_agent_docker() { + local docker_dir="${FACTORY_ROOT}/docker/agents" + mkdir -p "$docker_dir" + + if [ ! -f "${docker_dir}/Dockerfile" ]; then + echo "Warning: docker/agents/Dockerfile not found — expected in repo" >&2 + fi + if [ ! -f "${docker_dir}/entrypoint.sh" ]; then + echo "Warning: docker/agents/entrypoint.sh not found — expected in repo" >&2 + fi +} + +# Check whether compose mode is active (docker-compose.yml exists). +is_compose_mode() { + [ -f "${FACTORY_ROOT}/docker-compose.yml" ] +} + # Provision or connect to a local Forgejo instance. # Creates admin + bot users, generates API tokens, stores in .env. +# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose. setup_forge() { local forge_url="$1" local repo_slug="$2" + local use_bare="${DISINTO_BARE:-false}" echo "" echo "── Forge setup ────────────────────────────────────────" + # Helper: run a command inside the Forgejo container + _forgejo_exec() { + if [ "$use_bare" = true ]; then + docker exec disinto-forgejo "$@" + else + docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T forgejo "$@" + fi + } + # Check if Forgejo is already running if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then echo "Forgejo: ${forge_url} (already running)" @@ -158,29 +283,33 @@ setup_forge() { exit 1 fi - # Create data directory - mkdir -p "${FORGEJO_DATA_DIR}" - # Extract port from forge_url local forge_port forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') forge_port="${forge_port:-3000}" - # Start Forgejo container - if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then - docker start disinto-forgejo >/dev/null 2>&1 || true + if [ "$use_bare" = true ]; then + # Bare-metal mode: standalone docker run + mkdir -p "${FORGEJO_DATA_DIR}" + + if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then + docker start disinto-forgejo >/dev/null 2>&1 || true + else + docker run -d \ + --name disinto-forgejo \ + --restart unless-stopped \ + -p "${forge_port}:3000" \ + -p 2222:22 \ + -v "${FORGEJO_DATA_DIR}:/data" \ + -e "FORGEJO__database__DB_TYPE=sqlite3" \ + -e "FORGEJO__server__ROOT_URL=${forge_url}/" \ + -e "FORGEJO__server__HTTP_PORT=3000" \ + -e "FORGEJO__service__DISABLE_REGISTRATION=true" \ + forgejo/forgejo:latest + fi else - docker run -d \ - --name disinto-forgejo \ - --restart unless-stopped \ - -p "${forge_port}:3000" \ - -p 2222:22 \ - -v "${FORGEJO_DATA_DIR}:/data" \ - -e "FORGEJO__database__DB_TYPE=sqlite3" \ - -e "FORGEJO__server__ROOT_URL=${forge_url}/" \ - -e "FORGEJO__server__HTTP_PORT=3000" \ - -e "FORGEJO__service__DISABLE_REGISTRATION=true" \ - forgejo/forgejo:latest + # Compose mode: start Forgejo via docker compose + docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo fi # Wait for Forgejo to become healthy @@ -206,7 +335,7 @@ setup_forge() { if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then echo "Creating admin user: ${admin_user}" - docker exec disinto-forgejo forgejo admin user create \ + _forgejo_exec forgejo admin user create \ --admin \ --username "${admin_user}" \ --password "${admin_pass}" \ @@ -241,7 +370,7 @@ setup_forge() { -H "Authorization: token ${admin_token}" \ "${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then echo "Creating bot user: ${bot_user}" - docker exec disinto-forgejo forgejo admin user create \ + _forgejo_exec forgejo admin user create \ --username "${bot_user}" \ --password "${bot_pass}" \ --email "${bot_user}@disinto.local" \ @@ -748,18 +877,22 @@ disinto_init() { shift # Parse flags - local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" + local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false while [ $# -gt 0 ]; do case "$1" in --branch) branch="$2"; shift 2 ;; --repo-root) repo_root="$2"; shift 2 ;; --ci-id) ci_id="$2"; shift 2 ;; --forge-url) forge_url_flag="$2"; shift 2 ;; + --bare) bare=true; shift ;; --yes) auto_yes=true; shift ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done + # Export bare-metal flag for setup_forge + export DISINTO_BARE="$bare" + # Extract org/repo slug local forge_repo forge_repo=$(parse_repo_slug "$repo_url") @@ -829,6 +962,15 @@ p.write_text(text) fi fi + # Generate compose files (unless --bare) + if [ "$bare" = false ]; then + local forge_port + forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') + forge_port="${forge_port:-3000}" + generate_compose "$forge_port" + generate_agent_docker + fi + # Set up local Forgejo instance (provision if needed, create users/tokens/repo) setup_forge "$forge_url" "$forge_repo" @@ -919,11 +1061,24 @@ p.write_text(text) # Encrypt secrets if SOPS + age are available write_secrets_encrypted + # Bring up the full stack (compose mode only) + if [ "$bare" = false ] && [ -f "${FACTORY_ROOT}/docker-compose.yml" ]; then + echo "" + echo "── Starting full stack ────────────────────────────────" + docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d + echo "Stack: running (forgejo + woodpecker + agents)" + fi + echo "" echo "Done. Project ${project_name} is ready." echo " Config: ${toml_path}" echo " Clone: ${repo_root}" echo " Forge: ${forge_url}/${forge_repo}" + if [ "$bare" = false ]; then + echo " Stack: docker compose (use 'disinto up/down/logs/shell')" + else + echo " Mode: bare-metal" + fi echo " Run 'disinto status' to verify." } @@ -1054,10 +1209,79 @@ disinto_secrets() { esac } +# ── up command ──────────────────────────────────────────────────────────────── + +disinto_up() { + local compose_file="${FACTORY_ROOT}/docker-compose.yml" + if [ ! -f "$compose_file" ]; then + echo "Error: docker-compose.yml not found" >&2 + echo " Run 'disinto init ' first (without --bare)" >&2 + exit 1 + fi + + # Decrypt secrets to temp .env if SOPS available and .env.enc exists + local tmp_env="" + local enc_file="${FACTORY_ROOT}/.env.enc" + local env_file="${FACTORY_ROOT}/.env" + if [ -f "$enc_file" ] && command -v sops &>/dev/null && [ ! -f "$env_file" ]; then + tmp_env="${env_file}" + sops -d --output-type dotenv "$enc_file" > "$tmp_env" + trap '[ -n "${tmp_env:-}" ] && rm -f "$tmp_env"' EXIT + echo "Decrypted secrets for compose" + fi + + docker compose -f "$compose_file" up -d "$@" + echo "Stack is up" + + # Clean up temp .env (also handled by EXIT trap if compose fails) + if [ -n "$tmp_env" ] && [ -f "$tmp_env" ]; then + rm -f "$tmp_env" + echo "Removed temporary .env" + fi +} + +# ── down command ────────────────────────────────────────────────────────────── + +disinto_down() { + local compose_file="${FACTORY_ROOT}/docker-compose.yml" + if [ ! -f "$compose_file" ]; then + echo "Error: docker-compose.yml not found" >&2 + exit 1 + fi + docker compose -f "$compose_file" down "$@" + echo "Stack is down" +} + +# ── logs command ────────────────────────────────────────────────────────────── + +disinto_logs() { + local compose_file="${FACTORY_ROOT}/docker-compose.yml" + if [ ! -f "$compose_file" ]; then + echo "Error: docker-compose.yml not found" >&2 + exit 1 + fi + docker compose -f "$compose_file" logs -f "$@" +} + +# ── shell command ───────────────────────────────────────────────────────────── + +disinto_shell() { + local compose_file="${FACTORY_ROOT}/docker-compose.yml" + if [ ! -f "$compose_file" ]; then + echo "Error: docker-compose.yml not found" >&2 + exit 1 + fi + docker compose -f "$compose_file" exec agents bash +} + # ── Main dispatch ──────────────────────────────────────────────────────────── case "${1:-}" in init) shift; disinto_init "$@" ;; + up) shift; disinto_up "$@" ;; + down) shift; disinto_down "$@" ;; + logs) shift; disinto_logs "$@" ;; + shell) shift; disinto_shell ;; status) shift; disinto_status "$@" ;; secrets) shift; disinto_secrets "$@" ;; -h|--help) usage ;; diff --git a/docker/agents/Dockerfile b/docker/agents/Dockerfile new file mode 100644 index 0000000..18cc7f2 --- /dev/null +++ b/docker/agents/Dockerfile @@ -0,0 +1,22 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash curl git jq tmux cron python3 openssh-client ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Claude CLI — install and verify +RUN curl -fsSL https://cli.anthropic.com/install.sh | sh \ + && cp "$(find /root -name claude -type f 2>/dev/null | head -1)" /usr/local/bin/claude \ + && claude --version + +# Non-root user +RUN useradd -m -u 1000 -s /bin/bash agent + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Entrypoint runs as root to start the cron daemon; +# cron jobs execute as the agent user (crontab -u agent). +WORKDIR /home/agent + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/agents/entrypoint.sh b/docker/agents/entrypoint.sh new file mode 100644 index 0000000..ce36e89 --- /dev/null +++ b/docker/agents/entrypoint.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +# entrypoint.sh — Start agent container with cron in foreground +# +# Runs as root inside the container. Installs crontab entries for the +# agent user from project TOMLs, then starts cron in the foreground. +# All cron jobs execute as the agent user (UID 1000). + +DISINTO_DIR="/home/agent/disinto" +LOGFILE="/home/agent/data/agent-entrypoint.log" +mkdir -p /home/agent/data +chown agent:agent /home/agent/data + +log() { + printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE" +} + +# Build crontab from project TOMLs and install for the agent user. +install_project_crons() { + local cron_lines="" + for toml in "${DISINTO_DIR}"/projects/*.toml; do + [ -f "$toml" ] || continue + local pname + pname=$(python3 -c " +import sys, tomllib +with open(sys.argv[1], 'rb') as f: + print(tomllib.load(f)['name']) +" "$toml" 2>/dev/null) || continue + + cron_lines="${cron_lines} +# disinto: ${pname} +2,7,12,17,22,27,32,37,42,47,52,57 * * * * ${DISINTO_DIR}/review/review-poll.sh ${toml} >/dev/null 2>&1 +4,9,14,19,24,29,34,39,44,49,54,59 * * * * ${DISINTO_DIR}/dev/dev-poll.sh ${toml} >/dev/null 2>&1 +0 0,6,12,18 * * * cd ${DISINTO_DIR} && bash gardener/gardener-run.sh ${toml} >/dev/null 2>&1" + done + + if [ -n "$cron_lines" ]; then + printf '%s\n' "$cron_lines" | crontab -u agent - + log "Installed crontab for agent user" + else + log "No project TOMLs found — crontab empty" + fi +} + +log "Agent container starting" +install_project_crons + +# Run cron in the foreground. Cron jobs execute as the agent user. +log "Starting cron daemon" +exec cron -f diff --git a/lib/env.sh b/lib/env.sh index f1b0bc8..72a8e0a 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -7,6 +7,14 @@ set -euo pipefail # Resolve script root (parent of lib/) FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Container detection: when running inside the agent container, DISINTO_CONTAINER +# is set by docker-compose.yml. Adjust paths so phase files, logs, and thread +# maps land on the persistent volume instead of /tmp (which is ephemeral). +if [ "${DISINTO_CONTAINER:-}" = "1" ]; then + DISINTO_DATA_DIR="${HOME}/data" + mkdir -p "${DISINTO_DATA_DIR}" +fi + # Load secrets: prefer .env.enc (SOPS-encrypted), fall back to plaintext .env if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then set -a @@ -123,7 +131,12 @@ wpdb() { # Matrix messaging helper — usage: matrix_send [thread_event_id] [context_tag] # Returns event_id on stdout. Registers threads for listener dispatch. # context_tag is stored in the thread map (e.g. issue number) for routing replies. -MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" +# Thread map: use persistent data dir inside container, /tmp on bare metal +if [ "${DISINTO_CONTAINER:-}" = "1" ]; then + MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-${DISINTO_DATA_DIR}/matrix-thread-map}" +else + MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" +fi matrix_send() { [ -z "${MATRIX_TOKEN:-}" ] && return 0 local prefix="$1" msg="$2" thread_id="${3:-}" ctx_tag="${4:-}"