Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,16 @@ jobs:
run: docker version && docker compose version
- name: Login context notice
run: bash scripts/e2e/login-context.sh

e2e-runtime-volumes-ssh:
name: E2E (Runtime volumes + SSH)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/setup
- name: Docker info
run: docker version && docker compose version
- name: Runtime volumes + host SSH CLI
run: bash scripts/e2e/runtime-volumes-ssh.sh
60 changes: 41 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,71 @@
# docker-git

`docker-git` создаёт отдельную Docker-среду для каждого репозитория, issue или PR.
По умолчанию проекты лежат в `~/.docker-git`.

Теперь есть API-first controller mode:
- хосту нужен только Docker
- поднимается `docker-git-api` controller container
- его state живёт в Docker volume `docker-git-projects`
- controller через Docker API создаёт и обслуживает дочерние project containers
- снаружи ты общаешься с системой через HTTP API или `./ctl`

## Что нужно

- Docker Engine или Docker Desktop
- Для controller mode: Docker Engine или Docker Desktop
- Доступ к Docker без `sudo`
- Node.js и `npm`
- Node.js и `npm` нужны только для legacy host CLI mode

## Установка
## API Controller Mode

```bash
npm i -g @prover-coder-ai/docker-git
docker-git --help
./ctl up
./ctl health
./ctl projects
```

## Авторизация
API публикуется на `http://127.0.0.1:3334` по умолчанию.

```bash
docker-git auth github login --web
docker-git auth codex login --web
docker-git auth claude login --web
./ctl request GET /projects
./ctl request POST /projects '{"repoUrl":"https://github.com/ProverCoderAI/docker-git.git","repoRef":"main"}'
```

Важно:
- `./ctl` не требует `curl`, `node` или `pnpm` на хосте
- запросы к API выполняются через `curl` внутри controller container
- `.docker-git` больше не обязан лежать на host filesystem: controller хранит его в Docker volume

## Legacy Host CLI

```bash
npm i -g @prover-coder-ai/docker-git
docker-git --help
```

## Пример

Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR.
Через API controller можно создать проект и потом поднять его отдельно:

```bash
docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --mcp-playwright
./ctl request POST /projects '{"repoUrl":"https://github.com/ProverCoderAI/docker-git.git","repoRef":"main","up":false}'
./ctl projects
```

