11#! /usr/bin/env bash
2- # CHANGE: provide a minimal local orchestrator for the dev container and auth helpers
3- # WHY: single command to manage the container and login flows
4- # QUOTE(TZ): "команда с помощью которой можно полностью контролировать этими докер образами "
5- # REF: user-request-2026-01-07
2+ # CHANGE: control the API-first docker-git controller container from the host
3+ # WHY: host should only need Docker while all orchestration runs inside the API controller
4+ # QUOTE(TZ): "Поднимается сервер и ты через него можешь общаться с контейнером "
5+ # REF: user-request-2026-03-15-api-controller
66# SOURCE: n/a
7- # FORMAT THEOREM: forall cmd: valid(cmd) -> action (cmd) terminates
7+ # FORMAT THEOREM: forall cmd: valid(cmd) -> controller_action (cmd) terminates
88# PURITY: SHELL
99# EFFECT: Effect<IO, Error, Env>
10- # INVARIANT: uses repo-local docker-compose.yml and dev-ssh container
11- # COMPLEXITY: O(1)
10+ # INVARIANT: every API request is executed from inside the controller container; host does not need curl/node/pnpm
11+ # COMPLEXITY: O(1) + network/docker
1212set -euo pipefail
1313
1414ROOT=" $( cd " $( dirname " ${BASH_SOURCE[0]} " ) " && pwd) "
1515COMPOSE_FILE=" $ROOT /docker-compose.yml"
16- CONTAINER_NAME=" dev-ssh "
17- SSH_KEY =" $ROOT /dev_ssh_key "
18- SSH_PORT= " 2222 "
19- SSH_USER= " dev "
20- SSH_HOST= " localhost "
16+ CONTAINER_NAME=" docker-git-api "
17+ API_PORT =" ${DOCKER_GIT_API_PORT :- 3334} "
18+ API_HOST= " ${DOCKER_GIT_API_BIND_HOST :- 127.0.0.1} "
19+ API_BASE_URL= " http://127.0.0.1: ${API_PORT} "
20+ DOCKER_CMD=()
2121
2222usage () {
2323 cat << 'USAGE '
2424Usage: ./ctl <command>
2525
26- Container:
27- up Build and start the container
28- down Stop and remove the container
29- ps Show container status
30- logs Tail logs
31- restart Restart the container
32- exec Shell into the container
33- ssh SSH into the container
26+ Controller:
27+ up Build and start the API controller
28+ down Stop and remove the API controller
29+ ps Show controller status
30+ logs Tail controller logs
31+ restart Restart the controller
32+ shell Open a shell inside the controller
33+ url Print the published API URL
34+ health GET /health through curl running inside the controller
3435
35- Codex auth:
36- codex-login Device-code login flow (headless-friendly)
37- codex-status Show auth status (exit 0 when logged in)
38- codex-logout Remove cached credentials
36+ API:
37+ projects GET /projects
38+ request request <METHOD> <PATH> [JSON_BODY]
39+ examples:
40+ ./ctl request GET /projects
41+ ./ctl request POST /projects '{"repoUrl":"https://github.com/org/repo.git"}'
42+ ./ctl request POST /projects/<projectId>/up
3943
4044USAGE
4145}
4246
4347compose () {
44- docker compose -f " $COMPOSE_FILE " " $@ "
48+ " ${DOCKER_CMD[@]} " compose -f " $COMPOSE_FILE " " $@ "
4549}
4650
51+ require_running () {
52+ if ! " ${DOCKER_CMD[@]} " ps --format ' {{.Names}}' | grep -Fxq " $CONTAINER_NAME " ; then
53+ echo " Controller is not running. Start it with: ./ctl up" >&2
54+ exit 1
55+ fi
56+ }
57+
58+ api_exec () {
59+ " ${DOCKER_CMD[@]} " exec " $CONTAINER_NAME " " $@ "
60+ }
61+
62+ normalize_api_path () {
63+ local raw_path=" $1 "
64+
65+ if [[ " $raw_path " != /projects/* ]]; then
66+ printf ' %s' " $raw_path "
67+ return
68+ fi
69+
70+ local normalized
71+ normalized=" $( " ${DOCKER_CMD[@]} " exec -i " $CONTAINER_NAME " node - " $raw_path " << 'NODE '
72+ const raw = process.argv[2] ?? ""
73+ const [pathname, query = ""] = raw.split(/\?(.*)/s, 2)
74+ const prefix = "/projects/"
75+
76+ const joinWithQuery = (path) => query.length > 0 ? `${path}?${query}` : path
77+ const encodeProjectPath = (projectId, suffix = "") =>
78+ joinWithQuery(`${prefix}${encodeURIComponent(projectId)}${suffix}`)
79+
80+ if (!pathname.startsWith(prefix)) {
81+ process.stdout.write(raw)
82+ process.exit(0)
83+ }
84+
85+ const remainder = pathname.slice(prefix.length)
86+ if (!remainder.startsWith("/")) {
87+ process.stdout.write(raw)
88+ process.exit(0)
89+ }
90+
91+ const patterns = [
92+ {
93+ regex: /^(.*)\/agents\/([^/]+)\/(attach|stop|logs)$/u,
94+ render: ([, projectId, agentId, action]) =>
95+ encodeProjectPath(projectId, `/agents/${encodeURIComponent(agentId)}/${action}`)
96+ },
97+ {
98+ regex: /^(.*)\/agents\/([^/]+)$/u,
99+ render: ([, projectId, agentId]) =>
100+ encodeProjectPath(projectId, `/agents/${encodeURIComponent(agentId)}`)
101+ },
102+ {
103+ regex: /^(.*)\/agents$/u,
104+ render: ([, projectId]) => encodeProjectPath(projectId, "/agents")
105+ },
106+ {
107+ regex: /^(.*)\/(up|down|recreate|ps|logs|events)$/u,
108+ render: ([, projectId, action]) => encodeProjectPath(projectId, `/${action}`)
109+ },
110+ {
111+ regex: /^(.*)$/u,
112+ render: ([, projectId]) => encodeProjectPath(projectId)
113+ }
114+ ]
115+
116+ for (const { regex, render } of patterns) {
117+ const match = remainder.match(regex)
118+ if (match !== null) {
119+ process.stdout.write(render(match))
120+ process.exit(0)
121+ }
122+ }
123+
124+ process.stdout.write(raw)
125+ NODE
126+ ) "
127+ printf ' %s' " $normalized "
128+ }
129+
130+ api_request () {
131+ local method=" $1 "
132+ local path=" $2 "
133+ local body=" ${3:- } "
134+
135+ require_running
136+ local normalized_path
137+ normalized_path=" $( normalize_api_path " $path " ) "
138+
139+ if [[ -n " $body " ]]; then
140+ printf ' %s' " $body " | " ${DOCKER_CMD[@]} " exec -i " $CONTAINER_NAME " sh -lc \
141+ " curl -fsS -X '$method ' '$API_BASE_URL$normalized_path ' -H 'content-type: application/json' --data-binary @-"
142+ printf ' \n'
143+ return
144+ fi
145+
146+ " ${DOCKER_CMD[@]} " exec " $CONTAINER_NAME " sh -lc " curl -fsS -X '$method ' '$API_BASE_URL$normalized_path '"
147+ printf ' \n'
148+ }
149+
150+ wait_for_health () {
151+ require_running
152+ local attempts=30
153+ local delay_seconds=2
154+ local attempt=1
155+ while (( attempt <= attempts )) ; do
156+ if " ${DOCKER_CMD[@]} " exec " $CONTAINER_NAME " sh -lc " curl -fsS '$API_BASE_URL /health' >/dev/null" ; then
157+ return 0
158+ fi
159+ sleep " $delay_seconds "
160+ attempt=$(( attempt + 1 ))
161+ done
162+
163+ echo " Controller did not become healthy in time." >&2
164+ return 1
165+ }
166+
167+ resolve_docker_cmd () {
168+ if docker info > /dev/null 2>&1 ; then
169+ DOCKER_CMD=(docker)
170+ return
171+ fi
172+ if sudo -n docker info > /dev/null 2>&1 ; then
173+ DOCKER_CMD=(sudo docker)
174+ return
175+ fi
176+ DOCKER_CMD=(docker)
177+ }
178+
179+ resolve_docker_cmd
180+
47181case " ${1:- } " in
48182 up)
49183 compose up -d --build
184+ wait_for_health
185+ echo " Controller API: http://${API_HOST} :${API_PORT} "
50186 ;;
51187 down)
52188 compose down
@@ -59,21 +195,27 @@ case "${1:-}" in
59195 ;;
60196 restart)
61197 compose restart
198+ wait_for_health
62199 ;;
63- exec)
64- docker exec -it " $CONTAINER_NAME " bash
200+ shell)
201+ require_running
202+ " ${DOCKER_CMD[@]} " exec -it " $CONTAINER_NAME " bash
65203 ;;
66- ssh )
67- ssh -i " $SSH_KEY " -p " $SSH_PORT " " $SSH_USER @ $SSH_HOST "
204+ url )
205+ echo " http:// ${API_HOST} : ${API_PORT} "
68206 ;;
69- codex-login )
70- docker exec -it " $CONTAINER_NAME " codex login --device-auth
207+ health )
208+ api_request GET /health
71209 ;;
72- codex-status )
73- docker exec " $CONTAINER_NAME " codex login status
210+ projects )
211+ api_request GET /projects
74212 ;;
75- codex-logout)
76- docker exec -it " $CONTAINER_NAME " codex logout
213+ request)
214+ if [[ $# -lt 3 ]]; then
215+ echo " Usage: ./ctl request <METHOD> <PATH> [JSON_BODY]" >&2
216+ exit 1
217+ fi
218+ api_request " $2 " " $3 " " ${4:- } "
77219 ;;
78220 help|--help|-h|" " )
79221 usage
@@ -83,4 +225,4 @@ case "${1:-}" in
83225 usage >&2
84226 exit 1
85227 ;;
86- esac
228+ esac
0 commit comments