- `--force` пересоздаёт окружение и удаляет volumes проекта.
- `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации.
API возвращает `projectId`, после чего можно:

```bash
./ctl request POST /projects/<projectId>/up
./ctl request GET /projects/<projectId>/logs
./ctl request POST /projects/<projectId>/down
```

Автоматический запуск агента:
## Проверка Docker runtime

```bash
docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --auto
pnpm run e2e:runtime-volumes-ssh
```

- `--auto` сам выбирает Claude или Codex по доступной авторизации. Если доступны оба, выбор случайный.
- `--auto=claude` или `--auto=codex` принудительно выбирает агента.
- В auto-режиме агент сам выполняет задачу, создаёт PR и после завершения контейнер очищается.
Сценарий доказывает, что контейнер стартует через Docker, runtime state живёт в named volumes, а SSH реально заходит в дочерний project container.

## Подробности

Expand Down
214 changes: 178 additions & 36 deletions ctl
Original file line number Diff line number Diff line change
@@ -1,52 +1,188 @@
#!/usr/bin/env bash
# CHANGE: provide a minimal local orchestrator for the dev container and auth helpers
# WHY: single command to manage the container and login flows
# QUOTE(TZ): "команда с помощью которой можно полностью контролировать этими докер образами"
# REF: user-request-2026-01-07
# CHANGE: control the API-first docker-git controller container from the host
# WHY: host should only need Docker while all orchestration runs inside the API controller
# QUOTE(TZ): "Поднимается сервер и ты через него можешь общаться с контейнером"
# REF: user-request-2026-03-15-api-controller
# SOURCE: n/a
# FORMAT THEOREM: forall cmd: valid(cmd) -> action(cmd) terminates
# FORMAT THEOREM: forall cmd: valid(cmd) -> controller_action(cmd) terminates
# PURITY: SHELL
# EFFECT: Effect<IO, Error, Env>
# INVARIANT: uses repo-local docker-compose.yml and dev-ssh container
# COMPLEXITY: O(1)
# INVARIANT: every API request is executed from inside the controller container; host does not need curl/node/pnpm
# COMPLEXITY: O(1) + network/docker
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
CONTAINER_NAME="dev-ssh"
SSH_KEY="$ROOT/dev_ssh_key"
SSH_PORT="2222"
SSH_USER="dev"
SSH_HOST="localhost"
CONTAINER_NAME="docker-git-api"
API_PORT="${DOCKER_GIT_API_PORT:-3334}"
API_HOST="${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}"
API_BASE_URL="http://127.0.0.1:${API_PORT}"
DOCKER_CMD=()

usage() {
cat <<'USAGE'
Usage: ./ctl <command>

Container:
up Build and start the container
down Stop and remove the container
ps Show container status
logs Tail logs
restart Restart the container
exec Shell into the container
ssh SSH into the container
Controller:
up Build and start the API controller
down Stop and remove the API controller
ps Show controller status
logs Tail controller logs
restart Restart the controller
shell Open a shell inside the controller
url Print the published API URL
health GET /health through curl running inside the controller

Codex auth:
codex-login Device-code login flow (headless-friendly)
codex-status Show auth status (exit 0 when logged in)
codex-logout Remove cached credentials
API:
projects GET /projects
request request <METHOD> <PATH> [JSON_BODY]
examples:
./ctl request GET /projects
./ctl request POST /projects '{"repoUrl":"https://github.com/org/repo.git"}'
./ctl request POST /projects/<projectId>/up

USAGE
}

compose() {
docker compose -f "$COMPOSE_FILE" "$@"
"${DOCKER_CMD[@]}" compose -f "$COMPOSE_FILE" "$@"
}

require_running() {
if ! "${DOCKER_CMD[@]}" ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then
echo "Controller is not running. Start it with: ./ctl up" >&2
exit 1
fi
}

api_exec() {
"${DOCKER_CMD[@]}" exec "$CONTAINER_NAME" "$@"
}

normalize_api_path() {
local raw_path="$1"

if [[ "$raw_path" != /projects/* ]]; then
printf '%s' "$raw_path"
return
fi

local normalized
normalized="$("${DOCKER_CMD[@]}" exec -i "$CONTAINER_NAME" node - "$raw_path" <<'NODE'
const raw = process.argv[2] ?? ""
const [pathname, query = ""] = raw.split(/\?(.*)/s, 2)
const prefix = "/projects/"

const joinWithQuery = (path) => query.length > 0 ? `${path}?${query}` : path
const encodeProjectPath = (projectId, suffix = "") =>
joinWithQuery(`${prefix}${encodeURIComponent(projectId)}${suffix}`)

if (!pathname.startsWith(prefix)) {
process.stdout.write(raw)
process.exit(0)
}

const remainder = pathname.slice(prefix.length)
if (!remainder.startsWith("/")) {
process.stdout.write(raw)
process.exit(0)
}

const patterns = [
{
regex: /^(.*)\/agents\/([^/]+)\/(attach|stop|logs)$/u,
render: ([, projectId, agentId, action]) =>
encodeProjectPath(projectId, `/agents/${encodeURIComponent(agentId)}/${action}`)
},
{
regex: /^(.*)\/agents\/([^/]+)$/u,
render: ([, projectId, agentId]) =>
encodeProjectPath(projectId, `/agents/${encodeURIComponent(agentId)}`)
},
{
regex: /^(.*)\/agents$/u,
render: ([, projectId]) => encodeProjectPath(projectId, "/agents")
},
{
regex: /^(.*)\/(up|down|recreate|ps|logs|events)$/u,
render: ([, projectId, action]) => encodeProjectPath(projectId, `/${action}`)
},
{
regex: /^(.*)$/u,
render: ([, projectId]) => encodeProjectPath(projectId)
}
]

for (const { regex, render } of patterns) {
const match = remainder.match(regex)
if (match !== null) {
process.stdout.write(render(match))
process.exit(0)
}
}

process.stdout.write(raw)
NODE
)"
printf '%s' "$normalized"
}

api_request() {
local method="$1"
local path="$2"
local body="${3:-}"

require_running
local normalized_path
normalized_path="$(normalize_api_path "$path")"

if [[ -n "$body" ]]; then
printf '%s' "$body" | "${DOCKER_CMD[@]}" exec -i "$CONTAINER_NAME" sh -lc \
"curl -fsS -X '$method' '$API_BASE_URL$normalized_path' -H 'content-type: application/json' --data-binary @-"
printf '\n'
return
fi

"${DOCKER_CMD[@]}" exec "$CONTAINER_NAME" sh -lc "curl -fsS -X '$method' '$API_BASE_URL$normalized_path'"
printf '\n'
}

wait_for_health() {
require_running
local attempts=30
local delay_seconds=2
local attempt=1
while (( attempt <= attempts )); do
if "${DOCKER_CMD[@]}" exec "$CONTAINER_NAME" sh -lc "curl -fsS '$API_BASE_URL/health' >/dev/null"; then
return 0
fi
sleep "$delay_seconds"
attempt=$((attempt + 1))
done

echo "Controller did not become healthy in time." >&2
return 1
}

resolve_docker_cmd() {
if docker info >/dev/null 2>&1; then
DOCKER_CMD=(docker)
return
fi
if sudo -n docker info >/dev/null 2>&1; then
DOCKER_CMD=(sudo docker)
return
fi
DOCKER_CMD=(docker)
}

resolve_docker_cmd

case "${1:-}" in
up)
compose up -d --build
wait_for_health
echo "Controller API: http://${API_HOST}:${API_PORT}"
;;
down)
compose down
Expand All @@ -59,21 +195,27 @@ case "${1:-}" in
;;
restart)
compose restart
wait_for_health
;;
exec)
docker exec -it "$CONTAINER_NAME" bash
shell)
require_running
"${DOCKER_CMD[@]}" exec -it "$CONTAINER_NAME" bash
;;
ssh)
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST"
url)
echo "http://${API_HOST}:${API_PORT}"
;;
codex-login)
docker exec -it "$CONTAINER_NAME" codex login --device-auth
health)
api_request GET /health
;;
codex-status)
docker exec "$CONTAINER_NAME" codex login status
projects)
api_request GET /projects
;;
codex-logout)
docker exec -it "$CONTAINER_NAME" codex logout
request)
if [[ $# -lt 3 ]]; then
echo "Usage: ./ctl request <METHOD> <PATH> [JSON_BODY]" >&2
exit 1
fi
api_request "$2" "$3" "${4:-}"
;;
help|--help|-h|"")
usage
Expand All @@ -83,4 +225,4 @@ case "${1:-}" in
usage >&2
exit 1
;;
esac
esac
7 changes: 6 additions & 1 deletion docker-compose.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ services:
environment:
DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334}
DOCKER_GIT_PROJECTS_ROOT: ${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects}
DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: ${DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN:-}
DOCKER_GIT_FEDERATION_ACTOR: ${DOCKER_GIT_FEDERATION_ACTOR:-docker-git}
ports:
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${DOCKER_GIT_PROJECTS_ROOT_HOST:-/home/dev/.docker-git}:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
- docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
restart: unless-stopped

volumes:
docker_git_projects:
name: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects}
Loading
Loading