diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 26e06470..4afb36a1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -136,3 +136,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 diff --git a/README.md b/README.md index 48a0eb0e..2d5c1b5f 100644 --- a/README.md +++ b/README.md @@ -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//up +./ctl request GET /projects//logs +./ctl request POST /projects//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. ## Подробности diff --git a/ctl b/ctl index 6dcb70a8..5e2b59f3 100755 --- a/ctl +++ b/ctl @@ -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 -# 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 -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 [JSON_BODY] + examples: + ./ctl request GET /projects + ./ctl request POST /projects '{"repoUrl":"https://github.com/org/repo.git"}' + ./ctl request POST /projects//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 @@ -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 [JSON_BODY]" >&2 + exit 1 + fi + api_request "$2" "$3" "${4:-}" ;; help|--help|-h|"") usage @@ -83,4 +225,4 @@ case "${1:-}" in usage >&2 exit 1 ;; - esac +esac diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 4f68d18e..82828bda 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -7,11 +7,20 @@ 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}" + dns: + - 8.8.8.8 + - 8.8.4.4 + - 1.1.1.1 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} diff --git a/docker-compose.yml b/docker-compose.yml index e297fde4..82828bda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,26 @@ services: - dev: - build: . - container_name: dev-ssh + api: + build: + context: . + dockerfile: packages/api/Dockerfile + container_name: docker-git-api environment: - REPO_URL: "https://github.com/ProverCoderAI/eslint-plugin-suggest-members.git" - REPO_REF: "main" - TARGET_DIR: "/home/dev/app" - CODEX_HOME: "/home/dev/.codex" + 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: - - "127.0.0.1:2222:22" + - "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}" dns: - 8.8.8.8 - 8.8.4.4 - 1.1.1.1 volumes: - - dev_home:/home/dev - - ./authorized_keys:/authorized_keys:ro - - ./.orch/auth/codex:/home/dev/.codex + - /var/run/docker.sock:/var/run/docker.sock + - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + restart: unless-stopped volumes: - dev_home: + docker_git_projects: + name: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} diff --git a/package.json b/package.json index e5c7b676..7ae1aa64 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "e2e": "bash scripts/e2e/run-all.sh", "e2e:clone-cache": "bash scripts/e2e/clone-cache.sh", "e2e:login-context": "bash scripts/e2e/login-context.sh", + "e2e:runtime-volumes-ssh": "bash scripts/e2e/runtime-volumes-ssh.sh", "e2e:opencode-autoconnect": "bash scripts/e2e/opencode-autoconnect.sh", "list": "pnpm --filter ./packages/app build && node packages/app/dist/main.js list", "dev": "pnpm --filter ./packages/app dev", diff --git a/packages/api/README.md b/packages/api/README.md index 1875623a..6be1d4b5 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -2,6 +2,12 @@ HTTP API for docker-git orchestration (projects, agents, logs/events, federation). +This is now the intended controller plane: +- the API runs inside `docker-git-api` +- `.docker-git` state lives in the Docker volume `docker-git-projects` +- the API talks to Docker through `/var/run/docker.sock` +- child project containers no longer depend on host bind mounts for bootstrap auth/env + ## UI wrapper After API startup open: @@ -22,8 +28,8 @@ pnpm --filter ./packages/api start From repository root: ```bash -docker compose -f docker-compose.api.yml up -d --build -curl -s http://127.0.0.1:3334/health +docker compose up -d --build +./ctl health ``` Default port mapping: @@ -35,8 +41,8 @@ Optional env: - `DOCKER_GIT_API_BIND_HOST` (default: `127.0.0.1`) - `DOCKER_GIT_API_PORT` (default: `3334`) -- `DOCKER_GIT_PROJECTS_ROOT_HOST` (host path with docker-git projects, default: `/home/dev/.docker-git`) - `DOCKER_GIT_PROJECTS_ROOT` (container path, default: `/home/dev/.docker-git`) +- `DOCKER_GIT_PROJECTS_ROOT_VOLUME` (Docker volume name for controller state, default: `docker-git-projects`) - `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub origin) - `DOCKER_GIT_FEDERATION_ACTOR` (default: `docker-git`) @@ -74,20 +80,18 @@ Optional env: 1. Read actor profile (contains `inbox/outbox/followers/following/liked`): ```bash -curl -s http://127.0.0.1:3334/federation/actor +./ctl request GET /federation/actor ``` 2. Create follow subscription: ```bash -curl -sS -X POST http://127.0.0.1:3334/federation/follows \ - -H 'content-type: application/json' \ - -d '{ - "domain":"https://social.provercoder.ai", - "actor":"https://dev.example/users/bot", - "object":"https://tracker.example/issues/followers", - "capability":"https://tracker.example/caps/follow" - }' +./ctl request POST /federation/follows '{ + "domain":"https://social.provercoder.ai", + "actor":"https://dev.example/users/bot", + "object":"https://tracker.example/issues/followers", + "capability":"https://tracker.example/caps/follow" +}' ``` `domain` is used as public origin. `.example` hosts in `actor/object/capability` are normalized to that domain. @@ -95,45 +99,41 @@ curl -sS -X POST http://127.0.0.1:3334/federation/follows \ 3. Confirm subscription by sending `Accept` into inbox: ```bash -curl -sS -X POST http://127.0.0.1:3334/federation/inbox \ - -H 'content-type: application/json' \ - -d '{ - "@context":"https://www.w3.org/ns/activitystreams", - "type":"Accept", - "object":"https://social.provercoder.ai/federation/activities/follows/" - }' +./ctl request POST /federation/inbox '{ + "@context":"https://www.w3.org/ns/activitystreams", + "type":"Accept", + "object":"https://social.provercoder.ai/federation/activities/follows/" +}' ``` 4. Verify follow state and collections: ```bash -curl -s http://127.0.0.1:3334/federation/follows -curl -s http://127.0.0.1:3334/federation/following -curl -s http://127.0.0.1:3334/federation/outbox +./ctl request GET /federation/follows +./ctl request GET /federation/following +./ctl request GET /federation/outbox ``` 5. Push issue offer through ForgeFed inbox: ```bash -curl -sS -X POST http://127.0.0.1:3334/federation/inbox \ - -H 'content-type: application/json' \ - -d '{ - "@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"], - "id":"https://social.provercoder.ai/offers/42", - "type":"Offer", - "target":"https://social.provercoder.ai/issues", - "object":{ - "type":"Ticket", - "id":"https://social.provercoder.ai/issues/42", - "attributedTo":"https://origin.provercoder.ai/users/alice", - "summary":"Need reproducible CI parity", - "content":"Implement API behavior matching CLI." - } - }' +./ctl request POST /federation/inbox '{ + "@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"], + "id":"https://social.provercoder.ai/offers/42", + "type":"Offer", + "target":"https://social.provercoder.ai/issues", + "object":{ + "type":"Ticket", + "id":"https://social.provercoder.ai/issues/42", + "attributedTo":"https://origin.provercoder.ai/users/alice", + "summary":"Need reproducible CI parity", + "content":"Implement API behavior matching CLI." + } +}' ``` 6. Verify persisted issues: ```bash -curl -s http://127.0.0.1:3334/federation/issues +./ctl request GET /federation/issues ``` diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 76041536..31d82fc6 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -28,6 +28,38 @@ export type ProjectDetails = ProjectSummary & { readonly clonedOnHostname?: string | undefined } +export type GithubAuthTokenStatus = { + readonly key: string + readonly label: string + readonly status: "valid" | "invalid" | "unknown" + readonly login: string | null +} + +export type GithubAuthStatus = { + readonly summary: string + readonly tokens: ReadonlyArray +} + +export type GithubAuthLoginRequest = { + readonly label?: string | null | undefined + readonly token?: string | null | undefined + readonly scopes?: string | null | undefined +} + +export type GithubAuthLogoutRequest = { + readonly label?: string | null | undefined +} + +export type ApplyAllRequest = { + readonly activeOnly?: boolean | undefined +} + +export type ApiAuthRequired = { + readonly provider: "github" + readonly message: string + readonly command: string +} + export type CreateProjectRequest = { readonly repoUrl?: string | undefined readonly repoRef?: string | undefined @@ -57,6 +89,7 @@ export type CreateProjectRequest = { readonly openSsh?: boolean | undefined readonly force?: boolean | undefined readonly forceEnv?: boolean | undefined + readonly waitForClone?: boolean | undefined } export type AgentEnvVar = { diff --git a/packages/api/src/api/errors.ts b/packages/api/src/api/errors.ts index 9d59a550..a4013e11 100644 --- a/packages/api/src/api/errors.ts +++ b/packages/api/src/api/errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect" +export class ApiAuthRequiredError extends Data.TaggedError("ApiAuthRequiredError")<{ + readonly provider: "github" + readonly message: string + readonly command: string +}> {} + export class ApiBadRequestError extends Data.TaggedError("ApiBadRequestError")<{ readonly message: string readonly details?: unknown @@ -19,6 +25,7 @@ export class ApiInternalError extends Data.TaggedError("ApiInternalError")<{ }> {} export type ApiKnownError = + | ApiAuthRequiredError | ApiBadRequestError | ApiNotFoundError | ApiConflictError diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index eadcd700..bdb51ffe 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema" const OptionalString = Schema.optional(Schema.String) const OptionalBoolean = Schema.optional(Schema.Boolean) +const OptionalNullableString = Schema.optional(Schema.NullOr(Schema.String)) export const CreateProjectRequestSchema = Schema.Struct({ repoUrl: OptionalString, @@ -31,7 +32,22 @@ export const CreateProjectRequestSchema = Schema.Struct({ up: OptionalBoolean, openSsh: OptionalBoolean, force: OptionalBoolean, - forceEnv: OptionalBoolean + forceEnv: OptionalBoolean, + waitForClone: OptionalBoolean +}) + +export const GithubAuthLoginRequestSchema = Schema.Struct({ + label: OptionalNullableString, + token: OptionalNullableString, + scopes: OptionalNullableString +}) + +export const GithubAuthLogoutRequestSchema = Schema.Struct({ + label: OptionalNullableString +}) + +export const ApplyAllRequestSchema = Schema.Struct({ + activeOnly: OptionalBoolean }) export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom") @@ -84,5 +100,8 @@ export const AgentLogLineSchema = Schema.Struct({ }) export type CreateProjectRequestInput = Schema.Schema.Type +export type GithubAuthLoginRequestInput = Schema.Schema.Type +export type GithubAuthLogoutRequestInput = Schema.Schema.Type +export type ApplyAllRequestInput = Schema.Schema.Type export type CreateAgentRequestInput = Schema.Schema.Type export type CreateFollowRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index c92a4a6c..e463a9ae 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -9,9 +9,10 @@ import * as HttpServerError from "@effect/platform/HttpServerError" import * as ParseResult from "effect/ParseResult" import * as Schema from "effect/Schema" -import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" -import { CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema } from "./api/schema.js" +import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" +import { ApplyAllRequestSchema, CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema, GithubAuthLoginRequestSchema, GithubAuthLogoutRequestSchema } from "./api/schema.js" import { uiHtml, uiScript, uiStyles } from "./ui.js" +import { loginGithubAuth, logoutGithubAuth, readGithubAuthStatus } from "./services/auth.js" import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js" import { latestProjectCursor, listProjectEventsSince } from "./services/events.js" import { @@ -27,8 +28,10 @@ import { makeFederationOutboxCollection } from "./services/federation.js" import { + applyAllProjects, createProjectFromRequest, deleteProjectById, + downAllProjects, downProject, getProject, listProjects, @@ -48,6 +51,7 @@ const AgentParamsSchema = Schema.Struct({ }) type ApiError = + | ApiAuthRequiredError | ApiBadRequestError | ApiNotFoundError | ApiConflictError @@ -93,6 +97,20 @@ const errorResponse = (error: ApiError | unknown) => { return jsonResponse({ error: { type: error._tag, message: error.message, details: error.details } }, 400) } + if (error instanceof ApiAuthRequiredError) { + return jsonResponse( + { + error: { + type: error._tag, + message: error.message, + provider: error.provider, + command: error.command + } + }, + 401 + ) + } + if (error instanceof ApiNotFoundError) { return jsonResponse({ error: { type: error._tag, message: error.message } }, 404) } @@ -121,6 +139,9 @@ const agentParams = HttpRouter.schemaParams(AgentParamsSchema) const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreateProjectRequestSchema) const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema) +const readGithubAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLoginRequestSchema) +const readGithubAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLogoutRequestSchema) +const readApplyAllRequest = () => HttpServerRequest.schemaBodyJson(ApplyAllRequestSchema) const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown) const configuredFederationPublicOrigin = @@ -184,6 +205,29 @@ export const makeRouter = () => { HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), HttpRouter.get("/health", jsonResponse({ ok: true }, 200)), + HttpRouter.get( + "/auth/github/status", + Effect.gen(function*(_) { + const status = yield* _(readGithubAuthStatus()) + return yield* _(jsonResponse({ status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/github/login", + Effect.gen(function*(_) { + const request = yield* _(readGithubAuthLoginRequest()) + const status = yield* _(loginGithubAuth(request)) + return yield* _(jsonResponse({ ok: true, status }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/github/logout", + Effect.gen(function*(_) { + const request = yield* _(readGithubAuthLogoutRequest()) + const status = yield* _(logoutGithubAuth(request)) + return yield* _(jsonResponse({ ok: true, status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/federation/issues", Effect.sync(() => ({ issues: listFederationIssues() })).pipe( @@ -274,6 +318,21 @@ export const makeRouter = () => { return yield* _(jsonResponse({ project }, 201)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.post( + "/projects/apply-all", + Effect.gen(function*(_) { + const request = yield* _(readApplyAllRequest()) + yield* _(applyAllProjects(request.activeOnly ?? false)) + return yield* _(jsonResponse({ ok: true }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/projects/down-all", + downAllProjects().pipe( + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), HttpRouter.get( "/projects/:projectId", projectParams.pipe( diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts new file mode 100644 index 00000000..00eaedb3 --- /dev/null +++ b/packages/api/src/services/auth.ts @@ -0,0 +1,179 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import type { PlatformError } from "@effect/platform/Error" +import * as Path from "@effect/platform/Path" +import { defaultTemplateConfig } from "@effect-template/lib/core/template-defaults" +import { parseGithubRepoUrl } from "@effect-template/lib/core/repo" +import { authGithubLogin as runGithubLogin, authGithubLogout as runGithubLogout } from "@effect-template/lib/usecases/auth-github" +import { readEnvText } from "@effect-template/lib/usecases/env-file" +import { + githubInvalidTokenMessage, + resolveGithubCloneAuthToken +} from "@effect-template/lib/usecases/github-token-preflight" +import { validateGithubToken, type GithubTokenValidationResult } from "@effect-template/lib/usecases/github-token-validation" +import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers" +import { Effect, Match } from "effect" + +import type { + GithubAuthLoginRequest, + GithubAuthLogoutRequest, + GithubAuthStatus, + GithubAuthTokenStatus +} from "../api/contracts.js" +import { ApiAuthRequiredError } from "../api/errors.js" + +export const githubAuthRequiredCommand = "docker-git auth github login --web" +export const githubAuthRequiredMessage = "GitHub authentication is required. Run: docker-git auth github login --web" +export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath + +const githubTokenKey = "GITHUB_TOKEN" +const githubTokenPrefix = "GITHUB_TOKEN__" + +type GithubTokenEntry = { + readonly key: string + readonly label: string + readonly token: string +} + +const labelFromKey = (key: string): string => + key.startsWith(githubTokenPrefix) ? key.slice(githubTokenPrefix.length) : "default" + +const listGithubTokens = (envText: string): ReadonlyArray => { + const entries: Array = [] + for (const line of envText.split(/\r?\n/u)) { + const trimmed = line.trim() + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue + } + const raw = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed + const eqIndex = raw.indexOf("=") + if (eqIndex <= 0) { + continue + } + const key = raw.slice(0, eqIndex).trim() + const value = raw.slice(eqIndex + 1).trim() + if ((key === githubTokenKey || key.startsWith(githubTokenPrefix)) && value.length > 0) { + entries.push({ + key, + label: labelFromKey(key), + token: value + }) + } + } + return entries +} + +const toTokenStatus = ( + entry: GithubTokenEntry, + validation: GithubTokenValidationResult +): GithubAuthTokenStatus => ({ + key: entry.key, + label: entry.label, + status: validation.status, + login: validation.login +}) + +const buildStatusSummary = (tokens: ReadonlyArray): string => + tokens.length === 0 + ? "GitHub not connected (no tokens)." + : `GitHub tokens (${tokens.length}):` + +const githubAuthError = (message: string): ApiAuthRequiredError => + new ApiAuthRequiredError({ + provider: "github", + message, + command: githubAuthRequiredCommand + }) + +const resolveControllerEnvPath = ( + path: Path.Path, + envGlobalPath: string +): string => + resolvePathFromCwd(path, process.cwd(), envGlobalPath) + +const readGithubAuthTokens = ( + envGlobalPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedEnvPath = resolveControllerEnvPath(path, envGlobalPath) + const envText = yield* _(readEnvText(fs, resolvedEnvPath)) + const entries = listGithubTokens(envText) + const tokens: ReadonlyArray = yield* _( + Effect.forEach( + entries, + (entry) => + validateGithubToken(entry.token).pipe( + Effect.map((validation: GithubTokenValidationResult) => toTokenStatus(entry, validation)) + ), + { concurrency: "unbounded" } + ) + ) + return { + summary: buildStatusSummary(tokens), + tokens + } satisfies GithubAuthStatus + }) + +export const readGithubAuthStatus = (): Effect.Effect => + readGithubAuthTokens(githubAuthEnvGlobalPath) + +export const loginGithubAuth = (request: GithubAuthLoginRequest) => + Effect.gen(function*(_) { + yield* _( + runGithubLogin({ + _tag: "AuthGithubLogin", + label: request.label ?? null, + token: request.token ?? null, + scopes: request.scopes ?? null, + envGlobalPath: githubAuthEnvGlobalPath + }) + ) + return yield* _(readGithubAuthTokens(githubAuthEnvGlobalPath)) + }) + +export const logoutGithubAuth = (request: GithubAuthLogoutRequest) => + Effect.gen(function*(_) { + yield* _( + runGithubLogout({ + _tag: "AuthGithubLogout", + label: request.label ?? null, + envGlobalPath: githubAuthEnvGlobalPath + }) + ) + return yield* _(readGithubAuthTokens(githubAuthEnvGlobalPath)) + }) + +export const ensureGithubAuthForCreate = (config: { + readonly repoUrl: string + readonly gitTokenLabel?: string | undefined + readonly envGlobalPath: string +}): Effect.Effect => + Effect.gen(function*(_) { + if (parseGithubRepoUrl(config.repoUrl) === null) { + return + } + + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedEnvPath = resolveControllerEnvPath(path, config.envGlobalPath) + const envText = yield* _(readEnvText(fs, resolvedEnvPath)) + const token = resolveGithubCloneAuthToken(envText, { + repoUrl: config.repoUrl, + gitTokenLabel: config.gitTokenLabel + }) + + if (token === null) { + return yield* _(Effect.fail(githubAuthError(githubAuthRequiredMessage))) + } + + const validation: GithubTokenValidationResult = yield* _(validateGithubToken(token)) + return yield* _( + Match.value(validation.status).pipe( + Match.when("valid", () => Effect.void), + Match.when("invalid", () => Effect.fail(githubAuthError(githubInvalidTokenMessage))), + Match.when("unknown", () => Effect.logWarning("Unable to validate GitHub token before create; continuing.")), + Match.exhaustive + ) + ) + }) diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 0afa1be8..6ec86361 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -1,4 +1,13 @@ -import { buildCreateCommand, createProject, formatParseError, listProjectItems, readProjectConfig } from "@effect-template/lib" +import { + buildCreateCommand, + createProject, + formatParseError, + applyAllDockerGitProjects, + downAllDockerGitProjects, + listProjectItems, + readProjectConfig, + runDockerComposeUpWithPortCheck +} from "@effect-template/lib" import { runCommandCapture } from "@effect-template/lib/shell/command-runner" import { CommandFailedError } from "@effect-template/lib/shell/errors" import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects" @@ -8,6 +17,7 @@ import { Effect, Either } from "effect" import type { CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js" import { ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" +import { ensureGithubAuthForCreate } from "./auth.js" import { emitProjectEvent } from "./events.js" const readComposePsFormatted = (cwd: string) => @@ -155,6 +165,14 @@ export const listProjects = () => Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) ) +export const applyAllProjects = (activeOnly: boolean) => + applyAllDockerGitProjects({ + _tag: "ApplyAll", + activeOnly + }) + +export const downAllProjects = () => downAllDockerGitProjects + export const getProject = ( projectId: string ) => @@ -223,9 +241,12 @@ export const createProjectFromRequest = ( const command = { ...parsed.right, - openSsh: false + openSsh: false, + waitForClone: request.waitForClone ?? parsed.right.waitForClone } + yield* _(ensureGithubAuthForCreate(command.config)) + yield* _( Effect.sync(() => { emitProjectEvent(command.outDir, "project.deployment.status", { @@ -282,7 +303,7 @@ export const upProject = ( Effect.gen(function*(_) { const project = yield* _(findProjectById(projectId)) yield* _(markDeployment(projectId, "build", "docker compose up -d --build")) - yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(runDockerComposeUpWithPortCheck(project.projectDir)) yield* _(markDeployment(projectId, "running", "Container running")) }) @@ -319,7 +340,7 @@ export const recreateProject = ( ) yield* _(runComposeCapture(projectId, project.projectDir, ["down"], [0, 1])) - yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(runDockerComposeUpWithPortCheck(project.projectDir)) yield* _(markDeployment(projectId, "running", "Recreate completed")) }) diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts new file mode 100644 index 00000000..d79fb80d --- /dev/null +++ b/packages/api/tests/auth.test.ts @@ -0,0 +1,159 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { vi } from "vitest" + +import { ApiAuthRequiredError } from "../src/api/errors.js" +import { readGithubAuthStatus } from "../src/services/auth.js" +import { createProjectFromRequest } from "../src/services/projects.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +) => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-api-auth-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const withWorkingDirectory = ( + cwd: string, + effect: Effect.Effect +) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.cwd() + process.chdir(cwd) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + process.chdir(previous) + }) + ) + +const withProjectsRoot = ( + projectsRoot: string, + effect: Effect.Effect +) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env["DOCKER_GIT_PROJECTS_ROOT"] + process.env["DOCKER_GIT_PROJECTS_ROOT"] = projectsRoot + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + return + } + process.env["DOCKER_GIT_PROJECTS_ROOT"] = previous + }) + ) + +const withPatchedFetch = ( + fetchImpl: typeof globalThis.fetch, + effect: Effect.Effect +) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = globalThis.fetch + globalThis.fetch = fetchImpl + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + globalThis.fetch = previous + }) + ) + +describe("api auth", () => { + it.effect("returns auth required for GitHub create when no token is stored", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const envDir = path.join(projectsRoot, ".orch", "env") + const envPath = path.join(envDir, "global.env") + + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(envPath, "# docker-git env\n")) + + const failure = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://github.com/ProverCoderAI/docker-git", + repoRef: "main", + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.flip) + ) + ) + ) + + expect(failure).toBeInstanceOf(ApiAuthRequiredError) + if (failure instanceof ApiAuthRequiredError) { + expect(failure.provider).toBe("github") + expect(failure.command).toBe("docker-git auth github login --web") + } + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("reads GitHub auth status from the controller env file", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const envDir = path.join(projectsRoot, ".orch", "env") + const envPath = path.join(envDir, "global.env") + + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(envPath, "GITHUB_TOKEN=live-token\n")) + + const fetchMock = vi.fn(() => + Effect.runPromise( + Effect.succeed( + new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { + "content-type": "application/json" + } + }) + ) + ) + ) + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + withPatchedFetch(fetchMock, readGithubAuthStatus()) + ) + ) + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(status.summary).toBe("GitHub tokens (1):") + expect(status.tokens).toHaveLength(1) + expect(status.tokens[0]?.status).toBe("valid") + expect(status.tokens[0]?.login).toBe("octocat") + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts index 2e77bc5b..33862326 100644 --- a/packages/api/tests/schema.test.ts +++ b/packages/api/tests/schema.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Either, ParseResult, Schema } from "effect" -import { CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema } from "../src/api/schema.js" +import { + ApplyAllRequestSchema, + CreateAgentRequestSchema, + CreateFollowRequestSchema, + CreateProjectRequestSchema, + GithubAuthLoginRequestSchema, + GithubAuthLogoutRequestSchema +} from "../src/api/schema.js" describe("api schemas", () => { it.effect("decodes create project payload", () => @@ -61,4 +68,56 @@ describe("api schemas", () => { } }) })) + + it.effect("decodes auth login payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(GithubAuthLoginRequestSchema)({ + label: "default", + token: "token", + scopes: "repo,workflow" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.label).toBe("default") + expect(value.token).toBe("token") + expect(value.scopes).toBe("repo,workflow") + } + }) + })) + + it.effect("decodes auth logout payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(GithubAuthLogoutRequestSchema)({ + label: "default" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.label).toBe("default") + } + }) + })) + + it.effect("decodes apply-all payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(ApplyAllRequestSchema)({ + activeOnly: true + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.activeOnly).toBe(true) + } + }) + })) }) diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index a8cb0125..1f1fb193 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -15,6 +15,7 @@ import simpleImportSort from "eslint-plugin-simple-import-sort"; import sortDestructureKeys from "eslint-plugin-sort-destructure-keys"; import globals from "globals"; import eslintCommentsConfigs from "@eslint-community/eslint-plugin-eslint-comments/configs"; +import { noLibImportsRule } from "./eslint/no-lib-imports.mjs"; const codegenPlugin = fixupPluginRules( codegen as unknown as Parameters[0], @@ -53,6 +54,7 @@ export default defineConfig( sonarjs, unicorn, import: fixupPluginRules(importPlugin), + local: { rules: { "no-lib-imports": noLibImportsRule } }, "sort-destructure-keys": sortDestructureKeys, "simple-import-sort": simpleImportSort, codegen: codegenPlugin, @@ -71,6 +73,7 @@ export default defineConfig( rules: { ...sonarjs.configs.recommended.rules, ...unicorn.configs.recommended.rules, + "local/no-lib-imports": "error", "no-restricted-imports": ["error", { paths: [ { diff --git a/packages/app/eslint.effect-ts-check.config.mjs b/packages/app/eslint.effect-ts-check.config.mjs index f7829cda..2d86069f 100644 --- a/packages/app/eslint.effect-ts-check.config.mjs +++ b/packages/app/eslint.effect-ts-check.config.mjs @@ -10,6 +10,7 @@ import eslintComments from "@eslint-community/eslint-plugin-eslint-comments" import globals from "globals" import tseslint from "typescript-eslint" +import { noLibImportsRule } from "./eslint/no-lib-imports.mjs" const restrictedImports = [ { @@ -147,9 +148,11 @@ export default tseslint.config( }, plugins: { "@typescript-eslint": tseslint.plugin, - "eslint-comments": eslintComments + "eslint-comments": eslintComments, + local: { rules: { "no-lib-imports": noLibImportsRule } } }, rules: { + "local/no-lib-imports": "error", "no-console": "error", "no-restricted-imports": ["error", { paths: restrictedImports, diff --git a/packages/app/eslint/no-lib-imports.mjs b/packages/app/eslint/no-lib-imports.mjs new file mode 100644 index 00000000..485c6c2f --- /dev/null +++ b/packages/app/eslint/no-lib-imports.mjs @@ -0,0 +1,160 @@ +// @ts-check + +const bannedPackageName = "@effect-template/lib" + +/** + * @typedef {{ readonly type: "Literal", readonly value: unknown }} LiteralSourceNode + * @typedef {{ readonly value: { readonly cooked: string | null } }} TemplateQuasiNode + * @typedef {{ + * readonly type: "TemplateLiteral", + * readonly expressions: ReadonlyArray, + * readonly quasis: ReadonlyArray + * }} TemplateLiteralSourceNode + * @typedef {LiteralSourceNode | TemplateLiteralSourceNode} StaticSourceNode + * @typedef {{ readonly type: "Identifier", readonly name: string }} IdentifierNode + * @typedef {{ readonly type: "SpreadElement" }} SpreadElementNode + */ + +/** @param {string} value */ +const isDirectLibImport = (value) => + value === bannedPackageName || value.startsWith(`${bannedPackageName}/`) + +/** + * @param {unknown} value + * @returns {value is Record} + */ +const isRecord = (value) => typeof value === "object" && value !== null + +/** + * @param {unknown} value + * @returns {value is LiteralSourceNode} + */ +const isLiteralSourceNode = (value) => + isRecord(value) && value["type"] === "Literal" && "value" in value + +/** + * @param {unknown} value + * @returns {value is TemplateLiteralSourceNode} + */ +const isTemplateLiteralSourceNode = (value) => + isRecord(value) && + value["type"] === "TemplateLiteral" && + Array.isArray(value["expressions"]) && + Array.isArray(value["quasis"]) + +/** + * @param {unknown} value + * @returns {value is IdentifierNode} + */ +const isIdentifierNode = (value) => + isRecord(value) && value["type"] === "Identifier" && typeof value["name"] === "string" + +/** + * @param {unknown} value + * @returns {value is SpreadElementNode} + */ +const isSpreadElementNode = (value) => + isRecord(value) && value["type"] === "SpreadElement" + +/** @param {unknown} source */ +const readSourceText = (source) => { + if (isLiteralSourceNode(source) && typeof source.value === "string") { + return source.value + } + + if ( + isTemplateLiteralSourceNode(source) && + source.expressions.length === 0 && + source.quasis.length === 1 + ) { + const [quasi] = source.quasis + return typeof quasi?.value.cooked === "string" ? quasi.value.cooked : null + } + + return null +} + +/** + * @param {import("eslint").Rule.RuleContext} context + * @returns {import("eslint").Rule.RuleListener} + */ +const createRuleListener = (context) => { + /** @param {unknown} source */ + const checkSource = (source) => { + if (source == null) { + return + } + + const sourceText = readSourceText(source) + if (sourceText === null || !isDirectLibImport(sourceText)) { + return + } + + context.report({ + node: /** @type {import("eslint").JSSyntaxElement} */ (source), + messageId: "noLibImport", + data: { source: sourceText } + }) + } + + return { + /** @param {{ readonly callee?: unknown, readonly arguments?: ReadonlyArray | null | undefined }} node */ + CallExpression(node) { + if ( + !isIdentifierNode(node.callee) || + node.callee.name !== "require" || + !Array.isArray(node.arguments) + ) { + return + } + + const [firstArgument] = node.arguments + if (isSpreadElementNode(firstArgument)) { + return + } + + checkSource(firstArgument) + }, + /** @param {{ readonly source?: unknown }} node */ + ExportAllDeclaration(node) { + checkSource(node.source) + }, + /** @param {{ readonly source?: unknown }} node */ + ExportNamedDeclaration(node) { + checkSource(node.source) + }, + /** @param {{ readonly source?: unknown }} node */ + ImportDeclaration(node) { + checkSource(node.source) + }, + /** @param {{ readonly source?: unknown }} node */ + ImportExpression(node) { + checkSource(node.source) + }, + /** @param {{ readonly source?: unknown, readonly argument?: unknown }} node */ + TSImportType(node) { + checkSource("source" in node ? node.source : node.argument) + }, + /** @param {{ readonly expression?: unknown }} node */ + TSExternalModuleReference(node) { + checkSource(node.expression) + } + } +} + +/** @type {import("eslint").Rule.RuleModule} */ +export const noLibImportsRule = { + meta: { + type: "problem", + docs: { + description: + "forbid direct imports, re-exports, and require calls from @effect-template/lib inside package/app" + }, + schema: [], + messages: { + noLibImport: + "Direct import or require '{{source}}' from @effect-template/lib is forbidden in package/app. Use the API client or a local app adapter instead." + } + }, + create: createRuleListener +} diff --git a/packages/app/package.json b/packages/app/package.json index d0173599..2ba7e3e1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,9 +19,9 @@ "prepack": "pnpm run build:docker-git", "dev": "vite build --watch --ssr src/app/main.ts", "prelint": "pnpm -C ../lib build", - "lint": "PATH=../../scripts:$PATH vibecode-linter src/", - "lint:tests": "PATH=../../scripts:$PATH vibecode-linter tests/", - "lint:effect": "PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", + "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", + "lint:tests": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter tests/", + "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", "prebuild:docker-git": "pnpm -C ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", "check": "pnpm run typecheck", diff --git a/packages/app/src/app/program.ts b/packages/app/src/app/program.ts index ab9ecb9d..2b098677 100644 --- a/packages/app/src/app/program.ts +++ b/packages/app/src/app/program.ts @@ -1,4 +1,4 @@ -import { listProjects, readCloneRequest, runDockerGitClone, runDockerGitOpen } from "@effect-template/lib" +import { listProjects, readCloneRequest, runDockerGitClone, runDockerGitOpen } from "@lib" import { Console, Effect, Match, pipe } from "effect" /** diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts new file mode 100644 index 00000000..4c345871 --- /dev/null +++ b/packages/app/src/docker-git/api-client.ts @@ -0,0 +1,86 @@ +import { Effect } from "effect" + +import type { + AuthGithubLoginCommand, + AuthGithubLogoutCommand, + AuthGithubStatusCommand, + CreateCommand +} from "@lib/core/domain" + +import { request, requestVoid } from "./api-http.js" +import { asArray, asObject, type JsonRequest } from "./api-json.js" +import { decodeProjectDetails, decodeProjectSummary } from "./api-project-codec.js" + +export { type JsonObject, type JsonRequest, type JsonValue, renderJsonPayload } from "./api-json.js" +export { + type ApiProjectDetails, + type ApiProjectSummary, + decodeProjectDetails, + decodeProjectSummary, + renderProjectSummaryLine +} from "./api-project-codec.js" + +export const listProjects = () => + request("GET", "/projects").pipe( + Effect.map((payload) => { + const object = asObject(payload) + const items = object === null ? asArray(payload) : asArray(object["projects"]) + return items + .map((item) => decodeProjectSummary(item)) + .filter((value): value is NonNullable => value !== null) + }) + ) + +export const createProject = (command: CreateCommand) => { + const config = command.config + const body = { + repoUrl: config.repoUrl, + repoRef: config.repoRef, + targetDir: config.targetDir, + sshPort: String(config.sshPort), + sshUser: config.sshUser, + containerName: config.containerName, + serviceName: config.serviceName, + volumeName: config.volumeName, + cpuLimit: config.cpuLimit, + ramLimit: config.ramLimit, + dockerNetworkMode: config.dockerNetworkMode, + dockerSharedNetworkName: config.dockerSharedNetworkName, + enableMcpPlaywright: config.enableMcpPlaywright, + outDir: command.outDir, + gitTokenLabel: config.gitTokenLabel, + codexTokenLabel: config.codexAuthLabel, + claudeTokenLabel: config.claudeAuthLabel, + agentAutoMode: config.agentAuto ? (config.agentMode ?? "auto") : undefined, + up: command.runUp, + openSsh: false, + force: command.force, + forceEnv: command.forceEnv, + waitForClone: command.waitForClone + } satisfies JsonRequest + + return request("POST", "/projects", body).pipe( + Effect.map((payload) => { + const object = asObject(payload) + return object === null ? decodeProjectDetails(payload) : decodeProjectDetails(object["project"] ?? payload) + }) + ) +} + +export const applyAllProjects = (activeOnly: boolean) => requestVoid("POST", "/projects/apply-all", { activeOnly }) + +export const downAllProjects = () => requestVoid("POST", "/projects/down-all") + +export const githubLogin = (command: AuthGithubLoginCommand) => + request("POST", "/auth/github/login", { + label: command.label, + token: command.token, + scopes: command.scopes + }) + +export const githubStatus = (_command: AuthGithubStatusCommand) => request("GET", "/auth/github/status") + +export const githubLogout = (command: AuthGithubLogoutCommand) => + requestVoid("POST", "/auth/github/logout", { + label: command.label + }) diff --git a/packages/app/src/docker-git/api-http.ts b/packages/app/src/docker-git/api-http.ts new file mode 100644 index 00000000..d5283e38 --- /dev/null +++ b/packages/app/src/docker-git/api-http.ts @@ -0,0 +1,151 @@ +import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform" +import type * as HttpClientError from "@effect/platform/HttpClientError" +import { Effect } from "effect" + +import { asObject, asString, type JsonRequest, type JsonValue, parseResponseBody } from "./api-json.js" +import { resolveApiBaseUrl } from "./controller.js" +import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" + +type ApiTransportError = ApiRequestError | ApiAuthRequiredError + +type ApiErrorEnvelope = { + readonly error?: { + readonly type?: string + readonly message?: string + readonly provider?: string + readonly command?: string + readonly details?: JsonValue + } +} + +type ApiErrorPayload = NonNullable + +const jsonHeaders: Readonly> = { + "content-type": "application/json", + accept: "application/json" +} + +const defaultGithubLoginCommand = "docker-git auth github login --web" + +const isApiTransportError = ( + error: ApiTransportError | HttpClientError.HttpClientError +): error is ApiTransportError => error._tag === "ApiRequestError" || error._tag === "ApiAuthRequiredError" + +const readErrorPayload = (body: JsonValue): ApiErrorPayload | undefined => { + const envelope = asObject(body) + if (envelope === null) { + return undefined + } + + const error = asObject(envelope["error"]) + if (error === null) { + return undefined + } + + const type = asString(error["type"]) + const message = asString(error["message"]) + const provider = asString(error["provider"]) + const command = asString(error["command"]) + const details = error["details"] + + return { + ...(type === null ? {} : { type }), + ...(message === null ? {} : { message }), + ...(provider === null ? {} : { provider }), + ...(command === null ? {} : { command }), + ...(details === undefined ? {} : { details }) + } +} + +const isAuthRequired = ( + status: number, + error: ApiErrorEnvelope["error"] | undefined +): boolean => status === 401 || (error?.type ?? "").toLowerCase().includes("authrequired") + +const renderDetails = (details: JsonValue | undefined): string | null => + details === undefined ? null : `Details: ${JSON.stringify(details, null, 2)}` + +const renderRequestMessage = (message: string, details: JsonValue | undefined): string => { + const renderedDetails = renderDetails(details) + return renderedDetails === null ? message : `${message}\n${renderedDetails}` +} + +const toAuthRequiredError = ( + error: ApiErrorEnvelope["error"] | undefined, + status: number +): ApiAuthRequiredError => ({ + _tag: "ApiAuthRequiredError", + provider: error?.provider ?? "github", + message: error?.message ?? `HTTP ${status}`, + command: error?.command ?? defaultGithubLoginCommand +}) + +const toApiRequestError = ( + method: string, + path: string, + status: number, + error: ApiErrorEnvelope["error"] | undefined +): ApiRequestError => ({ + _tag: "ApiRequestError", + method, + path, + message: renderRequestMessage(error?.message ?? `HTTP ${status}`, error?.details) +}) + +const toRequestError = ( + method: string, + path: string, + status: number, + body: JsonValue +): ApiTransportError => { + const error = readErrorPayload(body) + if (isAuthRequired(status, error)) { + return toAuthRequiredError(error, status) + } + return toApiRequestError(method, path, status, error) +} + +const executeRequest = ( + client: HttpClient.HttpClient, + method: "GET" | "POST", + path: string, + body: JsonRequest | undefined +) => + method === "GET" + ? client.get(`${resolveApiBaseUrl()}${path}`, { headers: jsonHeaders }) + : client.post(`${resolveApiBaseUrl()}${path}`, { + headers: jsonHeaders, + body: body === undefined ? HttpBody.empty : HttpBody.unsafeJson(body) + }) + +export const request = ( + method: "GET" | "POST", + path: string, + body?: JsonRequest +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _(executeRequest(client, method, path, body)) + const parsed = yield* _(response.text.pipe(Effect.flatMap((text) => parseResponseBody(text)))) + + if (response.status >= 400) { + return yield* _(Effect.fail(toRequestError(method, path, response.status, parsed))) + } + + return parsed + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError((error): ApiTransportError => + isApiTransportError(error) + ? error + : { + _tag: "ApiRequestError", + method, + path, + message: String(error) + } + ) + ) + +export const requestVoid = (method: "GET" | "POST", path: string, body?: JsonRequest) => + request(method, path, body).pipe(Effect.asVoid) diff --git a/packages/app/src/docker-git/api-json.ts b/packages/app/src/docker-git/api-json.ts new file mode 100644 index 00000000..348a6f4c --- /dev/null +++ b/packages/app/src/docker-git/api-json.ts @@ -0,0 +1,120 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import * as Schema from "@effect/schema/Schema" +import { Effect, Either } from "effect" + +type JsonPrimitive = boolean | number | string | null +export type JsonValue = JsonPrimitive | JsonObject | ReadonlyArray +export type JsonObject = Readonly<{ [key: string]: JsonValue }> +export type JsonRequest = + | JsonPrimitive + | { readonly [key: string]: JsonRequest | undefined } + | ReadonlyArray + +const JsonValueSchema: Schema.Schema = Schema.suspend(() => + Schema.Union( + Schema.Null, + Schema.Boolean, + Schema.Number, + Schema.String, + Schema.Array(JsonValueSchema), + Schema.Record({ key: Schema.String, value: JsonValueSchema }) + ) +) + +const JsonValueFromStringSchema = Schema.parseJson(JsonValueSchema) + +const decodeJsonText = (input: string): Effect.Effect => + Either.match(ParseResult.decodeUnknownEither(JsonValueFromStringSchema)(input), { + onLeft: () => Effect.succeed(input), + onRight: (value) => Effect.succeed(value) + }) + +export const parseResponseBody = (body: string): Effect.Effect => { + const trimmed = body.trim() + if (trimmed.length === 0) { + return Effect.succeed(null) + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + return decodeJsonText(trimmed) + } + return Effect.succeed(trimmed) +} + +export const isJsonObject = (value: JsonValue | undefined): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const isJsonArray = (value: JsonValue | undefined): value is ReadonlyArray => Array.isArray(value) + +export const asObject = (value: JsonValue | undefined): JsonObject | null => isJsonObject(value) ? value : null + +export const asArray = (value: JsonValue | undefined): ReadonlyArray => isJsonArray(value) ? value : [] + +export const asString = (value: JsonValue | undefined): string | null => typeof value === "string" ? value : null + +const renderGithubStatusLine = (entry: JsonObject): string | null => { + const label = asString(entry["label"]) + const status = asString(entry["status"]) + const login = asString(entry["login"]) + if (label === null || status === null) { + return null + } + + if (status === "valid") { + return login === null + ? `- ${label}: valid (owner unavailable)` + : `- ${label}: valid (owner: ${login})` + } + + if (status === "invalid") { + return `- ${label}: invalid` + } + + return `- ${label}: unknown (validation unavailable)` +} + +const renderGithubStatusLike = (value: JsonObject): string | null => { + const summary = asString(value["summary"]) + if (summary === null) { + return null + } + + const lines = asArray(value["tokens"]) + .flatMap((entry) => { + const item = asObject(entry) + return item === null ? [] : [renderGithubStatusLine(item)] + }) + .filter((line): line is string => line !== null) + + return lines.length === 0 ? summary : [summary, ...lines].join("\n") +} + +export const renderJsonPayload = (payload: JsonValue): string => { + if (typeof payload === "string") { + return payload + } + + const object = asObject(payload) + if (object === null) { + return JSON.stringify(payload, null, 2) + } + + const directStatus = renderGithubStatusLike(object) + if (directStatus !== null) { + return directStatus + } + + const message = asString(object["message"]) + if (message !== null) { + return message + } + + const nestedStatus = asObject(object["status"]) + if (nestedStatus !== null) { + const renderedNestedStatus = renderGithubStatusLike(nestedStatus) + if (renderedNestedStatus !== null) { + return renderedNestedStatus + } + } + + return JSON.stringify(payload, null, 2) +} diff --git a/packages/app/src/docker-git/api-project-codec.ts b/packages/app/src/docker-git/api-project-codec.ts new file mode 100644 index 00000000..36a00b18 --- /dev/null +++ b/packages/app/src/docker-git/api-project-codec.ts @@ -0,0 +1,154 @@ +import { asObject, asString, type JsonValue } from "./api-json.js" + +export type ApiProjectSummary = { + readonly id: string + readonly displayName: string + readonly repoUrl: string + readonly repoRef: string + readonly status: "running" | "stopped" | "unknown" + readonly statusLabel: string +} + +export type ApiProjectDetails = ApiProjectSummary & { + readonly containerName: string + readonly serviceName: string + readonly sshUser: string + readonly sshPort: number + readonly targetDir: string + readonly projectDir: string + readonly sshCommand: string + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string + readonly codexHome: string + readonly clonedOnHostname?: string | undefined +} + +type ProjectDetailFields = Omit +type RequiredProjectDetailFields = Omit + +const isProjectStatus = ( + value: string +): value is ApiProjectSummary["status"] => value === "running" || value === "stopped" || value === "unknown" + +const stringOrEmpty = (value: string | null): string => value ?? "" + +const numberOrZero = (value: number | null): number => value ?? 0 + +const readSummaryBaseFields = ( + object: ReturnType +): Omit & { readonly status: string } | null => { + if (object === null) { + return null + } + + const id = asString(object["id"]) + const displayName = asString(object["displayName"]) + const repoUrl = asString(object["repoUrl"]) + const repoRef = asString(object["repoRef"]) + const status = asString(object["status"]) + const statusLabel = asString(object["statusLabel"]) + const values = [id, displayName, repoUrl, repoRef, status, statusLabel] + + if (values.includes(null)) { + return null + } + + return { + id: stringOrEmpty(id), + displayName: stringOrEmpty(displayName), + repoUrl: stringOrEmpty(repoUrl), + repoRef: stringOrEmpty(repoRef), + status: stringOrEmpty(status), + statusLabel: stringOrEmpty(statusLabel) + } +} + +const readRequiredProjectDetails = ( + object: ReturnType +): RequiredProjectDetailFields | null => { + if (object === null) { + return null + } + + const containerName = asString(object["containerName"]) + const serviceName = asString(object["serviceName"]) + const sshUser = asString(object["sshUser"]) + const sshPort = typeof object["sshPort"] === "number" ? object["sshPort"] : null + const targetDir = asString(object["targetDir"]) + const projectDir = asString(object["projectDir"]) + const sshCommand = asString(object["sshCommand"]) + const envGlobalPath = asString(object["envGlobalPath"]) + const envProjectPath = asString(object["envProjectPath"]) + const codexAuthPath = asString(object["codexAuthPath"]) + const codexHome = asString(object["codexHome"]) + const values = [ + containerName, + serviceName, + sshUser, + sshPort, + targetDir, + projectDir, + sshCommand, + envGlobalPath, + envProjectPath, + codexAuthPath, + codexHome + ] + + if (values.includes(null)) { + return null + } + + return { + containerName: stringOrEmpty(containerName), + serviceName: stringOrEmpty(serviceName), + sshUser: stringOrEmpty(sshUser), + sshPort: numberOrZero(sshPort), + targetDir: stringOrEmpty(targetDir), + projectDir: stringOrEmpty(projectDir), + sshCommand: stringOrEmpty(sshCommand), + envGlobalPath: stringOrEmpty(envGlobalPath), + envProjectPath: stringOrEmpty(envProjectPath), + codexAuthPath: stringOrEmpty(codexAuthPath), + codexHome: stringOrEmpty(codexHome) + } +} + +const readProjectSummaryFields = (value: JsonValue): ApiProjectSummary | null => { + const object = asObject(value) + const summary = readSummaryBaseFields(object) + if (summary === null || !isProjectStatus(summary.status)) { + return null + } + return { + ...summary, + status: summary.status + } +} + +const readProjectDetailFields = (value: JsonValue): ProjectDetailFields | null => { + const object = asObject(value) + if (object === null) { + return null + } + + const details = readRequiredProjectDetails(object) + if (details === null) { + return null + } + + const clonedOnHostname = asString(object["clonedOnHostname"]) + return clonedOnHostname === null ? details : { ...details, clonedOnHostname } +} + +export const decodeProjectSummary = (value: JsonValue): ApiProjectSummary | null => readProjectSummaryFields(value) + +export const decodeProjectDetails = (value: JsonValue): ApiProjectDetails | null => { + const summary = readProjectSummaryFields(value) + const details = readProjectDetailFields(value) + return summary === null || details === null ? null : { ...summary, ...details } +} + +export const renderProjectSummaryLine = (project: ApiProjectSummary): string => + `${project.displayName} [${project.statusLabel}] ${project.repoRef} ${project.repoUrl}` diff --git a/packages/app/src/docker-git/cli/input.ts b/packages/app/src/docker-git/cli/input.ts index f6776719..6dc30da1 100644 --- a/packages/app/src/docker-git/cli/input.ts +++ b/packages/app/src/docker-git/cli/input.ts @@ -1,7 +1,7 @@ import * as Terminal from "@effect/platform/Terminal" import { Effect } from "effect" -import { InputCancelledError, InputReadError } from "@effect-template/lib/shell/errors" +import { InputCancelledError, InputReadError } from "@lib/shell/errors" const normalizeMessage = (error: Error): string => error.message diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 38230071..e1250b4f 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -1,7 +1,7 @@ import { Either } from "effect" -import { type ApplyCommand, type ParseError } from "@effect-template/lib/core/domain" -import { normalizeCpuLimit, normalizeRamLimit } from "@effect-template/lib/core/resource-limits" +import { type ApplyCommand, type ParseError } from "@lib/core/domain" +import { normalizeCpuLimit, normalizeRamLimit } from "@lib/core/resource-limits" import { parseProjectDirWithOptions } from "./parser-shared.js" diff --git a/packages/app/src/docker-git/cli/parser-attach.ts b/packages/app/src/docker-git/cli/parser-attach.ts index dc888399..60f160d0 100644 --- a/packages/app/src/docker-git/cli/parser-attach.ts +++ b/packages/app/src/docker-git/cli/parser-attach.ts @@ -1,6 +1,6 @@ import { Either } from "effect" -import { type AttachCommand, type ParseError } from "@effect-template/lib/core/domain" +import { type AttachCommand, type ParseError } from "@lib/core/domain" import { parseProjectDirArgs } from "./parser-shared.js" diff --git a/packages/app/src/docker-git/cli/parser-auth.ts b/packages/app/src/docker-git/cli/parser-auth.ts index 389aae5a..932bc8ff 100644 --- a/packages/app/src/docker-git/cli/parser-auth.ts +++ b/packages/app/src/docker-git/cli/parser-auth.ts @@ -1,7 +1,7 @@ import { Either, Match } from "effect" -import type { RawOptions } from "@effect-template/lib/core/command-options" -import { type AuthCommand, type Command, type ParseError } from "@effect-template/lib/core/domain" +import type { RawOptions } from "@lib/core/command-options" +import { type AuthCommand, type Command, type ParseError } from "@lib/core/domain" import { parseRawOptions } from "./parser-options.js" diff --git a/packages/app/src/docker-git/cli/parser-clone.ts b/packages/app/src/docker-git/cli/parser-clone.ts index c0cc4369..98125461 100644 --- a/packages/app/src/docker-git/cli/parser-clone.ts +++ b/packages/app/src/docker-git/cli/parser-clone.ts @@ -1,8 +1,8 @@ import { Either } from "effect" -import { buildCreateCommand, nonEmpty } from "@effect-template/lib/core/command-builders" -import type { RawOptions } from "@effect-template/lib/core/command-options" -import { type Command, type ParseError, resolveRepoInput } from "@effect-template/lib/core/domain" +import { buildCreateCommand, nonEmpty } from "@lib/core/command-builders" +import type { RawOptions } from "@lib/core/command-options" +import { type Command, type ParseError, resolveRepoInput } from "@lib/core/domain" import { parseRawOptions } from "./parser-options.js" import { resolveWorkspaceRepoPath, splitPositionalRepo } from "./parser-shared.js" diff --git a/packages/app/src/docker-git/cli/parser-create.ts b/packages/app/src/docker-git/cli/parser-create.ts index 37679364..69ba3436 100644 --- a/packages/app/src/docker-git/cli/parser-create.ts +++ b/packages/app/src/docker-git/cli/parser-create.ts @@ -1,3 +1,3 @@ -export { buildCreateCommand, nonEmpty } from "@effect-template/lib/core/command-builders" -export type { RawOptions } from "@effect-template/lib/core/command-options" -export type { CreateCommand, ParseError } from "@effect-template/lib/core/domain" +export { buildCreateCommand, nonEmpty } from "@lib/core/command-builders" +export type { RawOptions } from "@lib/core/command-options" +export type { CreateCommand, ParseError } from "@lib/core/domain" diff --git a/packages/app/src/docker-git/cli/parser-mcp-playwright.ts b/packages/app/src/docker-git/cli/parser-mcp-playwright.ts index 42abcd5f..94b77c11 100644 --- a/packages/app/src/docker-git/cli/parser-mcp-playwright.ts +++ b/packages/app/src/docker-git/cli/parser-mcp-playwright.ts @@ -1,6 +1,6 @@ import { Either } from "effect" -import { type McpPlaywrightUpCommand, type ParseError } from "@effect-template/lib/core/domain" +import { type McpPlaywrightUpCommand, type ParseError } from "@lib/core/domain" import { parseProjectDirWithOptions } from "./parser-shared.js" diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 5a733c61..3ef33a20 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -1,7 +1,7 @@ import { Either } from "effect" -import type { RawOptions } from "@effect-template/lib/core/command-options" -import type { ParseError } from "@effect-template/lib/core/domain" +import type { RawOptions } from "@lib/core/command-options" +import type { ParseError } from "@lib/core/domain" interface ValueOptionSpec { readonly flag: string @@ -280,4 +280,4 @@ export const parseRawOptions = (args: ReadonlyArray): Either.Either] [options] @@ -127,24 +125,3 @@ State actions: State options: --message, -m Commit message for state commit ` - -// CHANGE: normalize parse errors into user-facing messages -// WHY: keep formatting deterministic and centralized -// QUOTE(ТЗ): "Надо написать CLI команду" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall e: format(e) = s -> deterministic(s) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: each ParseError maps to exactly one message -// COMPLEXITY: O(1) -export const formatParseError = (error: ParseError): string => - Match.value(error).pipe( - Match.when({ _tag: "UnknownCommand" }, ({ command }) => `Unknown command: ${command}`), - Match.when({ _tag: "UnknownOption" }, ({ option }) => `Unknown option: ${option}`), - Match.when({ _tag: "MissingOptionValue" }, ({ option }) => `Missing value for option: ${option}`), - Match.when({ _tag: "MissingRequiredOption" }, ({ option }) => `Missing required option: ${option}`), - Match.when({ _tag: "InvalidOption" }, ({ option, reason }) => `Invalid option ${option}: ${reason}`), - Match.when({ _tag: "UnexpectedArgument" }, ({ value }) => `Unexpected argument: ${value}`), - Match.exhaustive - ) diff --git a/packages/app/src/docker-git/controller.ts b/packages/app/src/docker-git/controller.ts new file mode 100644 index 00000000..f73deb26 --- /dev/null +++ b/packages/app/src/docker-git/controller.ts @@ -0,0 +1,201 @@ +import { FetchHttpClient, HttpClient } from "@effect/platform" +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Duration, Effect, pipe, Schedule } from "effect" + +import { runCommandExitCode } from "@lib/shell/command-runner" + +import type { ControllerBootstrapError } from "./host-errors.js" + +const defaultApiPort = "3334" +const defaultApiHost = "127.0.0.1" + +type ControllerRuntime = + | CommandExecutor.CommandExecutor + | FileSystem.FileSystem + | Path.Path + +const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ + _tag: "ControllerBootstrapError", + message +}) + +const trimTrailingSlashes = (value: string): string => { + const parts = value.split("/") + let end = parts.length + + while (end > 0 && parts[end - 1] === "") { + end -= 1 + } + + return end === parts.length ? value : parts.slice(0, end).join("/") +} + +export const resolveApiBaseUrl = (): string => { + const explicit = process.env["DOCKER_GIT_API_URL"]?.trim() + if (explicit !== undefined && explicit.length > 0) { + return trimTrailingSlashes(explicit) + } + + const host = process.env["DOCKER_GIT_API_BIND_HOST"]?.trim() || defaultApiHost + const port = process.env["DOCKER_GIT_API_PORT"]?.trim() || defaultApiPort + return `http://${host}:${port}` +} + +const composeFilePath = (): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + let current = process.cwd() + + for (;;) { + const candidate = path.join(current, "docker-compose.yml") + const exists = yield* _(fs.exists(candidate)) + if (exists) { + return candidate + } + + const parent = path.dirname(current) + if (parent === current) { + return path.resolve(process.cwd(), "docker-compose.yml") + } + current = parent + } + }) + +const mapComposePathError = (error: PlatformError): ControllerBootstrapError => + controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`) + +const runExitCode = ( + command: string, + args: ReadonlyArray +): Effect.Effect => + runCommandExitCode({ + cwd: process.cwd(), + command, + args + }).pipe( + Effect.match({ + onFailure: () => 1, + onSuccess: (exitCode) => exitCode + }) + ) + +export const resolveDockerCommand = (): Effect.Effect< + ReadonlyArray, + never, + CommandExecutor.CommandExecutor +> => + Effect.gen(function*(_) { + const dockerInfoExit = yield* _(runExitCode("docker", ["info"])) + if (dockerInfoExit === 0) { + return ["docker"] + } + + const sudoDockerInfoExit = yield* _(runExitCode("sudo", ["-n", "docker", "info"])) + return sudoDockerInfoExit === 0 ? ["sudo", "docker"] : ["docker"] + }) + +const runCompose = ( + args: ReadonlyArray +): Effect.Effect => + Effect.gen(function*(_) { + const dockerCommand = yield* _(resolveDockerCommand()) + const composePath = yield* _(composeFilePath().pipe(Effect.mapError(mapComposePathError))) + const command = dockerCommand[0] ?? "docker" + const commandArgs = [ + ...dockerCommand.slice(1), + "compose", + "-f", + composePath, + ...args + ] + const exitCode = yield* _(runExitCode(command, commandArgs)) + + if (exitCode === 0) { + return + } + + return yield* _( + Effect.fail( + controllerBootstrapError( + [ + "Failed to start docker-git controller.", + `Command: ${[command, ...commandArgs].join(" ")}`, + `Exit code: ${exitCode}` + ].join("\n") + ) + ) + ) + }) + +const probeHealth = (apiBaseUrl: string): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _(client.get(`${apiBaseUrl}/health`, { headers: { accept: "application/json" } })) + + if (response.status >= 200 && response.status < 300) { + return + } + + return yield* _( + Effect.fail( + controllerBootstrapError( + `docker-git controller health returned ${response.status} at ${apiBaseUrl}/health` + ) + ) + ) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError((error): ControllerBootstrapError => + error._tag === "ControllerBootstrapError" + ? error + : { + _tag: "ControllerBootstrapError", + message: `docker-git controller health probe failed at ${apiBaseUrl}/health\nDetails: ${String(error)}` + } + ) + ) + +const waitForHealth = (apiBaseUrl: string) => + pipe( + probeHealth(apiBaseUrl), + Effect.retry( + Schedule.addDelay(Schedule.recurs(30), () => Duration.seconds(2)) + ), + Effect.mapError((error): ControllerBootstrapError => ({ + _tag: "ControllerBootstrapError", + message: `docker-git controller did not become healthy at ${apiBaseUrl}/health\nDetails: ${error.message}` + })) + ) + +// CHANGE: bootstrap the API controller before issuing host-side API requests +// WHY: host CLI must not fall back to local state; controller owns .docker-git and project runtime +// QUOTE(ТЗ): "app(cli) инструмент общается только с API а API имеет свой .docker-git" +// REF: user-request-2026-04-01-api-only-host +// SOURCE: n/a +// FORMAT THEOREM: ∀cmd: controller(cmd) starts before api(cmd) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: controller is healthy before any host API dispatch +// COMPLEXITY: O(1) compose + O(k) health checks +export const ensureControllerReady = Effect.gen(function*(_) { + const apiBaseUrl = resolveApiBaseUrl() + const healthy = yield* _( + probeHealth(apiBaseUrl).pipe( + Effect.match({ + onFailure: () => false, + onSuccess: () => true + }) + ) + ) + + if (healthy) { + return + } + + yield* _(runCompose(["up", "-d", "--build"])) + return yield* _(waitForHealth(apiBaseUrl)) +}) diff --git a/packages/app/src/docker-git/host-errors.ts b/packages/app/src/docker-git/host-errors.ts new file mode 100644 index 00000000..e5be3142 --- /dev/null +++ b/packages/app/src/docker-git/host-errors.ts @@ -0,0 +1,66 @@ +import type { ParseError } from "@lib/core/domain" +import { formatParseError } from "./cli/usage.js" + +export type ControllerBootstrapError = { + readonly _tag: "ControllerBootstrapError" + readonly message: string +} + +export type ApiRequestError = { + readonly _tag: "ApiRequestError" + readonly method: string + readonly path: string + readonly message: string +} + +export type ApiAuthRequiredError = { + readonly _tag: "ApiAuthRequiredError" + readonly provider: string + readonly message: string + readonly command: string +} + +export type UnsupportedCommandError = { + readonly _tag: "UnsupportedCommandError" + readonly command: string + readonly message: string +} + +export type HostError = + | ControllerBootstrapError + | ApiRequestError + | ApiAuthRequiredError + | UnsupportedCommandError + +export type CliError = ParseError | HostError + +const isParseError = (error: CliError): error is ParseError => + error._tag === "UnknownCommand" || + error._tag === "UnknownOption" || + error._tag === "MissingOptionValue" || + error._tag === "MissingRequiredOption" || + error._tag === "InvalidOption" || + error._tag === "UnexpectedArgument" + +export const renderCliError = (error: CliError): string => { + if (isParseError(error)) { + return formatParseError(error) + } + + if (error._tag === "ControllerBootstrapError") { + return error.message + } + + if (error._tag === "ApiAuthRequiredError") { + return [error.message, `Run: ${error.command}`].join("\n") + } + + if (error._tag === "UnsupportedCommandError") { + return error.message + } + + return [ + `${error.method} ${error.path} failed`, + error.message + ].join("\n") +} diff --git a/packages/app/src/docker-git/main.ts b/packages/app/src/docker-git/main.ts index b82e82ea..16895f77 100644 --- a/packages/app/src/docker-git/main.ts +++ b/packages/app/src/docker-git/main.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeContext, NodeRuntime } from "@effect/platform-node" -import { Effect } from "effect" +import { Effect, pipe } from "effect" import { program } from "./program.js" @@ -15,6 +15,6 @@ import { program } from "./program.js" // EFFECT: Effect // INVARIANT: program runs with NodeContext.layer // COMPLEXITY: O(n) -const main = Effect.provide(program, NodeContext.layer) +const main = pipe(program, Effect.provide(NodeContext.layer)) NodeRuntime.runMain(main) diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts index 87ce1c9a..0c24058b 100644 --- a/packages/app/src/docker-git/menu-actions.ts +++ b/packages/app/src/docker-git/menu-actions.ts @@ -1,16 +1,16 @@ -import { type MenuAction, type ProjectConfig } from "@effect-template/lib/core/domain" -import { readProjectConfig } from "@effect-template/lib/shell/config" -import { runDockerComposeDown, runDockerComposeLogs, runDockerComposePs } from "@effect-template/lib/shell/docker" -import { gcProjectNetworkByTemplate } from "@effect-template/lib/usecases/docker-network-gc" -import type { AppError } from "@effect-template/lib/usecases/errors" -import { renderError } from "@effect-template/lib/usecases/errors" +import { type MenuAction, type ProjectConfig } from "@lib/core/domain" +import { readProjectConfig } from "@lib/shell/config" +import { runDockerComposeDown, runDockerComposeLogs, runDockerComposePs } from "@lib/shell/docker" +import { gcProjectNetworkByTemplate } from "@lib/usecases/docker-network-gc" +import type { AppError } from "@lib/usecases/errors" +import { renderError } from "@lib/usecases/errors" import { downAllDockerGitProjects, listProjectItems, listProjectStatus, listRunningProjectItems -} from "@effect-template/lib/usecases/projects" -import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up" +} from "@lib/usecases/projects" +import { runDockerComposeUpWithPortCheck } from "@lib/usecases/projects-up" import { Effect, Match, pipe } from "effect" import { openAuthMenu } from "./menu-auth.js" diff --git a/packages/app/src/docker-git/menu-auth-data.ts b/packages/app/src/docker-git/menu-auth-data.ts index 17e436ab..a12ff738 100644 --- a/packages/app/src/docker-git/menu-auth-data.ts +++ b/packages/app/src/docker-git/menu-auth-data.ts @@ -2,10 +2,10 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect, Match, pipe } from "effect" -import { ensureEnvFile, parseEnvEntries, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file" -import { type AppError } from "@effect-template/lib/usecases/errors" -import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" -import { autoSyncState } from "@effect-template/lib/usecases/state-repo" +import { ensureEnvFile, parseEnvEntries, readEnvText, upsertEnvKey } from "@lib/usecases/env-file" +import { type AppError } from "@lib/usecases/errors" +import { defaultProjectsRoot } from "@lib/usecases/menu-helpers" +import { autoSyncState } from "@lib/usecases/state-repo" import { countAuthAccountEntries } from "./menu-auth-snapshot-builder.js" import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js" diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts index 833d0023..a4d34601 100644 --- a/packages/app/src/docker-git/menu-auth-effects.ts +++ b/packages/app/src/docker-git/menu-auth-effects.ts @@ -8,10 +8,10 @@ import { authGeminiLogout, authGithubLogin, claudeAuthRoot -} from "@effect-template/lib/usecases/auth" -import { geminiAuthRoot } from "@effect-template/lib/usecases/auth-gemini-helpers" -import type { AppError } from "@effect-template/lib/usecases/errors" -import { renderError } from "@effect-template/lib/usecases/errors" +} from "@lib/usecases/auth" +import { geminiAuthRoot } from "@lib/usecases/auth-gemini-helpers" +import type { AppError } from "@lib/usecases/errors" +import { renderError } from "@lib/usecases/errors" import { readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js" import { pauseOnError, resumeSshWithSkipInputs, withSuspendedTui } from "./menu-shared.js" diff --git a/packages/app/src/docker-git/menu-auth-helpers.ts b/packages/app/src/docker-git/menu-auth-helpers.ts index a5c10737..3e3a34be 100644 --- a/packages/app/src/docker-git/menu-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-auth-helpers.ts @@ -2,7 +2,7 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" -import type { AppError } from "@effect-template/lib/usecases/errors" +import type { AppError } from "@lib/usecases/errors" export const countAuthAccountDirectories = ( fs: FileSystem.FileSystem, diff --git a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts index e3b14fc2..deee36d3 100644 --- a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts +++ b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts @@ -2,7 +2,7 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect, pipe } from "effect" -import type { AppError } from "@effect-template/lib/usecases/errors" +import type { AppError } from "@lib/usecases/errors" import { countAuthAccountDirectories } from "./menu-auth-helpers.js" export type AuthAccountCounts = { diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts index 7089e6ff..d860d6a3 100644 --- a/packages/app/src/docker-git/menu-auth.ts +++ b/packages/app/src/docker-git/menu-auth.ts @@ -1,6 +1,6 @@ import { Effect, pipe } from "effect" -import type { AppError } from "@effect-template/lib/usecases/errors" +import type { AppError } from "@lib/usecases/errors" import { type AuthMenuAction, diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 9a5223ef..3b95bd74 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -1,8 +1,8 @@ -import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain" -import { createProject } from "@effect-template/lib/usecases/actions" -import type { AppError } from "@effect-template/lib/usecases/errors" -import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" import * as Path from "@effect/platform/Path" +import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@lib/core/domain" +import { createProject } from "@lib/usecases/actions" +import type { AppError } from "@lib/usecases/errors" +import { defaultProjectsRoot } from "@lib/usecases/menu-helpers" import { Effect, Either, Match, pipe } from "effect" import { parseArgs } from "./cli/parser.js" import { formatParseError, usageText } from "./cli/usage.js" diff --git a/packages/app/src/docker-git/menu-labeled-env.ts b/packages/app/src/docker-git/menu-labeled-env.ts index c8ff1d5b..1e69b069 100644 --- a/packages/app/src/docker-git/menu-labeled-env.ts +++ b/packages/app/src/docker-git/menu-labeled-env.ts @@ -1,4 +1,4 @@ -import { parseEnvEntries } from "@effect-template/lib/usecases/env-file" +import { parseEnvEntries } from "@lib/usecases/env-file" export const normalizeLabel = (value: string): string => { const trimmed = value.trim() diff --git a/packages/app/src/docker-git/menu-menu.ts b/packages/app/src/docker-git/menu-menu.ts index 0136b369..df4089d1 100644 --- a/packages/app/src/docker-git/menu-menu.ts +++ b/packages/app/src/docker-git/menu-menu.ts @@ -1,5 +1,5 @@ -import { parseMenuSelection } from "@effect-template/lib/core/domain" -import { isRepoUrlInput } from "@effect-template/lib/usecases/menu-helpers" +import { parseMenuSelection } from "@lib/core/domain" +import { isRepoUrlInput } from "@lib/usecases/menu-helpers" import { Either } from "effect" import { handleMenuActionSelection, type MenuSelectionContext } from "./menu-actions.js" diff --git a/packages/app/src/docker-git/menu-project-auth-data.ts b/packages/app/src/docker-git/menu-project-auth-data.ts index 778f2eea..8e40590c 100644 --- a/packages/app/src/docker-git/menu-project-auth-data.ts +++ b/packages/app/src/docker-git/menu-project-auth-data.ts @@ -2,11 +2,11 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect, Match, pipe } from "effect" -import { ensureEnvFile, findEnvValue, readEnvText } from "@effect-template/lib/usecases/env-file" -import type { AppError } from "@effect-template/lib/usecases/errors" -import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" -import { autoSyncState } from "@effect-template/lib/usecases/state-repo" +import { ensureEnvFile, findEnvValue, readEnvText } from "@lib/usecases/env-file" +import type { AppError } from "@lib/usecases/errors" +import { defaultProjectsRoot } from "@lib/usecases/menu-helpers" +import type { ProjectItem } from "@lib/usecases/projects" +import { autoSyncState } from "@lib/usecases/state-repo" import { countAuthAccountEntries } from "./menu-auth-snapshot-builder.js" import { countKeyEntries, normalizeLabel } from "./menu-labeled-env.js" diff --git a/packages/app/src/docker-git/menu-project-auth-flows.ts b/packages/app/src/docker-git/menu-project-auth-flows.ts index 2e52120c..6e08210d 100644 --- a/packages/app/src/docker-git/menu-project-auth-flows.ts +++ b/packages/app/src/docker-git/menu-project-auth-flows.ts @@ -2,10 +2,10 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import { Effect, Match } from "effect" -import { AuthError } from "@effect-template/lib/shell/errors" -import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers" -import { findEnvValue, upsertEnvKey } from "@effect-template/lib/usecases/env-file" -import type { AppError } from "@effect-template/lib/usecases/errors" +import { AuthError } from "@lib/shell/errors" +import { normalizeAccountLabel } from "@lib/usecases/auth-helpers" +import { findEnvValue, upsertEnvKey } from "@lib/usecases/env-file" +import type { AppError } from "@lib/usecases/errors" import { buildLabeledEnvKey } from "./menu-labeled-env.js" import { hasClaudeAccountCredentials } from "./menu-project-auth-claude.js" diff --git a/packages/app/src/docker-git/menu-project-auth.ts b/packages/app/src/docker-git/menu-project-auth.ts index c8dc1ee1..6b9b50cc 100644 --- a/packages/app/src/docker-git/menu-project-auth.ts +++ b/packages/app/src/docker-git/menu-project-auth.ts @@ -1,7 +1,7 @@ import { Effect, Match, pipe } from "effect" -import type { AppError } from "@effect-template/lib/usecases/errors" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { AppError } from "@lib/usecases/errors" +import type { ProjectItem } from "@lib/usecases/projects" import { nextBufferValue } from "./menu-buffer-input.js" import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js" diff --git a/packages/app/src/docker-git/menu-render-select.ts b/packages/app/src/docker-git/menu-render-select.ts index daa1aef7..6cfeb939 100644 --- a/packages/app/src/docker-git/menu-render-select.ts +++ b/packages/app/src/docker-git/menu-render-select.ts @@ -2,7 +2,7 @@ import { Match } from "effect" import { Text } from "ink" import type React from "react" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" import type { SelectProjectRuntime } from "./menu-types.js" export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete" | "Auth" diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 3318ea6f..cd34d29e 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -2,7 +2,7 @@ import { Match } from "effect" import { Box, Text } from "ink" import React from "react" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" import { renderLayout } from "./menu-render-layout.js" import { buildSelectLabels, diff --git a/packages/app/src/docker-git/menu-select-actions.ts b/packages/app/src/docker-git/menu-select-actions.ts index 46515bb7..93adbac8 100644 --- a/packages/app/src/docker-git/menu-select-actions.ts +++ b/packages/app/src/docker-git/menu-select-actions.ts @@ -1,13 +1,13 @@ -import { runDockerComposeDown } from "@effect-template/lib/shell/docker" -import type { AppError } from "@effect-template/lib/usecases/errors" -import { renderError } from "@effect-template/lib/usecases/errors" -import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" +import { runDockerComposeDown } from "@lib/shell/docker" +import type { AppError } from "@lib/usecases/errors" +import { renderError } from "@lib/usecases/errors" +import { mcpPlaywrightUp } from "@lib/usecases/mcp-playwright" import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems, type ProjectItem -} from "@effect-template/lib/usecases/projects" +} from "@lib/usecases/projects" import { Effect, pipe } from "effect" import { openProjectAuthMenu } from "./menu-project-auth.js" diff --git a/packages/app/src/docker-git/menu-select-connect.ts b/packages/app/src/docker-git/menu-select-connect.ts index 9c541c23..f4eb49b1 100644 --- a/packages/app/src/docker-git/menu-select-connect.ts +++ b/packages/app/src/docker-git/menu-select-connect.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" type ConnectDeps = { readonly connectWithUp: ( diff --git a/packages/app/src/docker-git/menu-select-load.ts b/packages/app/src/docker-git/menu-select-load.ts index 4e6cec4b..cd749e47 100644 --- a/packages/app/src/docker-git/menu-select-load.ts +++ b/packages/app/src/docker-git/menu-select-load.ts @@ -1,4 +1,4 @@ -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" import { Effect, pipe } from "effect" import { loadRuntimeByProject } from "./menu-select-runtime.js" diff --git a/packages/app/src/docker-git/menu-select-order.ts b/packages/app/src/docker-git/menu-select-order.ts index 6d703bbd..44bc0190 100644 --- a/packages/app/src/docker-git/menu-select-order.ts +++ b/packages/app/src/docker-git/menu-select-order.ts @@ -1,4 +1,4 @@ -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" import type { SelectProjectRuntime } from "./menu-types.js" diff --git a/packages/app/src/docker-git/menu-select-runtime.ts b/packages/app/src/docker-git/menu-select-runtime.ts index 4ac331fc..27c3727e 100644 --- a/packages/app/src/docker-git/menu-select-runtime.ts +++ b/packages/app/src/docker-git/menu-select-runtime.ts @@ -1,6 +1,6 @@ -import { runCommandCapture } from "@effect-template/lib/shell/command-runner" -import { runDockerPsNames } from "@effect-template/lib/shell/docker" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { runCommandCapture } from "@lib/shell/command-runner" +import { runDockerPsNames } from "@lib/shell/docker" +import type { ProjectItem } from "@lib/usecases/projects" import { Effect, pipe } from "effect" import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js" diff --git a/packages/app/src/docker-git/menu-select-view.ts b/packages/app/src/docker-git/menu-select-view.ts index 023b9750..d40c8180 100644 --- a/packages/app/src/docker-git/menu-select-view.ts +++ b/packages/app/src/docker-git/menu-select-view.ts @@ -1,4 +1,4 @@ -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" import { sortItemsByLaunchTime } from "./menu-select-order.js" import type { MenuViewContext, SelectProjectRuntime } from "./menu-types.js" diff --git a/packages/app/src/docker-git/menu-startup.ts b/packages/app/src/docker-git/menu-startup.ts index 277624cd..f49139fd 100644 --- a/packages/app/src/docker-git/menu-startup.ts +++ b/packages/app/src/docker-git/menu-startup.ts @@ -1,4 +1,4 @@ -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { ProjectItem } from "@lib/usecases/projects" export type MenuStartupSnapshot = { readonly activeDir: string | null diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 16eb3885..249c1184 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -3,9 +3,9 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import type * as Effect from "effect/Effect" -import type { MenuAction } from "@effect-template/lib/core/domain" -import type { AppError } from "@effect-template/lib/usecases/errors" -import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { MenuAction } from "@lib/core/domain" +import type { AppError } from "@lib/usecases/errors" +import type { ProjectItem } from "@lib/usecases/projects" // CHANGE: isolate TUI types/constants into a shared module // WHY: keep menu rendering and input handling small and focused diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index b314f95d..4b69433b 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -1,8 +1,8 @@ -import { runDockerPsNames } from "@effect-template/lib/shell/docker" -import { type InputCancelledError, InputReadError } from "@effect-template/lib/shell/errors" -import { type AppError, renderError } from "@effect-template/lib/usecases/errors" -import { listProjectItems, listProjectStatus } from "@effect-template/lib/usecases/projects" import { NodeContext } from "@effect/platform-node" +import { runDockerPsNames } from "@lib/shell/docker" +import { type InputCancelledError, InputReadError } from "@lib/shell/errors" +import { type AppError, renderError } from "@lib/usecases/errors" +import { listProjectItems, listProjectStatus } from "@lib/usecases/projects" import { Effect, pipe } from "effect" import { render, useApp, useInput } from "ink" import React, { useEffect, useMemo, useState } from "react" diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 34124dc4..0e7beba1 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -1,180 +1,282 @@ -import type { Command, ParseError } from "@effect-template/lib/core/domain" -import { createProject } from "@effect-template/lib/usecases/actions" -import { applyProjectConfig } from "@effect-template/lib/usecases/apply" -import { - authClaudeLogin, - authClaudeLogout, - authClaudeStatus, - authCodexLogin, - authCodexLogout, - authCodexStatus, - authGeminiLoginCli, - authGeminiLoginOauth, - authGeminiLogout, - authGeminiStatus, - authGithubLogin, - authGithubLogout, - authGithubStatus -} from "@effect-template/lib/usecases/auth" -import type { AppError } from "@effect-template/lib/usecases/errors" -import { renderError } from "@effect-template/lib/usecases/errors" -import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" -import { - applyAllDockerGitProjects, - downAllDockerGitProjects, - listProjectStatus -} from "@effect-template/lib/usecases/projects" -import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" -import { - sessionGistBackup, - sessionGistDownload, - sessionGistList, - sessionGistView -} from "@effect-template/lib/usecases/session-gists" -import { - autoPullState, - stateCommit, - stateInit, - statePath, - statePull, - statePush, - stateStatus, - stateSync -} from "@effect-template/lib/usecases/state-repo" -import { - killTerminalProcess, - listTerminalSessions, - tailTerminalLogs -} from "@effect-template/lib/usecases/terminal-sessions" +import type { Command } from "@lib/core/domain" import { Effect, Match, pipe } from "effect" + +import { + type ApiProjectDetails, + type ApiProjectSummary, + applyAllProjects, + createProject, + downAllProjects, + githubLogin, + githubLogout, + githubStatus, + listProjects, + renderJsonPayload, + renderProjectSummaryLine +} from "./api-client.js" import { readCommand } from "./cli/read-command.js" -import { attachTmux, listTmuxPanes } from "./tmux.js" +import { usageText } from "./cli/usage.js" +import { ensureControllerReady } from "./controller.js" +import type { CliError, UnsupportedCommandError } from "./host-errors.js" +import { renderCliError } from "./host-errors.js" -import { runMenu } from "./menu.js" +type OperationalCommand = Exclude +type UnsupportedOperationalCommandTag = + | "Menu" + | "Attach" + | "Panes" + | "SessionsList" + | "SessionsKill" + | "SessionsLogs" + | "ScrapExport" + | "ScrapImport" + | "McpPlaywrightUp" + | "Apply" + | "SessionGistBackup" + | "SessionGistList" + | "SessionGistView" + | "SessionGistDownload" + | "StatePath" + | "StateInit" + | "StateStatus" + | "StatePull" + | "StateCommit" + | "StatePush" + | "StateSync" + | "AuthCodexLogin" + | "AuthCodexStatus" + | "AuthCodexLogout" + | "AuthClaudeLogin" + | "AuthClaudeStatus" + | "AuthClaudeLogout" + | "AuthGeminiLogin" + | "AuthGeminiStatus" + | "AuthGeminiLogout" -const isParseError = (error: AppError): error is ParseError => - error._tag === "UnknownCommand" || - error._tag === "UnknownOption" || - error._tag === "MissingOptionValue" || - error._tag === "MissingRequiredOption" || - error._tag === "InvalidOption" || - error._tag === "UnexpectedArgument" +type UnsupportedOperationalCommand = Extract< + OperationalCommand, + { readonly _tag: UnsupportedOperationalCommandTag } +> const setExitCode = (code: number) => Effect.sync(() => { process.exitCode = code }) -const logWarningAndExit = (error: AppError) => +const logAndExit = (error: CliError, level: "warning" | "error" = "error") => pipe( - Effect.logWarning(renderError(error)), + level === "warning" ? Effect.logWarning(renderCliError(error)) : Effect.logError(renderCliError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid ) -const logErrorAndExit = (error: AppError) => +const unsupported = (command: string, message: string): Effect.Effect => + Effect.fail({ + _tag: "UnsupportedCommandError", + command, + message + }) + +const withControllerReady = ( + effect: Effect.Effect +) => pipe( - Effect.logError(renderError(error)), - Effect.tap(() => setExitCode(1)), - Effect.asVoid + ensureControllerReady, + Effect.zipRight(effect) ) -type NonBaseCommand = Exclude< - Command, - | { readonly _tag: "Help" } - | { readonly _tag: "Create" } - | { readonly _tag: "Status" } - | { readonly _tag: "DownAll" } - | { readonly _tag: "ApplyAll" } - | { readonly _tag: "Menu" } -> +const renderProjectList = (projects: ReadonlyArray) => + Effect.gen(function*(_) { + if (projects.length === 0) { + yield* _(Effect.log("No docker-git projects found.")) + return + } + + yield* _(Effect.log(`Found ${projects.length} docker-git project(s):`)) + for (const project of projects) { + yield* _(Effect.log(renderProjectSummaryLine(project))) + } + }) + +const renderCreateResult = (project: ApiProjectDetails | null) => + Effect.gen(function*(_) { + if (project === null) { + yield* _(Effect.log("Project created.")) + return + } + + yield* _(Effect.log(`Project created: ${project.displayName}`)) + yield* _(Effect.log(`Project ID: ${project.id}`)) + yield* _(Effect.log(`Status: ${project.statusLabel}`)) + }) + +const handleCreateCommand = (command: Extract) => + withControllerReady( + pipe(createProject(command), Effect.flatMap((project) => renderCreateResult(project))) + ) + +const handleStatusCommand = () => + withControllerReady(pipe(listProjects(), Effect.flatMap((projects) => renderProjectList(projects)))) + +const handleDownAllCommand = () => + withControllerReady(pipe(downAllProjects(), Effect.zipRight(Effect.log("All docker-git projects were stopped.")))) -const handleNonBaseCommand = (command: NonBaseCommand) => - Match.value(command) - .pipe( - Match.when({ _tag: "StatePath" }, () => statePath), - Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)), - Match.when({ _tag: "StateStatus" }, () => stateStatus), - Match.when({ _tag: "StatePull" }, () => statePull), - Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)), - Match.when({ _tag: "StatePush" }, () => statePush), - Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)), - Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)), - Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)), - Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)), - Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)), - Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)), - Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)), - Match.when({ _tag: "AuthClaudeLogin" }, (cmd) => authClaudeLogin(cmd)), - Match.when({ _tag: "AuthClaudeStatus" }, (cmd) => authClaudeStatus(cmd)), - Match.when({ _tag: "AuthClaudeLogout" }, (cmd) => authClaudeLogout(cmd)), - Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)), - Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)), - Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)) +const handleApplyAllCommand = (command: Extract) => + withControllerReady( + pipe( + applyAllProjects(command.activeOnly), + Effect.zipRight( + Effect.log( + command.activeOnly + ? "Applied docker-git config to running projects." + : "Applied docker-git config to all projects." + ) + ) ) - .pipe( - Match.when({ _tag: "AuthGeminiLogin" }, (cmd) => cmd.isWeb ? authGeminiLoginOauth(cmd) : authGeminiLoginCli(cmd)), - Match.when({ _tag: "AuthGeminiStatus" }, (cmd) => authGeminiStatus(cmd)), - Match.when({ _tag: "AuthGeminiLogout" }, (cmd) => authGeminiLogout(cmd)), - Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)), - Match.when({ _tag: "Apply" }, (cmd) => applyProjectConfig(cmd)), - Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), - Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), - Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)), - Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)), - Match.when({ _tag: "SessionGistBackup" }, (cmd) => sessionGistBackup(cmd)), - Match.when({ _tag: "SessionGistList" }, (cmd) => sessionGistList(cmd)), - Match.when({ _tag: "SessionGistView" }, (cmd) => sessionGistView(cmd)), - Match.when({ _tag: "SessionGistDownload" }, (cmd) => sessionGistDownload(cmd)), - Match.exhaustive + ) + +const handleGithubLoginCommand = (command: Extract) => + withControllerReady( + pipe(githubLogin(command), Effect.flatMap((payload) => Effect.log(renderJsonPayload(payload)))) + ) + +const handleGithubStatusCommand = (command: Extract) => + withControllerReady( + pipe(githubStatus(command), Effect.flatMap((payload) => Effect.log(renderJsonPayload(payload)))) + ) + +const handleGithubLogoutCommand = ( + command: Extract +) => + withControllerReady( + pipe( + githubLogout(command), + Effect.zipRight(Effect.log("GitHub auth removed from controller state.")) ) + ) + +const unsupportedOperationalCommands: Record< + UnsupportedOperationalCommandTag, + { readonly command: string; readonly message: string } +> = { + Menu: { + command: "menu", + message: "Interactive menu is not available in API-only host mode. Use `docker-git status` or `docker-git create`." + }, + Attach: { command: "attach", message: "Host-side SSH attach is disabled in API-only mode." }, + Panes: { command: "panes", message: "Host-side pane inspection is disabled in API-only mode." }, + SessionsList: { command: "sessions", message: "Terminal session inspection is disabled in API-only mode." }, + SessionsKill: { command: "sessions kill", message: "Terminal session control is disabled in API-only mode." }, + SessionsLogs: { command: "sessions logs", message: "Terminal session log access is disabled in API-only mode." }, + ScrapExport: { command: "scrap export", message: "Scrap export is disabled in API-only host mode." }, + ScrapImport: { command: "scrap import", message: "Scrap import is disabled in API-only host mode." }, + McpPlaywrightUp: { + command: "mcp-playwright", + message: "Playwright sidecar management is disabled in API-only host mode." + }, + Apply: { + command: "Apply", + message: "Command Apply is not available in API-only host mode." + }, + SessionGistBackup: { + command: "session-gists backup", + message: "Session gist backup is disabled in API-only host mode." + }, + SessionGistList: { + command: "session-gists list", + message: "Session gist list is disabled in API-only host mode." + }, + SessionGistView: { + command: "session-gists view", + message: "Session gist view is disabled in API-only host mode." + }, + SessionGistDownload: { + command: "session-gists download", + message: "Session gist download is disabled in API-only host mode." + }, + StatePath: { command: "state path", message: "Host state commands are disabled in API-only mode." }, + StateInit: { command: "state init", message: "Host state commands are disabled in API-only mode." }, + StateStatus: { command: "state status", message: "Host state commands are disabled in API-only mode." }, + StatePull: { command: "state pull", message: "Host state commands are disabled in API-only mode." }, + StateCommit: { command: "state commit", message: "Host state commands are disabled in API-only mode." }, + StatePush: { command: "state push", message: "Host state commands are disabled in API-only mode." }, + StateSync: { command: "state sync", message: "Host state commands are disabled in API-only mode." }, + AuthCodexLogin: { + command: "auth codex login", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthCodexStatus: { + command: "auth codex status", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthCodexLogout: { + command: "auth codex logout", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthClaudeLogin: { + command: "auth claude login", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthClaudeStatus: { + command: "auth claude status", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthClaudeLogout: { + command: "auth claude logout", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthGeminiLogin: { + command: "auth gemini login", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthGeminiStatus: { + command: "auth gemini status", + message: "Only GitHub auth is routed through the controller in host API mode." + }, + AuthGeminiLogout: { + command: "auth gemini logout", + message: "Only GitHub auth is routed through the controller in host API mode." + } +} -// CHANGE: compose CLI program with typed errors and shell effects; auto-pull .docker-git on startup -// WHY: keep a thin entry layer over pure parsing and template generation; ensure state is fresh -// QUOTE(ТЗ): "Сделать что бы когда вызывается команда docker-git то происходит git pull для .docker-git папки" -// REF: issue-178 +const unsupportedOperationalCommand = ( + command: UnsupportedOperationalCommand +): Effect.Effect => { + const spec = unsupportedOperationalCommands[command._tag] + return unsupported(spec.command, spec.message) +} + +const dispatchOperationalCommand = (command: OperationalCommand) => + Match.value(command).pipe( + Match.when({ _tag: "Create" }, handleCreateCommand), + Match.when({ _tag: "Status" }, handleStatusCommand), + Match.when({ _tag: "DownAll" }, handleDownAllCommand), + Match.when({ _tag: "ApplyAll" }, handleApplyAllCommand), + Match.when({ _tag: "AuthGithubLogin" }, handleGithubLoginCommand), + Match.when({ _tag: "AuthGithubStatus" }, handleGithubStatusCommand), + Match.when({ _tag: "AuthGithubLogout" }, handleGithubLogoutCommand), + Match.orElse((unsupported) => unsupportedOperationalCommand(unsupported)) + ) + +// CHANGE: route host CLI commands through the API controller only +// WHY: host must not read local .docker-git state or execute project lifecycle directly +// QUOTE(ТЗ): "app(cli) инструмент общается только с API" +// REF: user-request-2026-04-01-api-only-host // SOURCE: n/a -// FORMAT THEOREM: forall cmd: autoPull() *> handle(cmd) terminates with typed outcome +// FORMAT THEOREM: forall cmd: operational(cmd) -> api(cmd) // PURITY: SHELL -// EFFECT: Effect -// INVARIANT: auto-pull never blocks command execution; help is printed without side effects beyond logs -// COMPLEXITY: O(n) where n = |files| +// EFFECT: Effect +// INVARIANT: help remains local; unsupported commands fail explicitly +// COMPLEXITY: O(1) per command plus API round-trips export const program = pipe( - autoPullState, - Effect.flatMap(() => readCommand), + readCommand, Effect.flatMap((command: Command) => - Match.value(command).pipe( - Match.when({ _tag: "Help" }, ({ message }) => Effect.log(message)), - Match.when({ _tag: "Create" }, (create) => createProject(create)), - Match.when({ _tag: "Status" }, () => listProjectStatus), - Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), - Match.when({ _tag: "ApplyAll" }, (cmd) => applyAllDockerGitProjects(cmd)), - Match.when({ _tag: "Menu" }, () => runMenu), - Match.orElse((cmd) => handleNonBaseCommand(cmd)) - ) + command._tag === "Help" + ? Effect.log(usageText) + : dispatchOperationalCommand(command) ), - Effect.catchTag("FileExistsError", (error) => - pipe( - Effect.logWarning(renderError(error)), - Effect.asVoid - )), - Effect.catchTag("DockerAccessError", logWarningAndExit), - Effect.catchTag("DockerCommandError", logWarningAndExit), - Effect.catchTag("AuthError", logWarningAndExit), - Effect.catchTag("AgentFailedError", logWarningAndExit), - Effect.catchTag("CommandFailedError", logWarningAndExit), - Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit), - Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit), - Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit), Effect.matchEffect({ - onFailure: (error) => - isParseError(error) - ? logErrorAndExit(error) - : pipe( - Effect.logError(renderError(error)), - Effect.flatMap(() => Effect.fail(error)) - ), + onFailure: (error: CliError) => logAndExit(error), onSuccess: () => Effect.void - }), - Effect.asVoid + }) ) diff --git a/packages/app/src/docker-git/tmux.ts b/packages/app/src/docker-git/tmux.ts index a2434fab..b07ff26c 100644 --- a/packages/app/src/docker-git/tmux.ts +++ b/packages/app/src/docker-git/tmux.ts @@ -4,26 +4,22 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect, pipe } from "effect" -import type { AttachCommand, PanesCommand } from "@effect-template/lib/core/domain" -import { deriveRepoPathParts, deriveRepoSlug } from "@effect-template/lib/core/domain" -import { - runCommandCapture, - runCommandExitCode, - runCommandWithExitCodes -} from "@effect-template/lib/shell/command-runner" -import { readProjectConfig } from "@effect-template/lib/shell/config" +import type { AttachCommand, PanesCommand } from "@lib/core/domain" +import { deriveRepoPathParts, deriveRepoSlug } from "@lib/core/domain" +import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "@lib/shell/command-runner" +import { readProjectConfig } from "@lib/shell/config" import type { ConfigDecodeError, ConfigNotFoundError, DockerCommandError, FileExistsError, PortProbeError -} from "@effect-template/lib/shell/errors" -import { CommandFailedError } from "@effect-template/lib/shell/errors" -import { resolveBaseDir } from "@effect-template/lib/shell/paths" -import { findSshPrivateKey } from "@effect-template/lib/usecases/path-helpers" -import { buildSshCommand } from "@effect-template/lib/usecases/projects" -import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up" +} from "@lib/shell/errors" +import { CommandFailedError } from "@lib/shell/errors" +import { resolveBaseDir } from "@lib/shell/paths" +import { findSshPrivateKey } from "@lib/usecases/path-helpers" +import { buildSshCommand } from "@lib/usecases/projects" +import { runDockerComposeUpWithPortCheck } from "@lib/usecases/projects-up" const tmuxOk = [0] const layoutVersion = "v14" diff --git a/packages/app/src/lib/core/auth-domain.ts b/packages/app/src/lib/core/auth-domain.ts new file mode 100644 index 00000000..cc738dde --- /dev/null +++ b/packages/app/src/lib/core/auth-domain.ts @@ -0,0 +1,99 @@ +/* jscpd:ignore-start */ +export interface AuthGithubLoginCommand { + readonly _tag: "AuthGithubLogin" + readonly label: string | null + readonly token: string | null + readonly scopes: string | null + readonly envGlobalPath: string +} + +export interface AuthGithubStatusCommand { + readonly _tag: "AuthGithubStatus" + readonly envGlobalPath: string +} + +export interface AuthGithubLogoutCommand { + readonly _tag: "AuthGithubLogout" + readonly label: string | null + readonly envGlobalPath: string +} + +export interface AuthCodexLoginCommand { + readonly _tag: "AuthCodexLogin" + readonly label: string | null + readonly codexAuthPath: string +} + +export interface AuthCodexStatusCommand { + readonly _tag: "AuthCodexStatus" + readonly label: string | null + readonly codexAuthPath: string +} + +export interface AuthCodexLogoutCommand { + readonly _tag: "AuthCodexLogout" + readonly label: string | null + readonly codexAuthPath: string +} + +export interface AuthClaudeLoginCommand { + readonly _tag: "AuthClaudeLogin" + readonly label: string | null + readonly claudeAuthPath: string +} + +export interface AuthClaudeStatusCommand { + readonly _tag: "AuthClaudeStatus" + readonly label: string | null + readonly claudeAuthPath: string +} + +export interface AuthClaudeLogoutCommand { + readonly _tag: "AuthClaudeLogout" + readonly label: string | null + readonly claudeAuthPath: string +} + +// CHANGE: add Gemini CLI auth commands +// WHY: enable Gemini CLI authentication management similar to Claude/Codex +// QUOTE(ТЗ): "Добавь поддержку gemini CLI" +// REF: issue-146 +// SOURCE: https://geminicli.com/docs/get-started/authentication/ +// FORMAT THEOREM: forall cmd ∈ AuthGeminiCommand: cmd.geminiAuthPath is valid path +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: authentication state is isolated by label +// COMPLEXITY: O(1) +export interface AuthGeminiLoginCommand { + readonly _tag: "AuthGeminiLogin" + readonly label: string | null + readonly geminiAuthPath: string + readonly isWeb: boolean +} + +export interface AuthGeminiStatusCommand { + readonly _tag: "AuthGeminiStatus" + readonly label: string | null + readonly geminiAuthPath: string +} + +export interface AuthGeminiLogoutCommand { + readonly _tag: "AuthGeminiLogout" + readonly label: string | null + readonly geminiAuthPath: string +} + +export type AuthCommand = + | AuthGithubLoginCommand + | AuthGithubStatusCommand + | AuthGithubLogoutCommand + | AuthCodexLoginCommand + | AuthCodexStatusCommand + | AuthCodexLogoutCommand + | AuthClaudeLoginCommand + | AuthClaudeStatusCommand + | AuthClaudeLogoutCommand + | AuthGeminiLoginCommand + | AuthGeminiStatusCommand + | AuthGeminiLogoutCommand +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/auto-agent-flags.ts b/packages/app/src/lib/core/auto-agent-flags.ts new file mode 100644 index 00000000..c1683f36 --- /dev/null +++ b/packages/app/src/lib/core/auto-agent-flags.ts @@ -0,0 +1,26 @@ +/* jscpd:ignore-start */ +import { Either } from "effect" + +import type { RawOptions } from "./command-options.js" +import type { AgentMode, ParseError } from "./domain.js" + +export const resolveAutoAgentFlags = ( + raw: RawOptions +): Either.Either<{ readonly agentMode: AgentMode | undefined; readonly agentAuto: boolean }, ParseError> => { + const requested = raw.agentAutoMode + if (requested === undefined) { + return Either.right({ agentMode: undefined, agentAuto: false }) + } + if (requested === "auto") { + return Either.right({ agentMode: undefined, agentAuto: true }) + } + if (requested === "claude" || requested === "codex") { + return Either.right({ agentMode: requested, agentAuto: true }) + } + return Either.left({ + _tag: "InvalidOption", + option: "--auto", + reason: "expected one of: claude, codex" + }) +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/clone.ts b/packages/app/src/lib/core/clone.ts new file mode 100644 index 00000000..ab4948ad --- /dev/null +++ b/packages/app/src/lib/core/clone.ts @@ -0,0 +1,62 @@ +/* jscpd:ignore-start */ +export type CloneRequest = + | { readonly _tag: "Clone"; readonly args: ReadonlyArray } + | { readonly _tag: "Open"; readonly args: ReadonlyArray } + | { readonly _tag: "None" } + +const emptyRequest: CloneRequest = { _tag: "None" } + +const toCloneRequest = (args: ReadonlyArray): CloneRequest => ({ + _tag: "Clone", + args +}) + +const toOpenRequest = (args: ReadonlyArray): CloneRequest => ({ + _tag: "Open", + args +}) + +const resolveLifecycleArgs = ( + argv: ReadonlyArray, + command: "clone" | "open" +): ReadonlyArray => { + if (argv.length === 0) { + return [] + } + const [first, ...rest] = argv + return first === command ? rest : argv +} + +// CHANGE: resolve clone/open shortcut requests from argv + npm lifecycle metadata +// WHY: support pnpm run clone/open without requiring "--" +// QUOTE(ТЗ): "Добавить команду open. ... Просто открывает существующий по ссылке" +// REF: user-request-2026-01-27 +// SOURCE: n/a +// FORMAT THEOREM: forall a,e: resolve(a,e) -> deterministic +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: command requested only when argv[0] or npmLifecycleEvent is clone/open +// COMPLEXITY: O(n) +export const resolveCloneRequest = ( + argv: ReadonlyArray, + npmLifecycleEvent: string | undefined +): CloneRequest => { + if (npmLifecycleEvent === "clone") { + return toCloneRequest(resolveLifecycleArgs(argv, "clone")) + } + + if (npmLifecycleEvent === "open") { + return toOpenRequest(resolveLifecycleArgs(argv, "open")) + } + + if (argv.length > 0 && argv[0] === "clone") { + return toCloneRequest(argv.slice(1)) + } + + if (argv.length > 0 && argv[0] === "open") { + return toOpenRequest(argv.slice(1)) + } + + return emptyRequest +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders-shared.ts b/packages/app/src/lib/core/command-builders-shared.ts new file mode 100644 index 00000000..984811d9 --- /dev/null +++ b/packages/app/src/lib/core/command-builders-shared.ts @@ -0,0 +1,55 @@ +/* jscpd:ignore-start */ +import { Either } from "effect" + +import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, type ParseError } from "./domain.js" + +const parsePort = (value: string): Either.Either => { + const parsed = Number(value) + if (!Number.isInteger(parsed)) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-port", + reason: `expected integer, got: ${value}` + }) + } + if (parsed < 1 || parsed > 65_535) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-port", + reason: "must be between 1 and 65535" + }) + } + return Either.right(parsed) +} + +export const parseSshPort = (value: string): Either.Either => parsePort(value) + +export const parseDockerNetworkMode = ( + value: string | undefined +): Either.Either => { + const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode + if (isDockerNetworkMode(candidate)) { + return Either.right(candidate) + } + return Either.left({ + _tag: "InvalidOption", + option: "--network-mode", + reason: "expected one of: shared, project" + }) +} + +export const nonEmpty = ( + option: string, + value: string | undefined, + fallback?: string +): Either.Either => { + const candidate = value?.trim() ?? fallback + if (candidate === undefined || candidate.length === 0) { + return Either.left({ + _tag: "MissingRequiredOption", + option + }) + } + return Either.right(candidate) +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders.ts b/packages/app/src/lib/core/command-builders.ts new file mode 100644 index 00000000..f9f39e35 --- /dev/null +++ b/packages/app/src/lib/core/command-builders.ts @@ -0,0 +1,306 @@ +/* jscpd:ignore-start */ +import { Either } from "effect" + +import { expandContainerHome } from "../usecases/scrap-path.js" +import { resolveAutoAgentFlags } from "./auto-agent-flags.js" +import { nonEmpty, parseDockerNetworkMode, parseSshPort } from "./command-builders-shared.js" +import { type RawOptions } from "./command-options.js" +import { + type AgentMode, + type CreateCommand, + defaultCpuLimit, + defaultRamLimit, + defaultTemplateConfig, + deriveRepoPathParts, + deriveRepoSlug, + type ParseError, + resolveRepoInput +} from "./domain.js" +import { normalizeCpuLimit, normalizeRamLimit } from "./resource-limits.js" +import { trimRightChar } from "./strings.js" +import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js" + +export { nonEmpty } from "./command-builders-shared.js" + +const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/") + +type RepoBasics = { + readonly repoUrl: string + readonly repoSlug: string + readonly projectSlug: string + readonly repoPath: string + readonly repoRef: string + readonly targetDir: string + readonly sshUser: string + readonly sshPort: number +} + +const resolveRepoBasics = (raw: RawOptions): Either.Either => + Either.gen(function*(_) { + const rawRepoUrl = raw.repoUrl?.trim() ?? "" + const resolvedRepo = resolveRepoInput(rawRepoUrl) + const repoUrl = resolvedRepo.repoUrl + const repoSlug = deriveRepoSlug(repoUrl) + const repoPathParts = deriveRepoPathParts(repoUrl).pathParts + const workspaceSuffix = resolvedRepo.workspaceSuffix + const projectSlug = workspaceSuffix ? `${repoSlug}-${workspaceSuffix}` : repoSlug + const repoPath = workspaceSuffix ? [...repoPathParts, workspaceSuffix].join("/") : repoPathParts.join("/") + const repoRef = yield* _( + nonEmpty("--repo-ref", raw.repoRef ?? resolvedRepo.repoRef, defaultTemplateConfig.repoRef) + ) + const sshUser = yield* _(nonEmpty("--ssh-user", raw.sshUser, defaultTemplateConfig.sshUser)) + const rawTargetDir = yield* _( + nonEmpty("--target-dir", raw.targetDir, defaultTemplateConfig.targetDir) + ) + const targetDir = expandContainerHome(sshUser, rawTargetDir) + const sshPort = yield* _(parseSshPort(raw.sshPort ?? String(defaultTemplateConfig.sshPort))) + + return { repoUrl, repoSlug, projectSlug, repoPath, repoRef, targetDir, sshUser, sshPort } + }) + +type NameConfig = { + readonly containerName: string + readonly serviceName: string + readonly volumeName: string +} + +const resolveNames = ( + raw: RawOptions, + projectSlug: string +): Either.Either => + Either.gen(function*(_) { + const derivedContainerName = `dg-${projectSlug}` + const derivedServiceName = `dg-${projectSlug}` + const derivedVolumeName = `dg-${projectSlug}-home` + const containerName = yield* _( + nonEmpty("--container-name", raw.containerName, derivedContainerName) + ) + const serviceName = yield* _(nonEmpty("--service-name", raw.serviceName, derivedServiceName)) + const volumeName = yield* _(nonEmpty("--volume-name", raw.volumeName, derivedVolumeName)) + + return { containerName, serviceName, volumeName } + }) + +type PathConfig = { + readonly dockerGitPath: string + readonly authorizedKeysPath: string + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string + readonly codexSharedAuthPath: string + readonly codexHome: string + readonly geminiAuthPath: string + readonly geminiHome: string + readonly outDir: string +} + +type DefaultPathConfig = { + readonly dockerGitPath: string + readonly authorizedKeysPath: string + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string + readonly geminiAuthPath: string +} + +const resolveNormalizedSecretsRoot = (value: string | undefined): string | undefined => { + const trimmed = value?.trim() ?? "" + return trimmed.length === 0 ? undefined : normalizeSecretsRoot(trimmed) +} + +const buildDefaultPathConfig = ( + normalizedSecretsRoot: string | undefined +): DefaultPathConfig => + normalizedSecretsRoot === undefined + ? { + dockerGitPath: defaultTemplateConfig.dockerGitPath, + authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath, + envGlobalPath: defaultTemplateConfig.envGlobalPath, + envProjectPath: defaultTemplateConfig.envProjectPath, + codexAuthPath: defaultTemplateConfig.codexAuthPath, + geminiAuthPath: defaultTemplateConfig.geminiAuthPath + } + : { + // NOTE: Keep docker-git root mount stable (projects root) so caches like + // `.cache/git-mirrors` remain outside the secrets dir. + dockerGitPath: defaultTemplateConfig.dockerGitPath, + authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath, + envGlobalPath: `${normalizedSecretsRoot}/global.env`, + envProjectPath: defaultTemplateConfig.envProjectPath, + codexAuthPath: `${normalizedSecretsRoot}/codex`, + geminiAuthPath: `${normalizedSecretsRoot}/gemini` + } + +const resolvePaths = ( + raw: RawOptions, + repoPath: string +): Either.Either => + Either.gen(function*(_) { + const normalizedSecretsRoot = resolveNormalizedSecretsRoot(raw.secretsRoot) + const defaults = buildDefaultPathConfig(normalizedSecretsRoot) + const dockerGitPath = defaults.dockerGitPath + const authorizedKeysPath = yield* _( + nonEmpty("--authorized-keys", raw.authorizedKeysPath, defaults.authorizedKeysPath) + ) + const envGlobalPath = yield* _(nonEmpty("--env-global", raw.envGlobalPath, defaults.envGlobalPath)) + const envProjectPath = yield* _( + nonEmpty("--env-project", raw.envProjectPath, defaults.envProjectPath) + ) + const codexAuthPath = yield* _( + nonEmpty("--codex-auth", raw.codexAuthPath, defaults.codexAuthPath) + ) + const codexSharedAuthPath = codexAuthPath + const codexHome = yield* _(nonEmpty("--codex-home", raw.codexHome, defaultTemplateConfig.codexHome)) + const geminiAuthPath = defaults.geminiAuthPath + const geminiHome = defaultTemplateConfig.geminiHome + const outDir = yield* _(nonEmpty("--out-dir", raw.outDir, `.docker-git/${repoPath}`)) + + return { + dockerGitPath, + authorizedKeysPath, + envGlobalPath, + envProjectPath, + codexAuthPath, + codexSharedAuthPath, + codexHome, + geminiAuthPath, + geminiHome, + outDir + } + }) + +type CreateBehavior = { + readonly runUp: boolean + readonly openSsh: boolean + readonly force: boolean + readonly forceEnv: boolean + readonly enableMcpPlaywright: boolean +} + +const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ + runUp: raw.up ?? true, + openSsh: raw.openSsh ?? false, + force: raw.force ?? false, + forceEnv: raw.forceEnv ?? false, + enableMcpPlaywright: raw.enableMcpPlaywright ?? false +}) + +type BuildTemplateConfigInput = { + readonly repo: RepoBasics + readonly names: NameConfig + readonly paths: PathConfig + readonly cpuLimit: string | undefined + readonly ramLimit: string | undefined + readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] + readonly dockerSharedNetworkName: string + readonly gitTokenLabel: string | undefined + readonly codexAuthLabel: string | undefined + readonly claudeAuthLabel: string | undefined + readonly enableMcpPlaywright: boolean + readonly agentMode: AgentMode | undefined + readonly agentAuto: boolean + readonly clonedOnHostname?: string | undefined +} + +const buildTemplateConfig = ({ + agentAuto, + agentMode, + claudeAuthLabel, + clonedOnHostname, + codexAuthLabel, + cpuLimit, + dockerNetworkMode, + dockerSharedNetworkName, + enableMcpPlaywright, + gitTokenLabel, + names, + paths, + ramLimit, + repo +}: BuildTemplateConfigInput): CreateCommand["config"] => ({ + containerName: names.containerName, + serviceName: names.serviceName, + sshUser: repo.sshUser, + sshPort: repo.sshPort, + repoUrl: repo.repoUrl, + repoRef: repo.repoRef, + gitTokenLabel, + codexAuthLabel, + claudeAuthLabel, + targetDir: repo.targetDir, + volumeName: names.volumeName, + dockerGitPath: paths.dockerGitPath, + authorizedKeysPath: paths.authorizedKeysPath, + envGlobalPath: paths.envGlobalPath, + envProjectPath: paths.envProjectPath, + codexAuthPath: paths.codexAuthPath, + codexSharedAuthPath: paths.codexSharedAuthPath, + codexHome: paths.codexHome, + geminiAuthPath: paths.geminiAuthPath, + geminiHome: paths.geminiHome, + cpuLimit, + ramLimit, + dockerNetworkMode, + dockerSharedNetworkName, + enableMcpPlaywright, + pnpmVersion: defaultTemplateConfig.pnpmVersion, + agentMode, + agentAuto, + clonedOnHostname +}) + +// CHANGE: build a typed create command from raw options (CLI or API) +// WHY: share deterministic command construction across CLI and server +// QUOTE(ТЗ): "В lib ты оставляешь бизнес логику, а все CLI морду хранишь в app" +// REF: user-request-2026-02-02-cli-split +// SOURCE: n/a +// FORMAT THEOREM: forall raw: build(raw) -> deterministic(command) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: uses defaults for unset fields +// COMPLEXITY: O(1) +export const buildCreateCommand = ( + raw: RawOptions +): Either.Either => + Either.gen(function*(_) { + const repo = yield* _(resolveRepoBasics(raw)) + const names = yield* _(resolveNames(raw, repo.projectSlug)) + const paths = yield* _(resolvePaths(raw, repo.repoPath)) + const behavior = resolveCreateBehavior(raw) + const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel) + const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel) + const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel) + const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) + const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) + const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) + const dockerSharedNetworkName = yield* _( + nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) + ) + const { agentAuto, agentMode } = yield* _(resolveAutoAgentFlags(raw)) + + return { + _tag: "Create", + outDir: paths.outDir, + runUp: behavior.runUp, + openSsh: behavior.openSsh, + force: behavior.force, + forceEnv: behavior.forceEnv, + waitForClone: false, + config: buildTemplateConfig({ + repo, + names, + paths, + cpuLimit, + ramLimit, + dockerNetworkMode, + dockerSharedNetworkName, + gitTokenLabel, + codexAuthLabel, + claudeAuthLabel, + enableMcpPlaywright: behavior.enableMcpPlaywright, + agentMode, + agentAuto + }) + } + }) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-options.ts b/packages/app/src/lib/core/command-options.ts new file mode 100644 index 00000000..8d4fe2f3 --- /dev/null +++ b/packages/app/src/lib/core/command-options.ts @@ -0,0 +1,74 @@ +/* jscpd:ignore-start */ +import { type ParseError } from "./domain.js" + +// CHANGE: define reusable command option shape for create/clone/auth builders +// WHY: decouple pure command construction from CLI parsing locations +// QUOTE(ТЗ): "В lib ты оставляешь бизнес логику, а все CLI морду хранишь в app" +// REF: user-request-2026-02-02-cli-split +// SOURCE: n/a +// FORMAT THEOREM: forall o: RawOptions -> deterministic(o) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: all fields are optional and represent raw user intent +// COMPLEXITY: O(1) +export interface RawOptions { + readonly repoUrl?: string + readonly repoRef?: string + readonly targetDir?: string + readonly sshPort?: string + readonly sshUser?: string + readonly containerName?: string + readonly serviceName?: string + readonly volumeName?: string + readonly secretsRoot?: string + readonly authorizedKeysPath?: string + readonly envGlobalPath?: string + readonly envProjectPath?: string + readonly codexAuthPath?: string + readonly codexHome?: string + readonly cpuLimit?: string + readonly ramLimit?: string + readonly dockerNetworkMode?: string + readonly dockerSharedNetworkName?: string + readonly enableMcpPlaywright?: boolean + readonly archivePath?: string + readonly scrapMode?: string + readonly wipe?: boolean + readonly label?: string + readonly gitTokenLabel?: string + readonly codexTokenLabel?: string + readonly claudeTokenLabel?: string + readonly token?: string + readonly scopes?: string + readonly message?: string + readonly authWeb?: boolean + readonly authOauth?: boolean + readonly outDir?: string + readonly projectDir?: string + readonly lines?: string + readonly includeDefault?: boolean + readonly up?: boolean + readonly openSsh?: boolean + readonly force?: boolean + readonly forceEnv?: boolean + readonly agentAutoMode?: string + // Session gist options (issue-143) + readonly prNumber?: string + readonly repo?: string + readonly noComment?: boolean + readonly limit?: string + readonly output?: string +} + +// CHANGE: helper type alias for builder signatures that produce parse errors +// WHY: keep error typing consistent without CLI parsing +// QUOTE(ТЗ): "Ошибки типизированы" +// REF: user-request-2026-02-02-cli-split +// SOURCE: n/a +// FORMAT THEOREM: forall e: ParseError -> typed(e) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: ParseError tags are preserved +// COMPLEXITY: O(1) +export type CommandBuildError = ParseError +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/docker-git-scripts.ts b/packages/app/src/lib/core/docker-git-scripts.ts new file mode 100644 index 00000000..b95a0f18 --- /dev/null +++ b/packages/app/src/lib/core/docker-git-scripts.ts @@ -0,0 +1,33 @@ +/* jscpd:ignore-start */ +// CHANGE: define the set of docker-git scripts to embed in generated containers +// WHY: scripts (session-backup, pre-commit guards, knowledge splitter) must be available +// inside containers for git hooks and docker-git module usage +// REF: issue-176 +// SOURCE: n/a +// FORMAT THEOREM: ∀ name ∈ dockerGitScriptNames: name ∈ scripts/ ∧ referenced_by_hooks(name) +// PURITY: CORE (pure constant definition) +// INVARIANT: list is exhaustive for all scripts referenced by generated git hooks +// COMPLEXITY: O(1) + +/** + * Names of docker-git scripts that must be available inside generated containers. + * + * These scripts are referenced by git hooks (pre-push, pre-commit), the global + * git push post-action runtime, and session backup workflows. They are copied into + * each project's build context under + * `scripts/` and embedded into the Docker image at `/opt/docker-git/scripts/`. + * + * @pure true + * @invariant ∀ name ∈ result: ∃ file(scripts/{name}) in docker-git workspace + */ +export const dockerGitScriptNames: ReadonlyArray = [ + "session-backup-gist.js", + "session-backup-repo.js", + "session-list-gists.js", + "pre-commit-secret-guard.sh", + "pre-push-knowledge-guard.js", + "split-knowledge-large-files.js", + "repair-knowledge-history.js", + "setup-pre-commit-hook.js" +] +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/docker-network.ts b/packages/app/src/lib/core/docker-network.ts new file mode 100644 index 00000000..701192e0 --- /dev/null +++ b/packages/app/src/lib/core/docker-network.ts @@ -0,0 +1,52 @@ +/* jscpd:ignore-start */ +import { deriveRepoPathParts } from "./domain.js" + +export type DockerNetworkConfig = { + readonly subnet: string + readonly ipAddress: string +} + +const hashRepoSeed = (value: string): number => { + let hash = 0x81_1C_9D_C5 + for (const char of value) { + hash ^= char.codePointAt(0) ?? 0 + hash = Math.imul(hash, 0x01_00_01_93) + } + return hash >>> 0 +} + +// CHANGE: derive a stable docker DNS hostname from repo URL +// WHY: allow consistent per-project DNS aliases +// QUOTE(ТЗ): "docker.{dns}:port" +// REF: user-request-2026-01-30-dns +// SOURCE: n/a +// FORMAT THEOREM: forall url: dns(url) is deterministic +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: hostname always begins with docker. +// COMPLEXITY: O(n) where n = |url| +export const deriveDockerDnsName = (repoUrl: string): string => { + const parts = deriveRepoPathParts(repoUrl).pathParts + return ["docker", ...parts].join(".") +} + +// CHANGE: derive a stable docker subnet + IP for per-project isolation +// WHY: avoid port conflicts by giving each container a unique IP +// QUOTE(ТЗ): "У каждого контейнера свой IP т.е свой домен" +// REF: user-request-2026-01-30-dns +// SOURCE: n/a +// FORMAT THEOREM: forall url: net(url) is deterministic +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: subnet in 172.20.0.0/16..172.31.0.0/16, IP host in [10,209] +// COMPLEXITY: O(n) where n = |url| +export const deriveDockerNetworkConfig = (repoUrl: string): DockerNetworkConfig => { + const hash = hashRepoSeed(repoUrl) + const subnetA = 20 + (hash % 12) + const subnetB = (hash >>> 8) & 0xFF + const hostOctet = 10 + ((hash >>> 16) % 200) + const subnet = `172.${subnetA}.${subnetB}.0/24` + const ipAddress = `172.${subnetA}.${subnetB}.${hostOctet}` + return { subnet, ipAddress } +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts new file mode 100644 index 00000000..eca1fa33 --- /dev/null +++ b/packages/app/src/lib/core/domain.ts @@ -0,0 +1,262 @@ +/* jscpd:ignore-start */ +import type { AuthCommand } from "./auth-domain.js" +import type { SessionsCommand } from "./sessions-domain.js" +import type { StateCommand } from "./state-domain.js" + +export type { + AuthClaudeLoginCommand, + AuthClaudeLogoutCommand, + AuthClaudeStatusCommand, + AuthCodexLoginCommand, + AuthCodexLogoutCommand, + AuthCodexStatusCommand, + AuthCommand, + AuthGeminiLoginCommand, + AuthGeminiLogoutCommand, + AuthGeminiStatusCommand, + AuthGithubLoginCommand, + AuthGithubLogoutCommand, + AuthGithubStatusCommand +} from "./auth-domain.js" +export type { MenuAction, ParseError } from "./menu.js" +export { parseMenuSelection } from "./menu.js" +export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" +export type { + SessionsCommand, + SessionsKillCommand, + SessionsListCommand, + SessionsLogsCommand +} from "./sessions-domain.js" +export type { + StateCommand, + StateCommitCommand, + StateInitCommand, + StatePathCommand, + StatePullCommand, + StatePushCommand, + StateStatusCommand, + StateSyncCommand +} from "./state-domain.js" +export { + defaultCpuLimit, + defaultDockerNetworkMode, + defaultDockerSharedNetworkName, + defaultRamLimit, + defaultTemplateConfig, + dockerGitSharedCacheVolumeName, + dockerGitSharedCodexVolumeName +} from "./template-defaults.js" + +export type AgentMode = "claude" | "codex" | "gemini" + +export type DockerNetworkMode = "shared" | "project" + +export interface TemplateConfig { + readonly containerName: string + readonly serviceName: string + readonly sshUser: string + readonly sshPort: number + readonly repoUrl: string + readonly repoRef: string + readonly forkRepoUrl?: string + readonly gitTokenLabel?: string | undefined + readonly codexAuthLabel?: string | undefined + readonly claudeAuthLabel?: string | undefined + readonly targetDir: string + readonly volumeName: string + readonly dockerGitPath: string + readonly authorizedKeysPath: string + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string + readonly codexSharedAuthPath: string + readonly codexHome: string + readonly geminiAuthLabel?: string | undefined + readonly geminiAuthPath: string + readonly geminiHome: string + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined + readonly dockerNetworkMode: DockerNetworkMode + readonly dockerSharedNetworkName: string + readonly enableMcpPlaywright: boolean + readonly pnpmVersion: string + readonly agentMode?: AgentMode | undefined + readonly agentAuto?: boolean | undefined + readonly clonedOnHostname?: string | undefined +} + +export interface ProjectConfig { + readonly schemaVersion: 1 + readonly template: TemplateConfig +} + +export interface CreateCommand { + readonly _tag: "Create" + readonly config: TemplateConfig + readonly outDir: string + readonly runUp: boolean + readonly force: boolean + readonly forceEnv: boolean + readonly waitForClone: boolean + readonly openSsh: boolean +} + +export interface MenuCommand { + readonly _tag: "Menu" +} + +export interface AttachCommand { + readonly _tag: "Attach" + readonly projectDir: string +} + +export interface PanesCommand { + readonly _tag: "Panes" + readonly projectDir: string +} + +// CHANGE: remove scrap cache mode and keep only the reproducible session snapshot. +// WHY: cache archives include large, easily-rebuildable artifacts (e.g. node_modules) that should not be stored in git. +// QUOTE(ТЗ): "не должно быть старого режима где он качает весь шлак типо node_modules" +// REF: user-request-2026-02-15 +// SOURCE: n/a +// FORMAT THEOREM: forall m: ScrapMode, m = "session" +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: scrap exports/imports are always recipe-like (git state + small secrets), never full workspace caches +// COMPLEXITY: O(1) +export type ScrapMode = "session" + +export interface ScrapExportCommand { + readonly _tag: "ScrapExport" + readonly projectDir: string + readonly archivePath: string + readonly mode: ScrapMode +} + +export interface ScrapImportCommand { + readonly _tag: "ScrapImport" + readonly projectDir: string + readonly archivePath: string + readonly wipe: boolean + readonly mode: ScrapMode +} + +export interface McpPlaywrightUpCommand { + readonly _tag: "McpPlaywrightUp" + readonly projectDir: string + readonly runUp: boolean +} + +export interface ApplyCommand { + readonly _tag: "Apply" + readonly projectDir: string + readonly runUp: boolean + readonly gitTokenLabel?: string | undefined + readonly codexTokenLabel?: string | undefined + readonly claudeTokenLabel?: string | undefined + readonly geminiTokenLabel?: string | undefined + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined + readonly enableMcpPlaywright?: boolean | undefined +} + +// CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag +// WHY: allow bulk-updating all containers in one command; --active restricts to currently running containers only +// QUOTE(ТЗ): "Сделать команду которая сама на все контейнеры применит новые настройки" +// QUOTE(ТЗ): "сделать это возможным через атрибут --active применять только к активным контейнерам, а не ко всем" +// REF: issue-164, issue-185 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: when activeOnly=false applies to all discovered projects; when activeOnly=true applies only to running containers; individual failures do not abort the batch +// COMPLEXITY: O(1) +export interface ApplyAllCommand { + readonly _tag: "ApplyAll" + readonly activeOnly: boolean +} + +export interface HelpCommand { + readonly _tag: "Help" + readonly message: string +} + +export interface StatusCommand { + readonly _tag: "Status" +} + +export interface DownAllCommand { + readonly _tag: "DownAll" +} + +export type { + SessionGistBackupCommand, + SessionGistCommand, + SessionGistDownloadCommand, + SessionGistListCommand, + SessionGistViewCommand +} from "./session-gist-domain.js" + +export type ScrapCommand = + | ScrapExportCommand + | ScrapImportCommand + +export type Command = + | CreateCommand + | MenuCommand + | AttachCommand + | PanesCommand + | SessionsCommand + | ScrapCommand + | McpPlaywrightUpCommand + | ApplyCommand + | ApplyAllCommand + | HelpCommand + | StatusCommand + | DownAllCommand + | StateCommand + | AuthCommand + +// CHANGE: validate docker network mode values at the CLI/config boundary +// WHY: keep compose network behavior explicit and type-safe +// QUOTE(ТЗ): "Что бы среды были изолированы?" +// REF: user-request-2026-02-20-networks +// SOURCE: n/a +// FORMAT THEOREM: ∀x: isDockerNetworkMode(x) -> x ∈ {"shared","project"} +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns true only for known modes +// COMPLEXITY: O(1) +export const isDockerNetworkMode = (value: string): value is DockerNetworkMode => + value === "shared" || value === "project" + +// CHANGE: derive compose network name from typed template config +// WHY: keep network naming deterministic across template generation and runtime checks +// QUOTE(ТЗ): "Если я хочу уникальную сеть на каждый контейнер?" +// REF: user-request-2026-02-20-networks +// SOURCE: n/a +// FORMAT THEOREM: ∀cfg: resolveComposeNetworkName(cfg) = n -> deterministic(n) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: shared mode always resolves to dockerSharedNetworkName; project mode to "-net" +// COMPLEXITY: O(1) +export const resolveComposeNetworkName = ( + config: Pick +): string => + config.dockerNetworkMode === "shared" + ? config.dockerSharedNetworkName + : `${config.serviceName}-net` + +// CHANGE: derive a stable bootstrap volume name for per-project runtime bootstrap data +// WHY: API/controller mode cannot rely on host bind mounts for auth/env material +// QUOTE(ТЗ): "У нас есть CLI который вызывает docker ? ... Поднимается сервер и ты через него можешь общаться с контейнером" +// REF: user-request-2026-03-15-api-controller +// SOURCE: n/a +// FORMAT THEOREM: ∀cfg: resolveProjectBootstrapVolumeName(cfg) = v -> deterministic(v) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: bootstrap volume name is derived solely from project volumeName +// COMPLEXITY: O(1) +export const resolveProjectBootstrapVolumeName = ( + config: Pick +): string => `${config.volumeName}-bootstrap` +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/menu.ts b/packages/app/src/lib/core/menu.ts new file mode 100644 index 00000000..e6e4f686 --- /dev/null +++ b/packages/app/src/lib/core/menu.ts @@ -0,0 +1,113 @@ +/* jscpd:ignore-start */ +import { Either } from "effect" + +export type MenuAction = + | { readonly _tag: "Create" } + | { readonly _tag: "Select" } + | { readonly _tag: "Auth" } + | { readonly _tag: "ProjectAuth" } + | { readonly _tag: "Info" } + | { readonly _tag: "Up" } + | { readonly _tag: "Status" } + | { readonly _tag: "Logs" } + | { readonly _tag: "Down" } + | { readonly _tag: "DownAll" } + | { readonly _tag: "Delete" } + | { readonly _tag: "Quit" } + +export type ParseError = + | { readonly _tag: "UnknownCommand"; readonly command: string } + | { readonly _tag: "UnknownOption"; readonly option: string } + | { readonly _tag: "MissingOptionValue"; readonly option: string } + | { readonly _tag: "MissingRequiredOption"; readonly option: string } + | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } + | { readonly _tag: "UnexpectedArgument"; readonly value: string } + +const normalizeMenuInput = (input: string): string => input.trim().toLowerCase() + +const menuAliasMap = new Map([ + ["1", { _tag: "Create" }], + ["create", { _tag: "Create" }], + ["c", { _tag: "Create" }], + ["2", { _tag: "Select" }], + ["select", { _tag: "Select" }], + ["s", { _tag: "Select" }], + ["3", { _tag: "Auth" }], + ["auth", { _tag: "Auth" }], + ["a", { _tag: "Auth" }], + ["4", { _tag: "ProjectAuth" }], + ["project-auth", { _tag: "ProjectAuth" }], + ["projectauth", { _tag: "ProjectAuth" }], + ["pa", { _tag: "ProjectAuth" }], + ["5", { _tag: "Info" }], + ["info", { _tag: "Info" }], + ["i", { _tag: "Info" }], + ["up", { _tag: "Up" }], + ["u", { _tag: "Up" }], + ["start", { _tag: "Up" }], + ["6", { _tag: "Status" }], + ["status", { _tag: "Status" }], + ["ps", { _tag: "Status" }], + ["7", { _tag: "Logs" }], + ["logs", { _tag: "Logs" }], + ["log", { _tag: "Logs" }], + ["l", { _tag: "Logs" }], + ["8", { _tag: "Down" }], + ["down", { _tag: "Down" }], + ["stop", { _tag: "Down" }], + ["d", { _tag: "Down" }], + ["9", { _tag: "DownAll" }], + ["down-all", { _tag: "DownAll" }], + ["downall", { _tag: "DownAll" }], + ["stop-all", { _tag: "DownAll" }], + ["stopall", { _tag: "DownAll" }], + ["kill-all", { _tag: "DownAll" }], + ["killall", { _tag: "DownAll" }], + ["da", { _tag: "DownAll" }], + ["10", { _tag: "Delete" }], + ["delete", { _tag: "Delete" }], + ["del", { _tag: "Delete" }], + ["remove", { _tag: "Delete" }], + ["rm", { _tag: "Delete" }], + ["0", { _tag: "Quit" }], + ["11", { _tag: "Quit" }], + ["quit", { _tag: "Quit" }], + ["q", { _tag: "Quit" }], + ["exit", { _tag: "Quit" }] +]) + +const resolveMenuAction = (normalized: string): MenuAction | undefined => menuAliasMap.get(normalized) + +// CHANGE: decode interactive menu input into a typed action +// WHY: keep menu parsing pure and reusable across shells +// QUOTE(ТЗ): "Хочу что бы открылось менюшка" +// REF: user-request-2026-01-07 +// SOURCE: n/a +// FORMAT THEOREM: forall s: parseMenu(s) = a -> deterministic(a) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: unknown input maps to InvalidOption +// COMPLEXITY: O(1) +export const parseMenuSelection = (input: string): Either.Either => { + const normalized = normalizeMenuInput(input) + + if (normalized.length === 0) { + return Either.left({ + _tag: "InvalidOption", + option: "menu", + reason: "empty selection" + }) + } + + const action = resolveMenuAction(normalized) + if (action === undefined) { + return Either.left({ + _tag: "InvalidOption", + option: "menu", + reason: `unknown selection: ${input}` + }) + } + + return Either.right(action) +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/parse-errors.ts b/packages/app/src/lib/core/parse-errors.ts new file mode 100644 index 00000000..c519ee34 --- /dev/null +++ b/packages/app/src/lib/core/parse-errors.ts @@ -0,0 +1,26 @@ +/* jscpd:ignore-start */ +import { Match } from "effect" + +import type { ParseError } from "./domain.js" + +// CHANGE: normalize parse errors into deterministic messages +// WHY: reuse parse error formatting across CLI and server flows +// QUOTE(ТЗ): "ошибки должны быть описывающими" +// REF: user-request-2026-02-02-cli-split +// SOURCE: n/a +// FORMAT THEOREM: forall e: format(e) = s -> deterministic(s) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: each ParseError maps to exactly one message +// COMPLEXITY: O(1) +export const formatParseError = (error: ParseError): string => + Match.value(error).pipe( + Match.when({ _tag: "UnknownCommand" }, ({ command }) => `Unknown command: ${command}`), + Match.when({ _tag: "UnknownOption" }, ({ option }) => `Unknown option: ${option}`), + Match.when({ _tag: "MissingOptionValue" }, ({ option }) => `Missing value for option: ${option}`), + Match.when({ _tag: "MissingRequiredOption" }, ({ option }) => `Missing required option: ${option}`), + Match.when({ _tag: "InvalidOption" }, ({ option, reason }) => `Invalid option ${option}: ${reason}`), + Match.when({ _tag: "UnexpectedArgument" }, ({ value }) => `Unexpected argument: ${value}`), + Match.exhaustive + ) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/repo.ts b/packages/app/src/lib/core/repo.ts new file mode 100644 index 00000000..dfadf7fa --- /dev/null +++ b/packages/app/src/lib/core/repo.ts @@ -0,0 +1,317 @@ +/* jscpd:ignore-start */ +import { trimLeftChar, trimRightChar } from "./strings.js" + +const slugify = (value: string): string => { + const normalized = value + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/g, "-") + .replaceAll(/-+/g, "-") + const withoutLeading = trimLeftChar(normalized, "-") + const cleaned = trimRightChar(withoutLeading, "-") + + return cleaned.length > 0 ? cleaned : "app" +} + +// CHANGE: derive a stable repo slug from a repo URL +// WHY: generate deterministic container/service names per repository +// QUOTE(ТЗ): "по факту он должен создавтаь постоянно новый контейнер для нового репозитория" +// REF: user-request-2026-01-07 +// SOURCE: n/a +// FORMAT THEOREM: forall url: slug(url) = s -> deterministic(s) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: slug is lowercase and non-empty +// COMPLEXITY: O(n) where n = |url| +export const deriveRepoSlug = (repoUrl: string): string => { + const trimmed = trimRightChar(repoUrl.trim(), "/") + if (trimmed.length === 0) { + return "app" + } + + const lastSlash = trimmed.lastIndexOf("/") + const lastColon = trimmed.lastIndexOf(":") + const pivot = Math.max(lastSlash, lastColon) + const segment = pivot >= 0 ? trimmed.slice(pivot + 1) : trimmed + const withoutGit = segment.endsWith(".git") ? segment.slice(0, -4) : segment + + return slugify(withoutGit) +} + +type RepoPathParts = { + readonly ownerParts: ReadonlyArray + readonly repo: string + readonly pathParts: ReadonlyArray +} + +const stripGitSuffix = (segment: string): string => segment.endsWith(".git") ? segment.slice(0, -4) : segment + +const normalizePathParts = (pathPart: string): ReadonlyArray => { + const cleaned = trimLeftChar(pathPart, "/") + if (cleaned.length === 0) { + return [] + } + const rawParts = cleaned.split("/").filter(Boolean) + return rawParts.map((part, index) => index === rawParts.length - 1 ? stripGitSuffix(part) : part) +} + +const extractFromScheme = (trimmed: string): ReadonlyArray | null => { + const schemeIndex = trimmed.indexOf("://") + if (schemeIndex === -1) { + return null + } + const afterScheme = trimmed.slice(schemeIndex + 3) + const firstSlash = afterScheme.indexOf("/") + if (firstSlash === -1) { + return [] + } + return normalizePathParts(afterScheme.slice(firstSlash + 1)) +} + +const extractFromColon = (trimmed: string): ReadonlyArray | null => { + const colonIndex = trimmed.indexOf(":") + if (colonIndex === -1) { + return null + } + return normalizePathParts(trimmed.slice(colonIndex + 1)) +} + +const extractFromSlash = (trimmed: string): ReadonlyArray | null => { + const slashIndex = trimmed.indexOf("/") + if (slashIndex === -1) { + return null + } + return normalizePathParts(trimmed.slice(slashIndex + 1)) +} + +const extractRepoPathParts = (repoUrl: string): ReadonlyArray => { + const trimmed = trimRightChar(repoUrl.trim(), "/") + if (trimmed.length === 0) { + return [] + } + + const fromScheme = extractFromScheme(trimmed) + if (fromScheme !== null) { + return fromScheme + } + + const fromColon = extractFromColon(trimmed) + if (fromColon !== null) { + return fromColon + } + + const fromSlash = extractFromSlash(trimmed) + if (fromSlash !== null) { + return fromSlash + } + + return [stripGitSuffix(trimmed)] +} + +const normalizeRepoSegment = (segment: string, fallback: string): string => { + const normalized = slugify(segment) + return normalized.length > 0 ? normalized : fallback +} + +// CHANGE: derive stable owner/repo path parts from a repo URL +// WHY: avoid collisions when orgs have identical repo names +// QUOTE(ТЗ): "пути учитывают организацию в которой это лежит" +// REF: user-request-2026-01-27 +// SOURCE: n/a +// FORMAT THEOREM: forall url: parts(url) -> deterministic(parts) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: path parts are slugified and non-empty +// COMPLEXITY: O(n) where n = |url| +export const deriveRepoPathParts = (repoUrl: string): RepoPathParts => { + const repoSlug = deriveRepoSlug(repoUrl) + const rawParts = extractRepoPathParts(repoUrl) + if (rawParts.length === 0) { + return { ownerParts: [], repo: repoSlug, pathParts: [repoSlug] } + } + + const rawRepo = rawParts.at(-1) ?? repoSlug + const repo = normalizeRepoSegment(rawRepo, repoSlug) + const ownerParts = rawParts + .slice(0, -1) + .map((part) => normalizeRepoSegment(part, "org")) + .filter((part) => part.length > 0) + const pathParts = ownerParts.length > 0 ? [...ownerParts, repo] : [repo] + + return { ownerParts, repo, pathParts } +} + +export type GithubRepo = { + readonly owner: string + readonly repo: string +} + +const stripQueryHash = (value: string): string => { + const queryIndex = value.indexOf("?") + const hashIndex = value.indexOf("#") + const indices = [queryIndex, hashIndex].filter((index) => index >= 0) + if (indices.length === 0) { + return value + } + const cutIndex = Math.min(...indices) + return value.slice(0, cutIndex) +} + +const splitGithubPath = (input: string): ReadonlyArray | null => { + const trimmed = input.trim() + const httpsPrefix = "https://github.com/" + const sshPrefix = "ssh://git@github.com/" + const gitPrefix = "git@github.com:" + let rest: string | null = null + if (trimmed.startsWith(httpsPrefix)) { + rest = trimmed.slice(httpsPrefix.length) + } else if (trimmed.startsWith(sshPrefix)) { + rest = trimmed.slice(sshPrefix.length) + } else if (trimmed.startsWith(gitPrefix)) { + rest = trimmed.slice(gitPrefix.length) + } + if (rest === null) { + return null + } + const cleaned = trimRightChar(stripQueryHash(rest), "/") + if (cleaned.length === 0) { + return [] + } + return cleaned.split("/").filter((part) => part.length > 0) +} + +// CHANGE: parse GitHub owner/repo from common URL formats +// WHY: enable auto-fork logic without relying on slugified paths +// QUOTE(ТЗ): "Сразу на issues и он бы делал форк репы если это надо" +// REF: user-request-2026-02-05-issues-fork +// SOURCE: n/a +// FORMAT THEOREM: ∀u: github(u) → repo(u) = {owner, repo} +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns null for non-GitHub inputs +// COMPLEXITY: O(n) where n = |input| +export const parseGithubRepoUrl = (input: string): GithubRepo | null => { + const parts = splitGithubPath(input) + if (!parts || parts.length < 2) { + return null + } + + const owner = parts[0]?.trim() + const repoRaw = parts[1]?.trim() + if (!owner || !repoRaw) { + return null + } + + const repo = stripGitSuffix(repoRaw) + return { owner, repo } +} + +export type ResolvedRepoInput = { + readonly repoUrl: string + readonly repoRef?: string + readonly workspaceSuffix?: string +} + +type GithubRefParts = { + readonly owner: string + readonly repoRaw: string + readonly marker: string + readonly ref: string +} + +const readGithubPart = (value: string | undefined): string | null => { + const trimmed = value?.trim() ?? "" + return trimmed.length > 0 ? trimmed : null +} + +const parseGithubRefParts = (input: string): GithubRefParts | null => { + const parts = splitGithubPath(input) + if (!parts || parts.length < 4) { + return null + } + const owner = readGithubPart(parts[0]) + const repoRaw = readGithubPart(parts[1]) + const markerRaw = readGithubPart(parts[2]) + const ref = readGithubPart(parts[3]) + if (!owner || !repoRaw || !markerRaw || !ref) { + return null + } + return { owner, repoRaw, marker: markerRaw.toLowerCase(), ref } +} + +const parseGithubPrUrl = (input: string): ResolvedRepoInput | null => { + const parsed = parseGithubRefParts(input) + if (!parsed || parsed.marker !== "pull") { + return null + } + + const repo = stripGitSuffix(parsed.repoRaw) + const workspaceSuffix = `pr-${slugify(parsed.ref)}` + return { + repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, + repoRef: `refs/pull/${parsed.ref}/head`, + workspaceSuffix + } +} + +// CHANGE: normalize GitHub tree/blob URLs into repo + ref +// WHY: allow docker-git clone to accept branch URLs like /tree/ +// QUOTE(ТЗ): "вызови --force на https://github.com/agiens/crm/tree/vova-fork" +// REF: user-request-2026-02-10-github-tree-url +// SOURCE: n/a +// FORMAT THEOREM: ∀u: tree(u) → repo(u)=git(u) ∧ ref(u)=branch(u) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: ignores additional path segments after the ref +// COMPLEXITY: O(n) where n = |url| +const parseGithubTreeUrl = (input: string): ResolvedRepoInput | null => { + const parsed = parseGithubRefParts(input) + if (!parsed || (parsed.marker !== "tree" && parsed.marker !== "blob")) { + return null + } + + const repo = stripGitSuffix(parsed.repoRaw) + return { repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, repoRef: parsed.ref } +} + +// CHANGE: normalize GitHub issue URLs into repo URLs +// WHY: allow docker-git clone to accept issue links directly +// QUOTE(ТЗ): "Сразу на issues" +// REF: user-request-2026-02-05-issues +// SOURCE: n/a +// FORMAT THEOREM: ∀u: issue(u) → repo(u) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: issue URL yields repoUrl + deterministic issue branch +// COMPLEXITY: O(n) where n = |url| +const parseGithubIssueUrl = (input: string): ResolvedRepoInput | null => { + const parsed = parseGithubRefParts(input) + if (!parsed || parsed.marker !== "issues") { + return null + } + + const repo = stripGitSuffix(parsed.repoRaw) + const workspaceSuffix = `issue-${slugify(parsed.ref)}` + return { + repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, + repoRef: workspaceSuffix, + workspaceSuffix + } +} + +// CHANGE: normalize repo input and PR/issue URLs into repo + ref +// WHY: allow cloning GitHub PR links and issue links directly +// QUOTE(ТЗ): "клонировть по cсылке на PR" | "Сразу на issues" +// REF: user-request-2026-01-28-pr | user-request-2026-02-05-issues +// SOURCE: n/a +// FORMAT THEOREM: forall url: resolve(url) -> deterministic(url, ref) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: PR URL yields repoUrl + refs/pull//head +// COMPLEXITY: O(n) where n = |url| +export const resolveRepoInput = (repoUrl: string): ResolvedRepoInput => + parseGithubPrUrl(repoUrl) + ?? parseGithubTreeUrl(repoUrl) + ?? parseGithubIssueUrl(repoUrl) + ?? { repoUrl: repoUrl.trim() } +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/resource-limits.ts b/packages/app/src/lib/core/resource-limits.ts new file mode 100644 index 00000000..1402a4b8 --- /dev/null +++ b/packages/app/src/lib/core/resource-limits.ts @@ -0,0 +1,145 @@ +/* jscpd:ignore-start */ +import { Either } from "effect" + +import { defaultCpuLimit, defaultRamLimit, type ParseError, type TemplateConfig } from "./domain.js" + +const mebibyte = 1024 ** 2 +const minimumResolvedCpuLimit = 0.25 +const minimumResolvedRamLimitMib = 512 +const precisionScale = 100 + +type HostResources = { + readonly cpuCount: number + readonly totalMemoryBytes: number +} + +export type ResolvedComposeResourceLimits = { + readonly cpuLimit: number + readonly ramLimit: string +} + +const cpuAbsolutePattern = /^\d+(?:\.\d+)?$/u +const ramAbsolutePattern = /^\d+(?:\.\d+)?(?:b|k|kb|m|mb|g|gb|t|tb)$/iu +const percentPattern = /^\d+(?:\.\d+)?%$/u + +const normalizePrecision = (value: number): number => Math.round(value * precisionScale) / precisionScale + +const missingLimit = (): string | undefined => undefined + +const parsePercent = (candidate: string): number | null => { + if (!percentPattern.test(candidate)) { + return null + } + const parsed = Number(candidate.slice(0, -1)) + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) { + return null + } + return normalizePrecision(parsed) +} + +const percentReason = (kind: "cpu" | "ram"): string => + kind === "cpu" + ? "expected CPU like 30% or 1.5" + : "expected RAM like 30%, 512m or 4g" + +const normalizePercent = (candidate: string, kind: "cpu" | "ram"): Either.Either => { + const parsed = parsePercent(candidate) + if (parsed === null) { + return Either.left({ + _tag: "InvalidOption", + option: kind === "cpu" ? "--cpu" : "--ram", + reason: percentReason(kind) + }) + } + return Either.right(`${parsed}%`) +} + +export const normalizeCpuLimit = ( + value: string | undefined, + option: string +): Either.Either => { + const candidate = value?.trim().toLowerCase() ?? "" + if (candidate.length === 0) { + return Either.right(missingLimit()) + } + if (candidate.endsWith("%")) { + return normalizePercent(candidate, "cpu") + } + if (!cpuAbsolutePattern.test(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option, + reason: "expected CPU like 30% or 1.5" + }) + } + const parsed = Number(candidate) + if (!Number.isFinite(parsed) || parsed <= 0) { + return Either.left({ + _tag: "InvalidOption", + option, + reason: "must be greater than 0" + }) + } + return Either.right(String(normalizePrecision(parsed))) +} + +export const normalizeRamLimit = ( + value: string | undefined, + option: string +): Either.Either => { + const candidate = value?.trim().toLowerCase() ?? "" + if (candidate.length === 0) { + return Either.right(missingLimit()) + } + if (candidate.endsWith("%")) { + return normalizePercent(candidate, "ram") + } + if (!ramAbsolutePattern.test(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option, + reason: "expected RAM like 30%, 512m or 4g" + }) + } + return Either.right(candidate) +} + +export const withDefaultResourceLimitIntent = ( + template: TemplateConfig +): TemplateConfig => ({ + ...template, + cpuLimit: template.cpuLimit ?? defaultCpuLimit, + ramLimit: template.ramLimit ?? defaultRamLimit +}) + +const resolvePercentCpuLimit = (percent: number, cpuCount: number): number => + Math.max( + minimumResolvedCpuLimit, + normalizePrecision((Math.max(1, cpuCount) * percent) / 100) + ) + +const resolvePercentRamLimit = (percent: number, totalMemoryBytes: number): string => { + const totalMib = Math.max(minimumResolvedRamLimitMib, Math.floor(totalMemoryBytes / mebibyte)) + const targetMib = Math.max(minimumResolvedRamLimitMib, Math.floor((totalMib * percent) / 100)) + return `${targetMib}m` +} + +export const resolveComposeResourceLimits = ( + template: Pick, + hostResources: HostResources +): ResolvedComposeResourceLimits => { + const cpuLimitIntent = template.cpuLimit ?? defaultCpuLimit + const ramLimitIntent = template.ramLimit ?? defaultRamLimit + const cpuPercent = parsePercent(cpuLimitIntent) + const ramPercent = parsePercent(ramLimitIntent) + + return { + cpuLimit: cpuPercent === null + ? Number(cpuLimitIntent) + : resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount), + ramLimit: ramPercent === null + ? ramLimitIntent + : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes) + } +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/session-gist-domain.ts b/packages/app/src/lib/core/session-gist-domain.ts new file mode 100644 index 00000000..e3f29856 --- /dev/null +++ b/packages/app/src/lib/core/session-gist-domain.ts @@ -0,0 +1,38 @@ +/* jscpd:ignore-start */ +// CHANGE: session backup commands for PR-based session history +// WHY: enables returning to old AI sessions via a private backup repository +// QUOTE(ТЗ): "иметь возможность возвращаться ко всем старым сессиям с агентами" +// REF: issue-143 +// PURITY: CORE + +export interface SessionGistBackupCommand { + readonly _tag: "SessionGistBackup" + readonly projectDir: string + readonly prNumber: number | null + readonly repo: string | null + readonly postComment: boolean +} + +export interface SessionGistListCommand { + readonly _tag: "SessionGistList" + readonly limit: number + readonly repo: string | null +} + +export interface SessionGistViewCommand { + readonly _tag: "SessionGistView" + readonly snapshotRef: string +} + +export interface SessionGistDownloadCommand { + readonly _tag: "SessionGistDownload" + readonly snapshotRef: string + readonly outputDir: string +} + +export type SessionGistCommand = + | SessionGistBackupCommand + | SessionGistListCommand + | SessionGistViewCommand + | SessionGistDownloadCommand +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/sessions-domain.ts b/packages/app/src/lib/core/sessions-domain.ts new file mode 100644 index 00000000..1abead3c --- /dev/null +++ b/packages/app/src/lib/core/sessions-domain.ts @@ -0,0 +1,28 @@ +/* jscpd:ignore-start */ +import type { SessionGistCommand } from "./session-gist-domain.js" + +export interface SessionsListCommand { + readonly _tag: "SessionsList" + readonly projectDir: string + readonly includeDefault: boolean +} + +export interface SessionsKillCommand { + readonly _tag: "SessionsKill" + readonly projectDir: string + readonly pid: number +} + +export interface SessionsLogsCommand { + readonly _tag: "SessionsLogs" + readonly projectDir: string + readonly pid: number + readonly lines: number +} + +export type SessionsCommand = + | SessionsListCommand + | SessionsKillCommand + | SessionsLogsCommand + | SessionGistCommand +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/state-domain.ts b/packages/app/src/lib/core/state-domain.ts new file mode 100644 index 00000000..0cbd356d --- /dev/null +++ b/packages/app/src/lib/core/state-domain.ts @@ -0,0 +1,42 @@ +/* jscpd:ignore-start */ +export interface StatePathCommand { + readonly _tag: "StatePath" +} + +export interface StateInitCommand { + readonly _tag: "StateInit" + readonly repoUrl: string + readonly repoRef: string +} + +export interface StatePullCommand { + readonly _tag: "StatePull" +} + +export interface StatePushCommand { + readonly _tag: "StatePush" +} + +export interface StateStatusCommand { + readonly _tag: "StateStatus" +} + +export interface StateCommitCommand { + readonly _tag: "StateCommit" + readonly message: string +} + +export interface StateSyncCommand { + readonly _tag: "StateSync" + readonly message: string | null +} + +export type StateCommand = + | StatePathCommand + | StateInitCommand + | StatePullCommand + | StatePushCommand + | StateStatusCommand + | StateCommitCommand + | StateSyncCommand +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/strings.ts b/packages/app/src/lib/core/strings.ts new file mode 100644 index 00000000..e51a5dc1 --- /dev/null +++ b/packages/app/src/lib/core/strings.ts @@ -0,0 +1,17 @@ +/* jscpd:ignore-start */ +export const trimLeftChar = (value: string, char: string): string => { + let start = 0 + while (start < value.length && value[start] === char) { + start += 1 + } + return value.slice(start) +} + +export const trimRightChar = (value: string, char: string): string => { + let end = value.length + while (end > 0 && value[end - 1] === char) { + end -= 1 + } + return value.slice(0, end) +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/template-defaults.ts b/packages/app/src/lib/core/template-defaults.ts new file mode 100644 index 00000000..840a5a03 --- /dev/null +++ b/packages/app/src/lib/core/template-defaults.ts @@ -0,0 +1,64 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "./domain.js" + +type DefaultTemplateConfig = Pick< + TemplateConfig, + | "containerName" + | "serviceName" + | "sshUser" + | "sshPort" + | "repoRef" + | "targetDir" + | "volumeName" + | "dockerGitPath" + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + | "codexHome" + | "geminiAuthPath" + | "geminiHome" + | "cpuLimit" + | "ramLimit" + | "dockerNetworkMode" + | "dockerSharedNetworkName" + | "enableMcpPlaywright" + | "pnpmVersion" +> + +export const defaultDockerNetworkMode: TemplateConfig["dockerNetworkMode"] = "shared" + +export const defaultDockerSharedNetworkName = "docker-git-shared" +export const dockerGitSharedCacheVolumeName = "docker-git-shared-cache" +export const dockerGitSharedCodexVolumeName = "docker-git-shared-codex" + +export const defaultCpuLimit = "30%" + +export const defaultRamLimit = "30%" + +export const defaultTemplateConfig = { + containerName: "dev-ssh", + serviceName: "dev", + sshUser: "dev", + sshPort: 2222, + repoRef: "main", + targetDir: "/home/dev/app", + volumeName: "dev_home", + dockerGitPath: "./.docker-git", + authorizedKeysPath: "./.docker-git/authorized_keys", + envGlobalPath: "./.docker-git/.orch/env/global.env", + envProjectPath: "./.orch/env/project.env", + codexAuthPath: "./.docker-git/.orch/auth/codex", + codexSharedAuthPath: "./.docker-git/.orch/auth/codex", + codexHome: "/home/dev/.codex", + geminiAuthPath: "./.docker-git/.orch/auth/gemini", + geminiHome: "/home/dev/.gemini", + cpuLimit: defaultCpuLimit, + ramLimit: defaultRamLimit, + dockerNetworkMode: defaultDockerNetworkMode, + dockerSharedNetworkName: defaultDockerSharedNetworkName, + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" +} satisfies DefaultTemplateConfig +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint.ts b/packages/app/src/lib/core/templates-entrypoint.ts new file mode 100644 index 00000000..39dcd49b --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint.ts @@ -0,0 +1,70 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "./domain.js" +import { renderEntrypointAgentsNotice } from "./templates-entrypoint/agents-notice.js" +import { + renderEntrypointAuthorizedKeys, + renderEntrypointBaseline, + renderEntrypointDisableMotd, + renderEntrypointDockerSocket, + renderEntrypointHeader, + renderEntrypointInputRc, + renderEntrypointPackageCache, + renderEntrypointSshd, + renderEntrypointZshShell, + renderEntrypointZshUserRc +} from "./templates-entrypoint/base.js" +import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js" +import { + renderEntrypointCodexHome, + renderEntrypointCodexResumeHint, + renderEntrypointCodexSharedAuth, + renderEntrypointMcpPlaywright, + renderEntrypointProjectCodexSkillsSync +} from "./templates-entrypoint/codex.js" +import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" +import { renderEntrypointGeminiConfig } from "./templates-entrypoint/gemini.js" +import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" +import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" +import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js" +import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js" +import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js" +import { + renderEntrypointBashCompletion, + renderEntrypointBashHistory, + renderEntrypointPrompt, + renderEntrypointZshConfig +} from "./templates-prompt.js" + +export const renderEntrypoint = (config: TemplateConfig): string => + [ + renderEntrypointHeader(config), + renderEntrypointDnsRepair(), + renderEntrypointPackageCache(config), + renderEntrypointDockerGitBootstrap(config), + renderEntrypointAuthorizedKeys(config), + renderEntrypointCodexHome(config), + renderEntrypointCodexSharedAuth(config), + renderEntrypointOpenCodeConfig(config), + renderEntrypointMcpPlaywright(config), + renderEntrypointZshShell(config), + renderEntrypointZshUserRc(config), + renderEntrypointPrompt(), + renderEntrypointBashCompletion(), + renderEntrypointBashHistory(), + renderEntrypointInputRc(config), + renderEntrypointZshConfig(), + renderEntrypointCodexResumeHint(config), + renderEntrypointProjectCodexSkillsSync(config), + renderEntrypointProjectAgentRules(), + renderEntrypointAgentsNotice(config), + renderEntrypointDockerSocket(config), + renderEntrypointGitConfig(config), + renderEntrypointClaudeConfig(config), + renderEntrypointGeminiConfig(config), + renderEntrypointGitHooks(), + renderEntrypointBackgroundTasks(config), + renderEntrypointBaseline(), + renderEntrypointDisableMotd(), + renderEntrypointSshd() + ].join("\n\n") +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/agent.ts b/packages/app/src/lib/core/templates-entrypoint/agent.ts new file mode 100644 index 00000000..8e7c69b9 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/agent.ts @@ -0,0 +1,209 @@ +/* jscpd:ignore-start */ +import { Match } from "effect" +import type { TemplateConfig } from "../domain.js" + +type AgentMode = "claude" | "codex" | "gemini" + +const indentBlock = (block: string, size = 2): string => { + const prefix = " ".repeat(size) + + return block + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n") +} + +const renderAgentPrompt = (): string => + String.raw`AGENT_PROMPT="" +ISSUE_NUM="" +if [[ "$REPO_REF" =~ ^issue-([0-9]+)$ ]]; then + ISSUE_NUM="${"${"}BASH_REMATCH[1]}" +fi + +if [[ "$AGENT_AUTO" == "1" ]]; then + if [[ -n "$ISSUE_NUM" ]]; then + AGENT_PROMPT="Read GitHub issue #$ISSUE_NUM for this repository (use gh issue view $ISSUE_NUM). Implement the requested changes, commit them, create a PR that closes #$ISSUE_NUM, and push it." + else + AGENT_PROMPT="Analyze this repository, implement any pending tasks, commit changes, create a PR, and push it." + fi +fi` + +const renderAgentSetup = (): string => + [ + String.raw`AGENT_DONE_PATH="/run/docker-git/agent.done" +AGENT_FAIL_PATH="/run/docker-git/agent.failed" +AGENT_PROMPT_FILE="/run/docker-git/agent-prompt.txt" +rm -f "$AGENT_DONE_PATH" "$AGENT_FAIL_PATH" "$AGENT_PROMPT_FILE"`, + String.raw`# Collect tokens for agent environment (su - dev does not always inherit profile.d) +AGENT_ENV_FILE="/run/docker-git/agent-env.sh" +{ + [[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh + [[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh + [[ -f /etc/profile.d/gemini-config.sh ]] && cat /etc/profile.d/gemini-config.sh +} > "$AGENT_ENV_FILE" 2>/dev/null || true +chmod 644 "$AGENT_ENV_FILE"`, + renderAgentPrompt(), + String.raw`AGENT_OK=0 +if [[ -n "$AGENT_PROMPT" ]]; then + printf "%s" "$AGENT_PROMPT" > "$AGENT_PROMPT_FILE" + chmod 644 "$AGENT_PROMPT_FILE" +fi` + ].join("\n\n") + +const renderAgentPromptCommand = (mode: AgentMode): string => + Match.value(mode).pipe( + Match.when("claude", () => String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when("codex", () => String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), + Match.exhaustive + ) + +const renderAgentAutoLaunchCommand = ( + config: TemplateConfig, + mode: AgentMode +): string => + String + .raw`su - ${config.sshUser} -s /bin/bash -c "bash -lc '. /etc/profile 2>/dev/null || true; . \"$AGENT_ENV_FILE\" 2>/dev/null || true; cd \"$TARGET_DIR\" && ${ + renderAgentPromptCommand(mode) + }'"` + +const renderAgentModeBlock = ( + config: TemplateConfig, + mode: AgentMode +): string => { + const startMessage = `[agent] starting ${mode}...` + const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)` + + return String.raw`"${mode}") + echo "${startMessage}" + if [[ -n "$AGENT_PROMPT" ]]; then + if ${renderAgentAutoLaunchCommand(config, mode)}; then + AGENT_OK=1 + fi + else + echo "${interactiveMessage}" + AGENT_OK=1 + fi + ;;` +} + +const renderAgentModeCase = (config: TemplateConfig): string => + [ + String.raw`case "$AGENT_MODE" in`, + indentBlock(renderAgentModeBlock(config, "claude")), + indentBlock(renderAgentModeBlock(config, "codex")), + indentBlock(renderAgentModeBlock(config, "gemini")), + indentBlock( + String.raw`*) + echo "[agent] unknown agent mode: $AGENT_MODE" + ;;` + ), + "esac" + ].join("\n") + +const renderAgentIssueComment = (config: TemplateConfig): string => + String.raw`echo "[agent] posting review comment to issue #$ISSUE_NUM..." + +PR_BODY="" +PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh pr list --head '$REPO_REF' --json body --jq '.[0].body'" 2>/dev/null) || true + +if [[ -z "$PR_BODY" ]]; then + PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && git log --format='%B' -1" 2>/dev/null) || true +fi + +if [[ -n "$PR_BODY" ]]; then + COMMENT_FILE="/run/docker-git/agent-comment.txt" + printf "%s" "$PR_BODY" > "$COMMENT_FILE" + chmod 644 "$COMMENT_FILE" + su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh issue comment '$ISSUE_NUM' --body-file '$COMMENT_FILE'" || echo "[agent] failed to comment on issue #$ISSUE_NUM" +else + echo "[agent] no PR body or commit message found, skipping comment" +fi` + +const renderProjectMoveScript = (): string => + String.raw`#!/bin/bash +. /run/docker-git/agent-env.sh 2>/dev/null || true +cd "$1" || exit 1 +ISSUE_NUM="$2" + +ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUM" --json id --jq '.id' 2>/dev/null) || true +if [[ -z "$ISSUE_NODE_ID" ]]; then + echo "[agent] could not get issue node ID, skipping move" + exit 0 +fi + +GQL_QUERY='query($nodeId: ID!) { node(id: $nodeId) { ... on Issue { projectItems(first: 1) { nodes { id project { id field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } } } } } } }' +ALL_IDS=$(gh api graphql -F nodeId="$ISSUE_NODE_ID" -f query="$GQL_QUERY" \ + --jq '(.data.node.projectItems.nodes // [])[0] // empty | [.id, .project.id, .project.field.id, ([.project.field.options[] | select(.name | test("review"; "i"))][0].id)] | @tsv' 2>/dev/null) || true + +if [[ -z "$ALL_IDS" ]]; then + echo "[agent] issue #$ISSUE_NUM is not in a project board, skipping move" + exit 0 +fi + +ITEM_ID=$(printf "%s" "$ALL_IDS" | cut -f1) +PROJECT_ID=$(printf "%s" "$ALL_IDS" | cut -f2) +STATUS_FIELD_ID=$(printf "%s" "$ALL_IDS" | cut -f3) +REVIEW_OPTION_ID=$(printf "%s" "$ALL_IDS" | cut -f4) +if [[ -z "$STATUS_FIELD_ID" || -z "$REVIEW_OPTION_ID" || "$STATUS_FIELD_ID" == "null" || "$REVIEW_OPTION_ID" == "null" ]]; then + echo "[agent] review status not found in project board, skipping move" + exit 0 +fi + +MUTATION='mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }' +MOVE_RESULT=$(gh api graphql \ + -F projectId="$PROJECT_ID" \ + -F itemId="$ITEM_ID" \ + -F fieldId="$STATUS_FIELD_ID" \ + -F optionId="$REVIEW_OPTION_ID" \ + -f query="$MUTATION" 2>&1) || true + +if [[ "$MOVE_RESULT" == *"projectV2Item"* ]]; then + echo "[agent] issue #$ISSUE_NUM moved to review" +else + echo "[agent] failed to move issue #$ISSUE_NUM in project board" +fi` + +const renderAgentIssueMove = (config: TemplateConfig): string => + [ + String.raw`echo "[agent] moving issue #$ISSUE_NUM to review..." +MOVE_SCRIPT="/run/docker-git/project-move.sh"`, + String.raw`cat > "$MOVE_SCRIPT" << 'EOFMOVE' +${renderProjectMoveScript()} +EOFMOVE`, + String.raw`chmod +x "$MOVE_SCRIPT" +su - ${config.sshUser} -c "$MOVE_SCRIPT '$TARGET_DIR' '$ISSUE_NUM'" || true` + ].join("\n") + +const renderAgentIssueReview = (config: TemplateConfig): string => + [ + String.raw`if [[ "$AGENT_OK" -eq 1 && "$AGENT_AUTO" == "1" && -n "$ISSUE_NUM" ]]; then`, + indentBlock(renderAgentIssueComment(config)), + "", + renderAgentIssueMove(config), + "fi" + ].join("\n") + +const renderAgentFinalize = (): string => + String.raw`if [[ "$AGENT_OK" -eq 1 ]]; then + echo "[agent] done" + touch "$AGENT_DONE_PATH" +else + echo "[agent] failed" + touch "$AGENT_FAIL_PATH" +fi` + +export const renderAgentLaunch = (config: TemplateConfig): string => + [ + String.raw`# 3) Auto-launch agent if AGENT_MODE is set +if [[ "$CLONE_OK" -eq 1 && -n "$AGENT_MODE" ]]; then`, + indentBlock(renderAgentSetup()), + "", + indentBlock(renderAgentModeCase(config)), + "", + renderAgentIssueReview(config), + "", + indentBlock(renderAgentFinalize()), + "fi" + ].join("\n") +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts b/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts new file mode 100644 index 00000000..51c46ac9 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts @@ -0,0 +1,117 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context +AGENTS_PATH="__CODEX_HOME__/AGENTS.md" +LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): __TARGET_DIR__" +WORKSPACES_LINE="Доступные workspace пути: __TARGET_DIR__" +WORKSPACE_INFO_LINE="Контекст workspace: repository" +FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__" +INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +SUBAGENTS_LINE="Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю." +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" + fi +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" + else + WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" + fi +fi +MANAGED_START="" +MANAGED_END="" +if [[ ! -f "$AGENTS_PATH" ]]; then + MANAGED_BLOCK="$(cat < "$AGENTS_PATH" +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, sshpass, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +$MANAGED_BLOCK +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +EOF + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$AGENTS_PATH" ]]; then + MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" + else + sed \ + -e '/^Рабочая папка проекта (git clone):/d' \ + -e '/^Доступные workspace пути:/d' \ + -e '/^Контекст workspace:/d' \ + -e '/^Фокус задачи:/d' \ + -e '/^Issue AGENTS.md:/d' \ + -e '/^Доступ к интернету:/d' \ + -e '/^Для решения задач обязательно используй subagents[.]/d' \ + "$AGENTS_PATH" > "$TMP_AGENTS_PATH" + if [[ -s "$TMP_AGENTS_PATH" ]]; then + printf "\n" >> "$TMP_AGENTS_PATH" + fi + printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" + fi + mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then + LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then + rm -f "$LEGACY_AGENTS_PATH" + fi +fi` + +export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => + entrypointAgentsNoticeTemplate.replaceAll("__CODEX_HOME__", config.codexHome).replaceAll( + "__SSH_USER__", + config.sshUser + ) + .replaceAll("__TARGET_DIR__", config.targetDir) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/base.ts b/packages/app/src/lib/core/templates-entrypoint/base.ts new file mode 100644 index 00000000..816a55d9 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/base.ts @@ -0,0 +1,170 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" +import { renderInputRc } from "../templates-prompt.js" + +export const renderEntrypointHeader = (config: TemplateConfig): string => + `#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="\${REPO_URL:-}" +REPO_REF="\${REPO_REF:-}" +FORK_REPO_URL="\${FORK_REPO_URL:-}" +TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}" +if [[ "$TARGET_DIR" == "~" ]]; then + TARGET_DIR="$HOME" +elif [[ "$TARGET_DIR" == "~/"* ]]; then + TARGET_DIR="$HOME\${TARGET_DIR:1}" +fi +CLAUDE_AUTH_LABEL="\${CLAUDE_AUTH_LABEL:-}" +CODEX_AUTH_LABEL="\${CODEX_AUTH_LABEL:-}" +GEMINI_AUTH_LABEL="\${GEMINI_AUTH_LABEL:-}" +GIT_AUTH_USER="\${GIT_AUTH_USER:-\${GITHUB_USER:-x-access-token}}" +GIT_AUTH_TOKEN="\${GIT_AUTH_TOKEN:-\${GITHUB_TOKEN:-\${GH_TOKEN:-}}}" +GH_TOKEN="\${GH_TOKEN:-\${GIT_AUTH_TOKEN:-}}" +GITHUB_TOKEN="\${GITHUB_TOKEN:-\${GH_TOKEN:-}}" +GIT_USER_NAME="\${GIT_USER_NAME:-}" +GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}" +CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}" +AGENT_MODE="\${AGENT_MODE:-}" +AGENT_AUTO="\${AGENT_AUTO:-}" +MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" +MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" +MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}" + +SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" + +docker_git_upsert_ssh_env() { + local key="$1" + local value="$2" + + if [[ -d "$SSH_ENV_PATH" ]]; then + mv "$SSH_ENV_PATH" "$SSH_ENV_PATH.bak-$(date +%s)" || true + fi + + mkdir -p "$(dirname "$SSH_ENV_PATH")" + touch "$SSH_ENV_PATH" + + awk -v k="$key" -F= '$1 != k { print }' "$SSH_ENV_PATH" > "$SSH_ENV_PATH.tmp" + mv "$SSH_ENV_PATH.tmp" "$SSH_ENV_PATH" + + printf "%s\n" "$key=$value" >> "$SSH_ENV_PATH" + chmod 600 "$SSH_ENV_PATH" || true + chown 1000:1000 "$SSH_ENV_PATH" || true +}` + +export const renderEntrypointPackageCache = (config: TemplateConfig): string => + `# Keep package manager caches inside the project home volume +PACKAGE_CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/packages" +PACKAGE_PNPM_STORE="\${npm_config_store_dir:-\${PNPM_STORE_DIR:-$PACKAGE_CACHE_ROOT/pnpm/store}}" +PACKAGE_NPM_CACHE="\${npm_config_cache:-\${NPM_CONFIG_CACHE:-$PACKAGE_CACHE_ROOT/npm}}" +PACKAGE_YARN_CACHE="\${YARN_CACHE_FOLDER:-$PACKAGE_CACHE_ROOT/yarn}" + +mkdir -p "$PACKAGE_PNPM_STORE" "$PACKAGE_NPM_CACHE" "$PACKAGE_YARN_CACHE" +chown -R 1000:1000 "$PACKAGE_CACHE_ROOT" || true + +cat < /etc/profile.d/docker-git-package-cache.sh +export PNPM_STORE_DIR="$PACKAGE_PNPM_STORE" +export npm_config_store_dir="$PACKAGE_PNPM_STORE" +export NPM_CONFIG_CACHE="$PACKAGE_NPM_CACHE" +export npm_config_cache="$PACKAGE_NPM_CACHE" +export YARN_CACHE_FOLDER="$PACKAGE_YARN_CACHE" +EOF +chmod 0644 /etc/profile.d/docker-git-package-cache.sh + +docker_git_upsert_ssh_env "PNPM_STORE_DIR" "$PACKAGE_PNPM_STORE" +docker_git_upsert_ssh_env "npm_config_store_dir" "$PACKAGE_PNPM_STORE" +docker_git_upsert_ssh_env "NPM_CONFIG_CACHE" "$PACKAGE_NPM_CACHE" +docker_git_upsert_ssh_env "npm_config_cache" "$PACKAGE_NPM_CACHE" +docker_git_upsert_ssh_env "YARN_CACHE_FOLDER" "$PACKAGE_YARN_CACHE"` + +export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string => + `# 1) Mirror authorized_keys from the project home volume into ~/.ssh +DOCKER_GIT_AUTH_KEYS="/home/${config.sshUser}/.docker-git/authorized_keys" +mkdir -p /home/${config.sshUser}/.ssh +chmod 700 /home/${config.sshUser}/.ssh + +if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then + cp "$DOCKER_GIT_AUTH_KEYS" /home/${config.sshUser}/.ssh/authorized_keys + chmod 600 /home/${config.sshUser}/.ssh/authorized_keys +fi + +chown -R 1000:1000 /home/${config.sshUser}/.ssh` + +export const renderEntrypointDockerSocket = (config: TemplateConfig): string => + `# Ensure docker socket access for ${config.sshUser} +if [[ -S /var/run/docker.sock ]]; then + DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" + DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" + if [[ -z "$DOCKER_GROUP" ]]; then + DOCKER_GROUP="docker" + groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true + fi + usermod -aG "$DOCKER_GROUP" ${config.sshUser} || true + printf "export DOCKER_HOST=unix:///var/run/docker.sock\n" > /etc/profile.d/docker-host.sh +fi` + +export const renderEntrypointZshShell = (config: TemplateConfig): string => + String.raw`# Prefer zsh for ${config.sshUser} when available +if command -v zsh >/dev/null 2>&1; then + usermod -s /usr/bin/zsh ${config.sshUser} || true +fi` + +export const renderEntrypointZshUserRc = (config: TemplateConfig): string => + String.raw`# Ensure ${config.sshUser} has a zshrc and disable newuser wizard +ZSHENV_PATH="/etc/zsh/zshenv" +if [[ -f "$ZSHENV_PATH" ]]; then + if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" + fi +else + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" +fi +USER_ZSHRC="/home/${config.sshUser}/.zshrc" +if [[ ! -f "$USER_ZSHRC" ]]; then + cat <<'EOF' > "$USER_ZSHRC" +# docker-git default zshrc +if [ -f /etc/zsh/zshrc ]; then + source /etc/zsh/zshrc +fi +EOF + chown 1000:1000 "$USER_ZSHRC" || true +fi` + +export const renderEntrypointInputRc = (config: TemplateConfig): string => + String.raw`# Ensure readline history search bindings for ${config.sshUser} +INPUTRC_PATH="/home/${config.sshUser}/.inputrc" +if [[ ! -f "$INPUTRC_PATH" ]]; then + cat <<'EOF' > "$INPUTRC_PATH" +${renderInputRc()} +EOF + chown 1000:1000 "$INPUTRC_PATH" || true +fi` + +export const renderEntrypointBaseline = (): string => + `# 4.5) Snapshot baseline processes for terminal session filtering +mkdir -p /run/docker-git +BASELINE_PATH="/run/docker-git/terminal-baseline.pids" +if [[ ! -f "$BASELINE_PATH" ]]; then + ps -eo pid= > "$BASELINE_PATH" || true +fi` + +export const renderEntrypointDisableMotd = (): string => + String.raw`# 4.75) Disable Ubuntu MOTD noise for SSH sessions +PAM_SSHD="/etc/pam.d/sshd" +if [[ -f "$PAM_SSHD" ]]; then + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true +fi + +# Also disable sshd's own banners (e.g. "Last login") +mkdir -p /etc/ssh/sshd_config.d || true +DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" +cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" +PrintMotd no +PrintLastLog no +EOF +chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true` + +export const renderEntrypointSshd = (): string => + `# 5) Run sshd in foreground (log to stderr for CI/debuggability)\nexec /usr/sbin/sshd -D -e` +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts b/packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts new file mode 100644 index 00000000..9956bdca --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts @@ -0,0 +1,124 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +const entrypointClaudeGlobalPromptTemplate = String + .raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) +CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md" +CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}" +CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" +REPO_REF_VALUE="${"$"}{REPO_REF:-__REPO_REF_DEFAULT__}" +REPO_URL_VALUE="${"$"}{REPO_URL:-__REPO_URL_DEFAULT__}" + +if [[ "$REPO_REF_VALUE" == issue-* ]]; then + ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" + ISSUE_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then + ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO_VALUE" ]]; then + ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" + fi + fi + if [[ -n "$ISSUE_URL_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" + else + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE" + fi +elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then + PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then + PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO_VALUE" ]]; then + PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" + fi + fi + if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" + elif [[ -n "$PR_ID_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE" + else + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)" + fi +fi + +if [[ "$CLAUDE_AUTO_SYSTEM_PROMPT" == "1" ]]; then + mkdir -p "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" + chown 1000:1000 "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" 2>/dev/null || true + if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then + cat < "$CLAUDE_GLOBAL_PROMPT_FILE" + +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, sshpass, claude, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +Рабочая папка проекта (git clone): __TARGET_DIR__ +Доступные workspace пути: __TARGET_DIR__ +$CLAUDE_WORKSPACE_CONTEXT +Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__ +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю. +Если ты видишь файлы AGENTS.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции. + +EOF + chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true + chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true + fi +fi + +export CLAUDE_AUTO_SYSTEM_PROMPT` + +const escapeForDoubleQuotes = (value: string): string => { + const backslash = String.fromCodePoint(92) + const quote = String.fromCodePoint(34) + const escapedBackslash = `${backslash}${backslash}` + const escapedQuote = `${backslash}${quote}` + return value + .replaceAll(backslash, escapedBackslash) + .replaceAll(quote, escapedQuote) +} + +export const renderClaudeGlobalPromptSetup = (config: TemplateConfig): string => + entrypointClaudeGlobalPromptTemplate + .replaceAll("__TARGET_DIR__", config.targetDir) + .replaceAll("__SSH_USER__", config.sshUser) + .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) + .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) + +export const renderClaudeWrapperSetup = (): string => + String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude" +if command -v claude >/dev/null 2>&1; then + CURRENT_CLAUDE_BIN="$(command -v claude)" + CLAUDE_REAL_DIR="$(dirname "$CURRENT_CLAUDE_BIN")" + CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" + + # If a wrapper already exists but points to a missing real binary, recover from /usr/bin. + if [[ "$CURRENT_CLAUDE_BIN" == "$CLAUDE_WRAPPER_BIN" && ! -e "$CLAUDE_REAL_BIN" && -x "/usr/bin/claude" ]]; then + CURRENT_CLAUDE_BIN="/usr/bin/claude" + CLAUDE_REAL_DIR="/usr/bin" + CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" + fi + + # Keep the "real" binary in the same directory as the original command to preserve relative symlinks. + if [[ "$CURRENT_CLAUDE_BIN" != "$CLAUDE_REAL_BIN" && ! -e "$CLAUDE_REAL_BIN" ]]; then + mv "$CURRENT_CLAUDE_BIN" "$CLAUDE_REAL_BIN" + fi + if [[ -e "$CLAUDE_REAL_BIN" ]]; then + cat <<'EOF' > "$CLAUDE_WRAPPER_BIN" +#!/usr/bin/env bash +set -euo pipefail + +CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__" +CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}" +CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" + +if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then + CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" + export CLAUDE_CODE_OAUTH_TOKEN +else + unset CLAUDE_CODE_OAUTH_TOKEN || true +fi + +exec "$CLAUDE_REAL_BIN" "$@" +EOF + sed -i "s#__CLAUDE_REAL_BIN__#$CLAUDE_REAL_BIN#g" "$CLAUDE_WRAPPER_BIN" || true + chmod 0755 "$CLAUDE_WRAPPER_BIN" || true + fi +fi` +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/claude.ts b/packages/app/src/lib/core/templates-entrypoint/claude.ts new file mode 100644 index 00000000..209ae0fe --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/claude.ts @@ -0,0 +1,279 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" +import { renderClaudeGlobalPromptSetup, renderClaudeWrapperSetup } from "./claude-extra-config.js" + +const claudeAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/claude` + +const claudeAuthConfigTemplate = String + .raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude) +CLAUDE_LABEL_RAW="$CLAUDE_AUTH_LABEL" +if [[ -z "$CLAUDE_LABEL_RAW" ]]; then + CLAUDE_LABEL_RAW="default" +fi + +CLAUDE_LABEL_NORM="$(printf "%s" "$CLAUDE_LABEL_RAW" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" +if [[ -z "$CLAUDE_LABEL_NORM" ]]; then + CLAUDE_LABEL_NORM="default" +fi + +CLAUDE_AUTH_ROOT="__CLAUDE_AUTH_ROOT__" +CLAUDE_CONFIG_DIR="$CLAUDE_AUTH_ROOT/$CLAUDE_LABEL_NORM" + +# Backward compatibility: if default auth is stored directly under claude root, reuse it. +if [[ "$CLAUDE_LABEL_NORM" == "default" ]]; then + CLAUDE_ROOT_TOKEN_FILE="$CLAUDE_AUTH_ROOT/.oauth-token" + CLAUDE_ROOT_CONFIG_FILE="$CLAUDE_AUTH_ROOT/.config.json" + if [[ -f "$CLAUDE_ROOT_TOKEN_FILE" ]] || [[ -f "$CLAUDE_ROOT_CONFIG_FILE" ]]; then + CLAUDE_CONFIG_DIR="$CLAUDE_AUTH_ROOT" + fi +fi + +export CLAUDE_CONFIG_DIR + +mkdir -p "$CLAUDE_CONFIG_DIR" || true +CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__" +CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__" +mkdir -p "$CLAUDE_HOME_DIR" || true +CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" +CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json" +CLAUDE_NESTED_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.claude/.credentials.json" + +docker_git_prepare_claude_auth_mode() { + if [[ -s "$CLAUDE_TOKEN_FILE" ]]; then + rm -f "$CLAUDE_CREDENTIALS_FILE" "$CLAUDE_NESTED_CREDENTIALS_FILE" "$CLAUDE_HOME_DIR/.credentials.json" || true + fi +} + +docker_git_prepare_claude_auth_mode + +docker_git_link_claude_file() { + local source_path="$1" + local link_path="$2" + + # Preserve user-created regular files and seed config dir once. + if [[ -e "$link_path" && ! -L "$link_path" ]]; then + if [[ -f "$link_path" && ! -e "$source_path" ]]; then + cp "$link_path" "$source_path" || true + chmod 0600 "$source_path" || true + fi + return 0 + fi + + ln -sfn "$source_path" "$link_path" || true +} + +docker_git_link_claude_home_file() { + local relative_path="$1" + local source_path="$CLAUDE_CONFIG_DIR/$relative_path" + local link_path="$CLAUDE_HOME_DIR/$relative_path" + docker_git_link_claude_file "$source_path" "$link_path" +} + +docker_git_link_claude_home_file ".oauth-token" +docker_git_link_claude_home_file ".config.json" +docker_git_link_claude_home_file ".claude.json" +if [[ ! -s "$CLAUDE_TOKEN_FILE" ]]; then + docker_git_link_claude_home_file ".credentials.json" +fi +docker_git_link_claude_file "$CLAUDE_CONFIG_DIR/.claude.json" "$CLAUDE_HOME_JSON" + +docker_git_refresh_claude_oauth_token() { + local token="" + if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then + token="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" + fi + if [[ -n "$token" ]]; then + export CLAUDE_CODE_OAUTH_TOKEN="$token" + else + unset CLAUDE_CODE_OAUTH_TOKEN || true + fi +} + +docker_git_refresh_claude_oauth_token` + +const renderClaudeAuthConfig = (config: TemplateConfig): string => + claudeAuthConfigTemplate + .replaceAll("__CLAUDE_AUTH_ROOT__", claudeAuthRootContainerPath(config.sshUser)) + .replaceAll("__CLAUDE_HOME_DIR__", `/home/${config.sshUser}/.claude`) + .replaceAll("__CLAUDE_HOME_JSON__", `/home/${config.sshUser}/.claude.json`) + +const renderClaudeCliInstall = (): string => + String.raw`# Claude Code: ensure CLI command exists (non-blocking startup self-heal) +docker_git_ensure_claude_cli() { + if command -v claude >/dev/null 2>&1; then + return 0 + fi + + if ! command -v npm >/dev/null 2>&1; then + return 0 + fi + + NPM_ROOT="$(npm root -g 2>/dev/null || true)" + CLAUDE_CLI_JS="$NPM_ROOT/@anthropic-ai/claude-code/cli.js" + if [[ -z "$NPM_ROOT" || ! -f "$CLAUDE_CLI_JS" ]]; then + echo "docker-git: claude cli.js not found under npm global root; skip shim restore" >&2 + return 0 + fi + + # Rebuild a minimal shim when npm package exists but binary link is missing. + cat <<'EOF' > /usr/local/bin/claude +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v npm >/dev/null 2>&1; then + echo "claude: npm is required but missing" >&2 + exit 127 +fi + +NPM_ROOT="$(npm root -g 2>/dev/null || true)" +CLAUDE_CLI_JS="$NPM_ROOT/@anthropic-ai/claude-code/cli.js" +if [[ -z "$NPM_ROOT" || ! -f "$CLAUDE_CLI_JS" ]]; then + echo "claude: cli.js not found under npm global root" >&2 + exit 127 +fi + +exec node "$CLAUDE_CLI_JS" "$@" +EOF + chmod 0755 /usr/local/bin/claude || true + ln -sf /usr/local/bin/claude /usr/bin/claude || true +} + +docker_git_ensure_claude_cli` + +const renderClaudePermissionSettingsConfig = (): string => + String.raw`# Claude Code: keep permission settings in sync with docker-git defaults +CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" +docker_git_sync_claude_permissions() { + CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_PERMISSION_SETTINGS_FILE" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_PERMISSION_SETTINGS_FILE +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const raw = fs.readFileSync(settingsPath, "utf8") + const parsed = JSON.parse(raw) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {} +const nextPermissions = { + ...currentPermissions, + defaultMode: "bypassPermissions" +} +const nextSettings = { + ...settings, + permissions: nextPermissions +} + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_claude_permissions +chmod 0600 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true +chown 1000:1000 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true` + +const renderClaudeMcpPlaywrightConfig = (): string => + String.raw`# Claude Code: keep Playwright MCP config in sync with container settings +CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" +docker_git_sync_claude_playwright_mcp() { + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="$MCP_PLAYWRIGHT_ENABLE" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_SETTINGS_FILE +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const enablePlaywright = process.env.MCP_PLAYWRIGHT_ENABLE === "1" +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const raw = fs.readFileSync(settingsPath, "utf8") + const parsed = JSON.parse(raw) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} +const nextServers = { ...currentServers } +if (enablePlaywright) { + nextServers.playwright = { + type: "stdio", + command: "docker-git-playwright-mcp", + args: [], + env: {} + } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +if (Object.keys(nextServers).length > 0) { + nextSettings.mcpServers = nextServers +} else { + delete nextSettings.mcpServers +} + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_claude_playwright_mcp +chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` + +const renderClaudeProfileSetup = (): string => + String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh" +printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE" +printf "export CLAUDE_CONFIG_DIR=%q\n" "$CLAUDE_CONFIG_DIR" >> "$CLAUDE_PROFILE" +printf "export CLAUDE_AUTO_SYSTEM_PROMPT=%q\n" "$CLAUDE_AUTO_SYSTEM_PROMPT" >> "$CLAUDE_PROFILE" +cat <<'EOF' >> "$CLAUDE_PROFILE" +CLAUDE_TOKEN_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.oauth-token" +if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then + export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" +else + unset CLAUDE_CODE_OAUTH_TOKEN || true +fi +EOF +chmod 0644 "$CLAUDE_PROFILE" || true + +docker_git_upsert_ssh_env "CLAUDE_AUTH_LABEL" "$CLAUDE_AUTH_LABEL" +docker_git_upsert_ssh_env "CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR" +docker_git_upsert_ssh_env "CLAUDE_CODE_OAUTH_TOKEN" "${"$"}{CLAUDE_CODE_OAUTH_TOKEN:-}" +docker_git_upsert_ssh_env "CLAUDE_AUTO_SYSTEM_PROMPT" "$CLAUDE_AUTO_SYSTEM_PROMPT"` + +export const renderEntrypointClaudeConfig = (config: TemplateConfig): string => + [ + renderClaudeAuthConfig(config), + renderClaudeCliInstall(), + renderClaudePermissionSettingsConfig(), + renderClaudeMcpPlaywrightConfig(), + renderClaudeGlobalPromptSetup(config), + renderClaudeWrapperSetup(), + renderClaudeProfileSetup() + ].join("\n\n") +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts b/packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts new file mode 100644 index 00000000..98e57642 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts @@ -0,0 +1,100 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +const escapeForDoubleQuotes = (value: string): string => { + const backslash = String.fromCodePoint(92) + return value + .replaceAll(backslash, `${backslash}${backslash}`) + .replaceAll(String.fromCodePoint(34), `${backslash}${String.fromCodePoint(34)}`) +} + +const entrypointCodexResumeHintTemplate = `# Ensure codex resume hint is shown for interactive shells +CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" +if [[ ! -s "$CODEX_HINT_PATH" ]]; then + cat <<'EOF' > "$CODEX_HINT_PATH" +docker_git_workspace_context_line() { + REPO_REF_VALUE="\${REPO_REF:-__REPO_REF_DEFAULT__}" + REPO_URL_VALUE="\${REPO_URL:-__REPO_URL_DEFAULT__}" + + if [[ "$REPO_REF_VALUE" == issue-* ]]; then + ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" + ISSUE_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then + ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO_VALUE" ]]; then + ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" + fi + fi + if [[ -n "$ISSUE_URL_VALUE" ]]; then + printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" + else + printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE" + fi + return + fi + + if [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then + PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\\1#p')" + PR_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then + PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO_VALUE" ]]; then + PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" + fi + fi + if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then + printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" + elif [[ -n "$PR_ID_VALUE" ]]; then + printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE" + elif [[ -n "$REPO_REF_VALUE" ]]; then + printf "%s\n" "Контекст workspace: pull request ($REPO_REF_VALUE)" + fi + return + fi + + if [[ -n "$REPO_URL_VALUE" ]]; then + printf "%s\n" "Контекст workspace: $REPO_URL_VALUE" + fi +} + +docker_git_print_codex_resume_hint() { + if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then + DOCKER_GIT_CONTEXT_LINE="$(docker_git_workspace_context_line)" + if [[ -n "$DOCKER_GIT_CONTEXT_LINE" ]]; then + echo "$DOCKER_GIT_CONTEXT_LINE" + fi + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi +} + +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + docker_git_print_codex_resume_hint + ;; + esac +fi +if [ -n "$ZSH_VERSION" ]; then + if [[ "$-" == *i* ]]; then + docker_git_print_codex_resume_hint + fi +fi +EOF + chmod 0644 "$CODEX_HINT_PATH" +fi +if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc +fi +if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then + printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc +fi` + +// PURITY: CORE +// INVARIANT: rendered output contains shell-escaped repo ref and url placeholders +// COMPLEXITY: O(1) +export const renderEntrypointCodexResumeHint = (config: TemplateConfig): string => + entrypointCodexResumeHintTemplate + .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) + .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/codex.ts b/packages/app/src/lib/core/templates-entrypoint/codex.ts new file mode 100644 index 00000000..52685ff7 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/codex.ts @@ -0,0 +1,176 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +export { renderEntrypointCodexResumeHint } from "./codex-resume-hint.js" + +export const renderEntrypointCodexHome = (config: TemplateConfig): string => + `# Ensure Codex home exists if mounted +mkdir -p ${config.codexHome} && chown -R 1000:1000 ${config.codexHome} + +DOCKER_GIT_CODEX_BOOTSTRAP="/home/${config.sshUser}/.docker-git/.orch/auth/codex/config.toml" +if [[ -f "$DOCKER_GIT_CODEX_BOOTSTRAP" && ! -f "${config.codexHome}/config.toml" ]]; then cp "$DOCKER_GIT_CODEX_BOOTSTRAP" "${config.codexHome}/config.toml"; chown 1000:1000 "${config.codexHome}/config.toml" || true; fi + +# Ensure home ownership matches the dev UID/GID (volumes may be stale) +HOME_OWNER="$(stat -c "%u:%g" /home/${config.sshUser} 2>/dev/null || echo "")" +if [[ "$HOME_OWNER" != "1000:1000" ]]; then + chown -R 1000:1000 /home/${config.sshUser} || true +fi` + +export const renderEntrypointCodexSharedAuth = (config: TemplateConfig): string => + `# Share Codex auth.json across projects (avoids refresh_token_reused) +CODEX_SHARE_AUTH="\${CODEX_SHARE_AUTH:-1}" +if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then + CODEX_LABEL_RAW="$CODEX_AUTH_LABEL" + if [[ -z "$CODEX_LABEL_RAW" ]]; then CODEX_LABEL_RAW="default"; fi + CODEX_LABEL_NORM="$(printf "%s" "$CODEX_LABEL_RAW" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + if [[ -z "$CODEX_LABEL_NORM" ]]; then CODEX_LABEL_NORM="default"; fi + CODEX_AUTH_LABEL="$CODEX_LABEL_NORM" + DOCKER_GIT_CODEX_AUTH_ROOT="/home/${config.sshUser}/.docker-git/.orch/auth/codex"; CODEX_SHARED_HOME="${config.codexHome}-shared" + mkdir -p "$CODEX_SHARED_HOME" && chown -R 1000:1000 "$CODEX_SHARED_HOME" || true + AUTH_FILE="${config.codexHome}/auth.json"; SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/auth.json" + if [[ "$CODEX_LABEL_NORM" != "default" ]]; then + SHARED_AUTH_FILE="$CODEX_SHARED_HOME/$CODEX_LABEL_NORM/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/$CODEX_LABEL_NORM/auth.json"; mkdir -p "$(dirname "$SHARED_AUTH_FILE")" + fi + if [[ -f "$SHARED_AUTH_SEED" ]]; then + cp "$SHARED_AUTH_SEED" "$SHARED_AUTH_FILE" + chmod 600 "$SHARED_AUTH_FILE" || true + chown 1000:1000 "$SHARED_AUTH_FILE" || true + else + rm -f "$SHARED_AUTH_FILE" || true + fi + # Guard against a bad bind mount creating a directory at auth.json. + if [[ -d "$AUTH_FILE" ]]; then + mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true + fi + if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then + rm -f "$AUTH_FILE" || true + fi + ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" + docker_git_upsert_ssh_env "CODEX_AUTH_LABEL" "$CODEX_AUTH_LABEL" +fi` + +const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Playwright MCP for Codex (browser automation) +CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" + +# Keep config.toml consistent with the container build. +# If Playwright MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn docker-git-playwright-mcp. +if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.4" +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +shell_snapshot = true +multi_agent = true +apps = true +shell_tool = true + +[profiles.longcontx] +model = "gpt-5.4" +model_context_window = 1050000 +model_auto_compact_token_limit = 945000 +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then + MCP_PLAYWRIGHT_CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223" + fi + + # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Playwright MCP (connects to Chromium via CDP) +[mcp_servers.playwright] +command = "docker-git-playwright-mcp" +args = [] +EOF +fi` + +export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => + entrypointMcpPlaywrightTemplate + .replaceAll("__CODEX_HOME__", config.codexHome) + .replaceAll("__SERVICE_NAME__", config.serviceName) + +const entrypointProjectCodexSkillsSyncTemplate = String + .raw`# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. +docker_git_sync_project_codex_skills() { + local codex_home="${"$"}{CODEX_HOME:-__CODEX_HOME__}" + local project_dir="${"$"}{TARGET_DIR:-}" + local project_skills_root="$codex_home/skills/.docker-git-project" + local linked=0 + local spec="" + local mount_name="" + local relative_path="" + + if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then + return 0 + fi + + mkdir -p "$codex_home/skills" + rm -rf "$project_skills_root" + mkdir -p "$project_skills_root" + + # Priority goes from generic/shared skill trees -> Codex-specific trees. + for spec in \ + "10-root-skills::.skills" \ + "20-agents-skills::.agents/skills" \ + "30-agents-dot-skills::.agents/.skills" \ + "80-codex-skills::.codex/skills" \ + "90-codex-dot-skills::.codex/.skills"; do + mount_name="${"$"}{spec%%::*}" + relative_path="${"$"}{spec#*::}" + + if [[ -d "$project_dir/$relative_path" ]]; then + ln -sfn "$project_dir/$relative_path" "$project_skills_root/$mount_name" + chown -h 1000:1000 "$project_skills_root/$mount_name" 2>/dev/null || true + linked=1 + fi + done + + chown 1000:1000 "$codex_home/skills" "$project_skills_root" 2>/dev/null || true + + if [[ "$linked" -eq 1 ]]; then + echo "[codex-skills] linked project skill trees into $project_skills_root" + fi +}` + +export const renderEntrypointProjectCodexSkillsSync = (config: TemplateConfig): string => + entrypointProjectCodexSkillsSyncTemplate.replaceAll("__CODEX_HOME__", config.codexHome) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/dns-repair.ts b/packages/app/src/lib/core/templates-entrypoint/dns-repair.ts new file mode 100644 index 00000000..b34527b9 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/dns-repair.ts @@ -0,0 +1,51 @@ +/* jscpd:ignore-start */ +// CHANGE: add automatic DNS repair at container startup +// WHY: Docker internal DNS (127.0.0.11) intermittently loses external nameservers, +// causing domain resolution to fail inside containers +// QUOTE(ТЗ): "При запуске контейнера он всегда исправляет интернет соединение потому что оно время от времени ложится" +// REF: issue-168 +// SOURCE: n/a +// FORMAT THEOREM: ∀container: startup(container) → dns_healthy(container) ∨ dns_repaired(container) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: after execution, at least one nameserver in /etc/resolv.conf resolves external domains +// COMPLEXITY: O(1) per probe attempt, O(max_attempts) worst case +export const renderEntrypointDnsRepair = (): string => + String.raw`# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken +docker_git_repair_dns() { + local test_domain="github.com" + local resolv="/etc/resolv.conf" + local fallback_dns="8.8.8.8 8.8.4.4 1.1.1.1" + + if getent hosts "$test_domain" >/dev/null 2>&1; then + return 0 + fi + + echo "[dns-repair] DNS resolution failed for $test_domain; attempting repair..." + + # Preserve Docker internal resolver but append external fallbacks + local has_external=0 + for ns in $fallback_dns; do + if grep -q "nameserver $ns" "$resolv" 2>/dev/null; then + has_external=1 + fi + done + + if [[ "$has_external" -eq 0 ]]; then + for ns in $fallback_dns; do + printf "nameserver %s\n" "$ns" >> "$resolv" + done + echo "[dns-repair] appended fallback nameservers to $resolv" + fi + + # Verify fix + if getent hosts "$test_domain" >/dev/null 2>&1; then + echo "[dns-repair] DNS resolution restored" + return 0 + fi + + echo "[dns-repair] WARNING: DNS resolution still failing after repair attempt" + return 1 +} +docker_git_repair_dns || true` +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/gemini.ts b/packages/app/src/lib/core/templates-entrypoint/gemini.ts new file mode 100644 index 00000000..fac66f92 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/gemini.ts @@ -0,0 +1,296 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +// CHANGE: add Gemini CLI entrypoint configuration +// WHY: enable Gemini CLI in Docker with automated auth, trust settings and MCP +// REF: issue-146 +// SOURCE: https://github.com/google-gemini/gemini-cli +// FORMAT THEOREM: renderEntrypointGeminiConfig(config) -> valid_bash_script +// PURITY: CORE +// INVARIANT: configurations are isolated by GEMINI_AUTH_LABEL +// COMPLEXITY: O(1) + +const geminiAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/gemini` + +const geminiAuthConfigTemplate = String + .raw`# Gemini CLI: keep ~/.gemini as a real home directory while sharing auth files from ~/.docker-git/.orch/auth/gemini +GEMINI_LABEL_RAW="$GEMINI_AUTH_LABEL" +if [[ -z "$GEMINI_LABEL_RAW" ]]; then + GEMINI_LABEL_RAW="default" +fi + +GEMINI_LABEL_NORM="$(printf "%s" "$GEMINI_LABEL_RAW" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" +if [[ -z "$GEMINI_LABEL_NORM" ]]; then + GEMINI_LABEL_NORM="default" +fi + +GEMINI_AUTH_ROOT="__GEMINI_AUTH_ROOT__" +export GEMINI_CONFIG_DIR="$GEMINI_AUTH_ROOT/$GEMINI_LABEL_NORM" + +mkdir -p "$GEMINI_CONFIG_DIR" || true +GEMINI_HOME_DIR="__GEMINI_HOME_DIR__" +mkdir -p "$GEMINI_HOME_DIR" || true +GEMINI_SHARED_HOME_DIR="$GEMINI_CONFIG_DIR/.gemini" +mkdir -p "$GEMINI_SHARED_HOME_DIR" || true + +docker_git_link_gemini_file() { + local source_path="$1" + local link_path="$2" + + if [[ -e "$link_path" && ! -L "$link_path" ]]; then + if [[ -f "$link_path" && ! -e "$source_path" ]]; then + cp "$link_path" "$source_path" || true + chmod 0600 "$source_path" || true + fi + return 0 + fi + + ln -sfn "$source_path" "$link_path" || true +} + +docker_git_prepare_gemini_home_dir() { + if [[ -L "$GEMINI_HOME_DIR" ]]; then + local previous_target + previous_target="$(readlink -f "$GEMINI_HOME_DIR" || true)" + rm -f "$GEMINI_HOME_DIR" || true + mkdir -p "$GEMINI_HOME_DIR" || true + if [[ -n "$previous_target" && -d "$previous_target" ]]; then + cp -a "$previous_target"/. "$GEMINI_HOME_DIR"/ 2>/dev/null || true + fi + return 0 + fi + + mkdir -p "$GEMINI_HOME_DIR" || true +} + +docker_git_prepare_gemini_home_dir + +# Link .api-key and .env from central auth storage to container home +docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.api-key" "$GEMINI_HOME_DIR/.api-key" +docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.env" "$GEMINI_HOME_DIR/.env" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth_creds.json" "$GEMINI_HOME_DIR/oauth_creds.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth-tokens.json" "$GEMINI_HOME_DIR/oauth-tokens.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/credentials.json" "$GEMINI_HOME_DIR/credentials.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/application_default_credentials.json" "$GEMINI_HOME_DIR/application_default_credentials.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/google_accounts.json" "$GEMINI_HOME_DIR/google_accounts.json" +docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/projects.json" "$GEMINI_HOME_DIR/projects.json" + +# Ensure gemini YOLO wrapper exists +GEMINI_REAL_BIN="$(command -v gemini || echo "/usr/local/bin/gemini")" +GEMINI_WRAPPER_BIN="/usr/local/bin/gemini-wrapper" +if [[ -f "$GEMINI_REAL_BIN" && "$GEMINI_REAL_BIN" != "$GEMINI_WRAPPER_BIN" ]]; then + if [[ ! -f "$GEMINI_WRAPPER_BIN" ]]; then + cat <<'EOF' > "$GEMINI_WRAPPER_BIN" +#!/usr/bin/env bash +GEMINI_ORIGINAL_BIN="__GEMINI_REAL_BIN__" +exec "$GEMINI_ORIGINAL_BIN" --yolo "$@" +EOF + sed -i "s#__GEMINI_REAL_BIN__#$GEMINI_REAL_BIN#g" "$GEMINI_WRAPPER_BIN" || true + chmod 0755 "$GEMINI_WRAPPER_BIN" || true + # Create an alias or symlink if needed, but here we just ensure it exists + fi +fi + +docker_git_refresh_gemini_env() { + # If .api-key exists, export it as GEMINI_API_KEY + if [[ -f "$GEMINI_HOME_DIR/.api-key" ]]; then + export GEMINI_API_KEY="$(cat "$GEMINI_HOME_DIR/.api-key" | tr -d '\r\n')" + elif [[ -f "$GEMINI_HOME_DIR/.env" ]]; then + # Parse GEMINI_API_KEY from .env + API_KEY="$(grep "^GEMINI_API_KEY=" "$GEMINI_HOME_DIR/.env" | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//")" + if [[ -n "$API_KEY" ]]; then + export GEMINI_API_KEY="$API_KEY" + fi + fi +} + +docker_git_refresh_gemini_env` + +const renderGeminiAuthConfig = (config: TemplateConfig): string => + geminiAuthConfigTemplate + .replaceAll("__GEMINI_AUTH_ROOT__", geminiAuthRootContainerPath(config.sshUser)) + .replaceAll("__GEMINI_HOME_DIR__", config.geminiHome) + +const geminiSettingsJsonTemplate = `{ + "model": { + "name": "gemini-3.1-pro-preview", + "compressionThreshold": 0.9, + "disableLoopDetection": true + }, + "modelConfigs": { + "customAliases": { + "yolo-ultra": { + "modelConfig": { + "model": "gemini-3.1-pro-preview", + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + }, + { + "urlContext": {} + } + ] + } + } + } + } + }, + "general": { + "defaultApprovalMode": "auto_edit" + }, + "tools": { + "allowed": [ + "run_shell_command", + "write_file", + "googleSearch", + "urlContext" + ] + }, + "sandbox": { + "enabled": false + }, + "security": { + "folderTrust": { + "enabled": false + }, + "auth": { + "selectedType": "oauth-personal" + }, + "disableYoloMode": false + }, + "mcpServers": { + "playwright": { + "command": "docker-git-playwright-mcp", + "args": [], + "trust": true + } + } +}` + +const renderGeminiPermissionSettingsConfig = (config: TemplateConfig): string => + String.raw`# Gemini CLI: keep trust settings in sync with docker-git defaults +GEMINI_SETTINGS_DIR="${config.geminiHome}" +GEMINI_TRUST_SETTINGS_FILE="$GEMINI_SETTINGS_DIR/trustedFolders.json" +GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_SETTINGS_DIR/settings.json" + +# Wait for symlink to be established by the auth config step +mkdir -p "$GEMINI_SETTINGS_DIR" || true + +# Disable folder trust prompt and enable auto-approval in settings.json +cat <<'EOF' > "$GEMINI_CONFIG_SETTINGS_FILE" +${geminiSettingsJsonTemplate} +EOF + +# Pre-trust important directories in trustedFolders.json +# Use flat mapping as required by recent Gemini CLI versions +cat <<'EOF' > "$GEMINI_TRUST_SETTINGS_FILE" +{ + "/": "TRUST_FOLDER", + "${config.geminiHome}": "TRUST_FOLDER", + "${config.targetDir}": "TRUST_FOLDER" +} +EOF + +chown -R 1000:1000 "$GEMINI_SETTINGS_DIR" || true +chmod 0600 "$GEMINI_TRUST_SETTINGS_FILE" "$GEMINI_CONFIG_SETTINGS_FILE" 2>/dev/null || true` + +const renderGeminiSudoConfig = (config: TemplateConfig): string => + String.raw`# Gemini CLI: allow passwordless sudo for agent tasks +if [[ -d /etc/sudoers.d ]]; then + echo "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/gemini-agent + chmod 0440 /etc/sudoers.d/gemini-agent +fi` + +const renderGeminiMcpPlaywrightConfig = (_config: TemplateConfig): string => + String.raw`# Gemini CLI: keep Playwright MCP config in sync (TODO: Gemini CLI MCP integration format) +# For now, Gemini CLI uses MCP via ~/.gemini/settings.json or command line. +# We'll ensure it has the same Playwright capability as Claude/Codex once format is confirmed.` + +const renderGeminiProfileSetup = (config: TemplateConfig): string => + String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" +printf "export GEMINI_AUTH_LABEL=%q\n" "$GEMINI_AUTH_LABEL" > "$GEMINI_PROFILE" +printf "export GEMINI_HOME=%q\n" "${config.geminiHome}" >> "$GEMINI_PROFILE" +printf "export GEMINI_CLI_DISABLE_UPDATE_CHECK=true\n" >> "$GEMINI_PROFILE" +printf "export GEMINI_CLI_NONINTERACTIVE=true\n" >> "$GEMINI_PROFILE" +printf "export GEMINI_CLI_APPROVAL_MODE=yolo\n" >> "$GEMINI_PROFILE" +printf "alias gemini='/usr/local/bin/gemini-wrapper'\n" >> "$GEMINI_PROFILE" +cat <<'EOF' >> "$GEMINI_PROFILE" +if [[ -f "$GEMINI_HOME/.api-key" ]]; then + export GEMINI_API_KEY="$(cat "$GEMINI_HOME/.api-key" | tr -d '\r\n')" +fi +EOF +chmod 0644 "$GEMINI_PROFILE" || true + +docker_git_upsert_ssh_env "GEMINI_AUTH_LABEL" "$GEMINI_AUTH_LABEL" +docker_git_upsert_ssh_env "GEMINI_API_KEY" "\${GEMINI_API_KEY:-}" +docker_git_upsert_ssh_env "GEMINI_CLI_DISABLE_UPDATE_CHECK" "true" +docker_git_upsert_ssh_env "GEMINI_CLI_NONINTERACTIVE" "true" +docker_git_upsert_ssh_env "GEMINI_CLI_APPROVAL_MODE" "yolo"` + +const entrypointGeminiNoticeTemplate = String.raw`# Ensure global GEMINI.md exists for container context +GEMINI_MD_PATH="__GEMINI_HOME__/GEMINI.md" +GEMINI_WORKSPACE_CONTEXT="Контекст workspace: repository" +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID" + fi +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID" + else + GEMINI_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF)" + fi +fi + +cat < "$GEMINI_MD_PATH" + +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, gemini, claude, opencode, oh-my-opencode, sshpass, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +Рабочая папка проекта (git clone): __TARGET_DIR__ +Доступные workspace пути: __TARGET_DIR__ +$GEMINI_WORKSPACE_CONTEXT +Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__ +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю. +Если ты видишь файлы AGENTS.md, GEMINI.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции. + +EOF +chown 1000:1000 "$GEMINI_MD_PATH" || true` + +const renderEntrypointGeminiNotice = (config: TemplateConfig): string => + entrypointGeminiNoticeTemplate + .replaceAll("__GEMINI_HOME__", config.geminiHome) + .replaceAll("__TARGET_DIR__", config.targetDir) + +export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => + [ + renderGeminiAuthConfig(config), + renderGeminiPermissionSettingsConfig(config), + renderGeminiMcpPlaywrightConfig(config), + renderGeminiSudoConfig(config), + renderGeminiProfileSetup(config), + renderEntrypointGeminiNotice(config) + ].join("\n\n") +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts b/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts new file mode 100644 index 00000000..22214a76 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts @@ -0,0 +1,169 @@ +/* jscpd:ignore-start */ +const entrypointGitPostPushWrapperInstall = String + .raw`# 5.5) Install git wrapper so post-push actions run for normal git push invocations. +# Git has no client-side post-push hook, so core.hooksPath alone is insufficient. +GIT_WRAPPER_BIN="/usr/local/bin/git" +GIT_REAL_BIN="$(type -aP git | awk -v wrapper="$GIT_WRAPPER_BIN" '$0 != wrapper { print; exit }')" +if [[ -n "$GIT_REAL_BIN" ]]; then + cat <<'EOF' > "$GIT_WRAPPER_BIN" +#!/usr/bin/env bash +set -euo pipefail + +# docker-git managed git wrapper +DOCKER_GIT_REAL_GIT_BIN="__DOCKER_GIT_REAL_BIN__" +DOCKER_GIT_POST_PUSH_ACTION="/opt/docker-git/hooks/post-push" + +docker_git_git_subcommand() { + local expect_value="0" + local arg="" + for arg in "$@"; do + if [[ "$expect_value" == "1" ]]; then + expect_value="0" + continue + fi + + case "$arg" in + --help|-h|--version|--html-path|--man-path|--info-path|--list-cmds|--list-cmds=*) + return 1 + ;; + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + expect_value="1" + continue + ;; + --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) + continue + ;; + --) + return 1 + ;; + -*) + continue + ;; + *) + printf "%s" "$arg" + return 0 + ;; + esac + done + + return 1 +} + +docker_git_git_resolve_repo_root() { + local -a git_context=() + local expect_value="0" + local arg="" + + for arg in "$@"; do + if [[ "$expect_value" == "1" ]]; then + git_context+=("$arg") + expect_value="0" + continue + fi + + case "$arg" in + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + git_context+=("$arg") + expect_value="1" + continue + ;; + --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) + git_context+=("$arg") + continue + ;; + --) + break + ;; + -*) + continue + ;; + *) + break + ;; + esac + done + + "$DOCKER_GIT_REAL_GIT_BIN" "${"${"}git_context[@]}" rev-parse --show-toplevel 2>/dev/null +} + +docker_git_git_push_is_dry_run() { + local expect_value="0" + local parsing_push_args="0" + local arg="" + + for arg in "$@"; do + if [[ "$parsing_push_args" == "0" ]]; then + if [[ "$expect_value" == "1" ]]; then + expect_value="0" + continue + fi + + case "$arg" in + -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) + expect_value="1" + continue + ;; + --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) + continue + ;; + push) + parsing_push_args="1" + continue + ;; + esac + + continue + fi + + case "$arg" in + --) + break + ;; + --dry-run|-n) + return 0 + ;; + esac + done + + return 1 +} + +docker_git_post_push_action() { + local repo_root="" + + if [[ "${"${"}DOCKER_GIT_SKIP_POST_PUSH_ACTION:-}" == "1" ]]; then + return 0 + fi + + if [[ -x "$DOCKER_GIT_POST_PUSH_ACTION" ]]; then + if repo_root="$(docker_git_git_resolve_repo_root "$@")" && [[ -n "$repo_root" ]]; then + DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + else + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" || true + fi + fi +} + +subcommand="" +if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" ]]; then + if "$DOCKER_GIT_REAL_GIT_BIN" "$@"; then + status=0 + else + status=$? + fi + + if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then + docker_git_post_push_action "$@" + fi + + exit "$status" +fi + +exec "$DOCKER_GIT_REAL_GIT_BIN" "$@" +EOF + sed -i "s#__DOCKER_GIT_REAL_BIN__#$GIT_REAL_BIN#g" "$GIT_WRAPPER_BIN" || true + chmod 0755 "$GIT_WRAPPER_BIN" || true +fi` + +export const renderEntrypointGitPostPushWrapperInstall = (): string => entrypointGitPostPushWrapperInstall +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/git.ts b/packages/app/src/lib/core/templates-entrypoint/git.ts new file mode 100644 index 00000000..d9b9ba59 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/git.ts @@ -0,0 +1,303 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" +import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" + +const renderAuthLabelResolution = (): string => + String.raw`# 2) Ensure GitHub auth vars are available for SSH sessions. +# Prefer a label-selected token (same selection model as clone/create) when present. +RESOLVED_AUTH_LABEL="" +AUTH_LABEL_RAW="${"${"}GIT_AUTH_LABEL:-${"${"}GITHUB_AUTH_LABEL:-}}" + +if [[ -z "$AUTH_LABEL_RAW" && "$REPO_URL" == https://github.com/* ]]; then + AUTH_LABEL_RAW="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##' | cut -d/ -f1)" +fi + +if [[ -n "$AUTH_LABEL_RAW" ]]; then + RESOLVED_AUTH_LABEL="$(printf "%s" "$AUTH_LABEL_RAW" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+//; s/_+$//')" + if [[ "$RESOLVED_AUTH_LABEL" == "DEFAULT" ]]; then + RESOLVED_AUTH_LABEL="" + fi +fi` + +const renderEffectiveTokenResolution = (): string => + String.raw`EFFECTIVE_GITHUB_TOKEN="$GITHUB_TOKEN" +if [[ -z "$EFFECTIVE_GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$GH_TOKEN" +fi +if [[ -z "$EFFECTIVE_GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$GIT_AUTH_TOKEN" +fi + +if [[ -n "$RESOLVED_AUTH_LABEL" ]]; then + LABELED_GIT_TOKEN_KEY="GIT_AUTH_TOKEN__$RESOLVED_AUTH_LABEL" + LABELED_GITHUB_TOKEN_KEY="GITHUB_TOKEN__$RESOLVED_AUTH_LABEL" + LABELED_GH_TOKEN_KEY="GH_TOKEN__$RESOLVED_AUTH_LABEL" + + LABELED_GIT_TOKEN="${"${"}!LABELED_GIT_TOKEN_KEY-}" + LABELED_GITHUB_TOKEN="${"${"}!LABELED_GITHUB_TOKEN_KEY-}" + LABELED_GH_TOKEN="${"${"}!LABELED_GH_TOKEN_KEY-}" + + if [[ -n "$LABELED_GIT_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$LABELED_GIT_TOKEN" + elif [[ -n "$LABELED_GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$LABELED_GITHUB_TOKEN" + elif [[ -n "$LABELED_GH_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$LABELED_GH_TOKEN" + fi +fi` + +const renderAuthBridgeFinalize = (config: TemplateConfig): string => + String.raw`EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" + +if [[ -n "$EFFECTIVE_GH_TOKEN" ]]; then + printf "export GH_TOKEN=%q\n" "$EFFECTIVE_GH_TOKEN" > /etc/profile.d/gh-token.sh + printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN" >> /etc/profile.d/gh-token.sh + printf "export GIT_AUTH_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN" >> /etc/profile.d/gh-token.sh + chmod 0644 /etc/profile.d/gh-token.sh + docker_git_upsert_ssh_env "GH_TOKEN" "$EFFECTIVE_GH_TOKEN" + docker_git_upsert_ssh_env "GITHUB_TOKEN" "$EFFECTIVE_GITHUB_TOKEN" + docker_git_upsert_ssh_env "GIT_AUTH_TOKEN" "$EFFECTIVE_GITHUB_TOKEN" + + SAFE_GH_TOKEN="$(printf "%q" "$EFFECTIVE_GH_TOKEN")" + # Keep git+https auth in sync with gh auth so push/pull works without manual setup. + su - ${config.sshUser} -c "GH_TOKEN=$SAFE_GH_TOKEN gh auth setup-git --hostname github.com --force" || true + + GH_LOGIN="$(su - ${config.sshUser} -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .login" 2>/dev/null || true)" + GH_ID="$(su - ${config.sshUser} -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .id" 2>/dev/null || true)" + GH_LOGIN="$(printf "%s" "$GH_LOGIN" | tr -d '\r\n')" + GH_ID="$(printf "%s" "$GH_ID" | tr -d '\r\n')" + + if [[ -z "$GIT_USER_NAME" && -n "$GH_LOGIN" ]]; then + GIT_USER_NAME="$GH_LOGIN" + fi + if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then + GIT_USER_EMAIL="${"${"}GH_ID}+${"${"}GH_LOGIN}@users.noreply.github.com" + fi +fi` + +const renderEntrypointAuthEnvBridge = (config: TemplateConfig): string => + [ + renderAuthLabelResolution(), + renderEffectiveTokenResolution(), + renderAuthBridgeFinalize(config) + ].join("\n\n") + +const renderEntrypointGitCredentialHelper = (config: TemplateConfig): string => + String.raw`# 3) Configure git credential helper for HTTPS remotes +GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" +cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -lt 1 || "$1" != "get" ]]; then + exit 0 +fi + +token="${"${"}GITHUB_TOKEN:-}" +if [[ -z "$token" ]]; then + token="${"${"}GH_TOKEN:-}" +fi + +if [[ -z "$token" ]]; then + exit 0 +fi + +printf "%s\n" "username=x-access-token" +printf "%s\n" "password=$token" +EOF +chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" +su - ${config.sshUser} -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'"` + +const renderEntrypointGitIdentity = (config: TemplateConfig): string => + String.raw`# 4) Configure git identity for the dev user if provided +if [[ -n "$GIT_USER_NAME" ]]; then + SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" + su - ${config.sshUser} -c "git config --global user.name $SAFE_GIT_USER_NAME" +fi + +if [[ -n "$GIT_USER_EMAIL" ]]; then + SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" + su - ${config.sshUser} -c "git config --global user.email $SAFE_GIT_USER_EMAIL" +fi` + +export const renderEntrypointGitConfig = (config: TemplateConfig): string => + [ + renderEntrypointAuthEnvBridge(config), + renderEntrypointGitCredentialHelper(config), + renderEntrypointGitIdentity(config) + ].join("\n\n") + +const entrypointGitHooksTemplate = String + .raw`# 3) Install global git hooks to protect main/master + managed AGENTS context +HOOKS_DIR="/opt/docker-git/hooks" +PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" +POST_PUSH_ACTION="$HOOKS_DIR/post-push" +mkdir -p "$HOOKS_DIR" + +cat <<'EOF' > "$PRE_PUSH_HOOK" +#!/usr/bin/env bash +set -euo pipefail + +protected_branches=("refs/heads/main" "refs/heads/master") +allow_delete="${"${"}DOCKER_GIT_ALLOW_DELETE:-}" +zero_sha="0000000000000000000000000000000000000000" +issue_managed_start='' +issue_managed_end='' + +extract_issue_block() { + local ref="$1" + + if ! git cat-file -e "$ref" 2>/dev/null; then + return 0 + fi + + local awk_status=0 + if ! git cat-file -p "$ref" | awk -v start="$issue_managed_start" -v end="$issue_managed_end" ' + BEGIN { in_block = 0; found = 0 } + $0 == start { in_block = 1; found = 1 } + in_block == 1 { print } + $0 == end && in_block == 1 { in_block = 0; exit } + END { + if (found == 0) exit 3 + if (in_block == 1) exit 2 + } + '; then + awk_status=$? + if [[ "$awk_status" -eq 3 ]]; then + return 0 + fi + return "$awk_status" + fi +} + +commit_changes_issue_block() { + local commit="$1" + local parent="" + local commit_block="" + local parent_block="" + + if ! git diff-tree --no-commit-id --name-only -r "$commit" -- AGENTS.md | grep -qx "AGENTS.md"; then + return 1 + fi + + if ! commit_block="$(extract_issue_block "$commit:AGENTS.md")"; then + return 2 + fi + + parent="$(git rev-list --parents -n 1 "$commit" | awk '{print $2}')" + if [[ -n "$parent" ]]; then + if ! parent_block="$(extract_issue_block "$parent:AGENTS.md")"; then + return 2 + fi + fi + + if [[ "$commit_block" != "$parent_block" ]]; then + return 0 + fi + return 1 +} + +check_issue_managed_block_range() { + local local_sha="$1" + local remote_sha="$2" + local commits="" + local commit="" + local guard_status=0 + + if [[ "$local_sha" == "$zero_sha" ]]; then + return 0 + fi + + if [[ "$remote_sha" == "$zero_sha" ]]; then + commits="$(git rev-list "$local_sha" --not --remotes 2>/dev/null || true)" + if [[ -z "$commits" ]]; then + commits="$local_sha" + fi + else + commits="$(git rev-list "$remote_sha..$local_sha" 2>/dev/null || true)" + fi + + for commit in $commits; do + commit_changes_issue_block "$commit" + guard_status=$? + if [[ "$guard_status" -eq 0 ]]; then + echo "docker-git: push contains commit updating managed issue block in AGENTS.md: $commit" + echo "docker-git: this block is runtime context and must stay outside repository history." + return 1 + fi + if [[ "$guard_status" -eq 2 ]]; then + echo "docker-git: failed to parse managed issue block in AGENTS.md for commit $commit" + echo "docker-git: push blocked to prevent committing runtime workspace metadata." + return 1 + fi + done + + return 0 +} + +while read -r local_ref local_sha remote_ref remote_sha; do + if [[ -z "$remote_ref" ]]; then + continue + fi + for protected in "${"${"}protected_branches[@]}"; do + if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then + echo "docker-git: push to protected branch '${"${"}protected##*/}' is disabled." + echo "docker-git: create a new branch: git checkout -b " + exit 1 + fi + done + if ! check_issue_managed_block_range "$local_sha" "$remote_sha"; then + exit 1 + fi + if [[ "$local_sha" == "$zero_sha" && "$remote_ref" == refs/heads/* ]]; then + if [[ "$allow_delete" != "1" ]]; then + echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." + exit 1 + fi + fi +done +EOF +chmod 0755 "$PRE_PUSH_HOOK" + +cat <<'EOF' > "$POST_PUSH_ACTION" +#!/usr/bin/env bash +set -euo pipefail + +# 5) Run session backup after successful push +REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" +if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then + REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +fi +cd "$REPO_ROOT" + +# CHANGE: keep post-push backup logic in a reusable action script +# WHY: git has no client-side post-push hook, so the global git wrapper +# invokes this after a successful git push +# REF: issue-192 +if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then + if command -v gh >/dev/null 2>&1; then + BACKUP_SCRIPT="" + if [ -f "$REPO_ROOT/scripts/session-backup-gist.js" ]; then + BACKUP_SCRIPT="$REPO_ROOT/scripts/session-backup-gist.js" + elif [ -f /opt/docker-git/scripts/session-backup-gist.js ]; then + BACKUP_SCRIPT="/opt/docker-git/scripts/session-backup-gist.js" + fi + if [ -n "$BACKUP_SCRIPT" ]; then + DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 node "$BACKUP_SCRIPT" || echo "[session-backup] Warning: session backup failed (non-fatal)" + else + echo "[session-backup] Warning: script not found (expected repo or global path)" + fi + else + echo "[session-backup] Warning: gh CLI not found (skipping session backup)" + fi +fi +EOF +chmod 0755 "$POST_PUSH_ACTION" + +${renderEntrypointGitPostPushWrapperInstall()} + +git config --system core.hooksPath "$HOOKS_DIR" || true +git config --global core.hooksPath "$HOOKS_DIR" || true` + +export const renderEntrypointGitHooks = (): string => entrypointGitHooksTemplate +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts new file mode 100644 index 00000000..92fecceb --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts @@ -0,0 +1,248 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +const entrypointDockerGitBootstrapTemplate = String + .raw`# Bootstrap ~/.docker-git for nested docker-git usage inside this container. +DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git" +DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" +DOCKER_GIT_CLAUDE_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/claude" +DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" +DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" +DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" +DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys" +BOOTSTRAP_ROOT="/opt/docker-git/bootstrap" +BOOTSTRAP_SOURCE_ROOT="$BOOTSTRAP_ROOT/source" +BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_SOURCE_ROOT/authorized-keys/__AUTHORIZED_KEYS_BASENAME__" +BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/codex" +BOOTSTRAP_CODEX_SHARED_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/shared-auth/codex" +BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/claude" +BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_SOURCE_ROOT/env-global/__ENV_GLOBAL_BASENAME__" +BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_SOURCE_ROOT/env-project/__ENV_PROJECT_BASENAME__" + +mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" + +sync_file_if_present() { + local source="$1" + local target="$2" + if [[ ! -f "$source" ]]; then + return 1 + fi + mkdir -p "$(dirname "$target")" + cp "$source" "$target" + return 0 +} + +sync_file_or_remove() { + local source="$1" + local target="$2" + if [[ -f "$source" ]]; then + sync_file_if_present "$source" "$target" + return 0 + fi + rm -f "$target" || true + return 1 +} + +sync_dir_entries() { + local source="$1" + local target="$2" + if [[ ! -d "$source" ]]; then + return 0 + fi + mkdir -p "$target" + ( + cd "$source" + find . -mindepth 1 -print + ) | while IFS= read -r entry; do + local source_entry="$source/$entry" + local target_entry="$target/$entry" + if [[ -d "$source_entry" ]]; then + mkdir -p "$target_entry" + elif [[ -f "$source_entry" ]]; then + mkdir -p "$(dirname "$target_entry")" + cp "$source_entry" "$target_entry" + fi + done +} + +sync_labeled_auth_files() { + local source_root="$1" + local target_root="$2" + + sync_file_or_remove "$source_root/auth.json" "$target_root/auth.json" || true + + if [[ -d "$source_root" ]]; then + ( + cd "$source_root" + find . -mindepth 1 -maxdepth 1 -type d -print + ) | while IFS= read -r entry; do + sync_file_or_remove "$source_root/$entry/auth.json" "$target_root/$entry/auth.json" || true + done + fi + + if [[ -d "$target_root" ]]; then + ( + cd "$target_root" + find . -mindepth 1 -maxdepth 1 -type d -print + ) | while IFS= read -r entry; do + if [[ ! -d "$source_root/$entry" ]]; then + rm -f "$target_root/$entry/auth.json" || true + fi + done + fi +} + +if [[ ! -f "$DOCKER_GIT_AUTH_KEYS" && -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then + cp "/home/__SSH_USER__/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" +fi +sync_file_if_present "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true +if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then + chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true +fi + +sync_file_if_present "$BOOTSTRAP_ENV_GLOBAL" "$DOCKER_GIT_ENV_GLOBAL" || true +if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then + cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" +# docker-git env +# KEY=value +EOF +fi +sync_file_if_present "$BOOTSTRAP_ENV_PROJECT" "$DOCKER_GIT_ENV_PROJECT" || true +if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then + cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" +# docker-git project env defaults +CODEX_SHARE_AUTH=1 +CODEX_AUTO_UPDATE=1 +DOCKER_GIT_ZSH_AUTOSUGGEST=1 +DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic +DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion +MCP_PLAYWRIGHT_ISOLATED=1 +EOF +fi + +upsert_env_var() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp)" + awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp" + printf "%s=%s\n" "$key" "$value" >> "$tmp" + mv "$tmp" "$file" +} + +docker_git_export_env_if_unset() { + local key="$1" + local value="$2" + + if [[ -n "${"$"}{!key+x}" ]]; then + docker_git_upsert_ssh_env "$key" "${"$"}{!key}" + return 0 + fi + + export "$key=$value" + docker_git_upsert_ssh_env "$key" "$value" + return 0 +} + +docker_git_load_env_file() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 0 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + ""|\#*) + continue + ;; + esac + if [[ "$line" != *=* ]]; then + continue + fi + + local key="${"$"}{line%%=*}" + local value="${"$"}{line#*=}" + if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + + docker_git_export_env_if_unset "$key" "$value" + done < "$file" +} + +copy_if_distinct_file() { + local source="$1" + local target="$2" + if [[ ! -f "$source" ]]; then + return 1 + fi + local source_real="" + local target_real="" + source_real="$(readlink -f "$source" 2>/dev/null || true)" + target_real="$(readlink -f "$target" 2>/dev/null || true)" + if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then + return 0 + fi + cp "$source" "$target" + return 0 +} + +sync_dir_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" +sync_labeled_auth_files "$BOOTSTRAP_CODEX_SHARED_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" +sync_dir_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" + +if [[ -n "$GH_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" +fi +if [[ -n "$GITHUB_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN" +elif [[ -n "$GH_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" +fi + +docker_git_load_env_file "$DOCKER_GIT_ENV_GLOBAL" +docker_git_load_env_file "$DOCKER_GIT_ENV_PROJECT" +if [[ -z "$GIT_AUTH_TOKEN" ]]; then + GIT_AUTH_TOKEN="$GITHUB_TOKEN" +fi +if [[ -z "$GIT_AUTH_TOKEN" ]]; then + GIT_AUTH_TOKEN="$GH_TOKEN" +fi +if [[ -z "$GH_TOKEN" ]]; then + GH_TOKEN="$GIT_AUTH_TOKEN" +fi +if [[ -z "$GITHUB_TOKEN" ]]; then + GITHUB_TOKEN="$GH_TOKEN" +fi + +SOURCE_CODEX_CONFIG="__CODEX_HOME__/config.toml" +copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true + +SOURCE_SHARED_AUTH="__CODEX_HOME__-shared/auth.json" +SOURCE_LOCAL_AUTH="__CODEX_HOME__/auth.json" +if [[ -f "$SOURCE_SHARED_AUTH" ]]; then + copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then + copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +fi +if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then + chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true +fi + +chown -R 1000:1000 "$DOCKER_GIT_HOME" || true` + +export const renderEntrypointDockerGitBootstrap = (config: TemplateConfig): string => + entrypointDockerGitBootstrapTemplate + .replaceAll("__SSH_USER__", config.sshUser) + .replaceAll( + "__AUTHORIZED_KEYS_BASENAME__", + config.authorizedKeysPath.replaceAll("\\", "/").split("/").at(-1) ?? "authorized_keys" + ) + .replaceAll("__ENV_GLOBAL_BASENAME__", config.envGlobalPath.replaceAll("\\", "/").split("/").at(-1) ?? "global.env") + .replaceAll( + "__ENV_PROJECT_BASENAME__", + config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? "project.env" + ) + .replaceAll("__CODEX_HOME__", config.codexHome) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/opencode.ts b/packages/app/src/lib/core/templates-entrypoint/opencode.ts new file mode 100644 index 00000000..f7ca9e44 --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/opencode.ts @@ -0,0 +1,215 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" + +const entrypointOpenCodeTemplate = `OPENCODE_DATA_DIR="/home/__SSH_USER__/.local/share/opencode" +OPENCODE_AUTH_FILE="$OPENCODE_DATA_DIR/auth.json" +OPENCODE_SHARED_HOME="__CODEX_HOME__-shared/opencode" +OPENCODE_SHARED_AUTH_FILE="$OPENCODE_SHARED_HOME/auth.json" + +# OpenCode: share auth.json across projects (so /connect is one-time) +OPENCODE_SHARE_AUTH="\${OPENCODE_SHARE_AUTH:-1}" +if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then + # Store in the shared auth volume to persist across projects/containers. + mkdir -p "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME" + chown -R 1000:1000 "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME" || true + + # Guard against a bad bind mount creating a directory at auth.json. + if [[ -d "$OPENCODE_AUTH_FILE" ]]; then + mv "$OPENCODE_AUTH_FILE" "$OPENCODE_AUTH_FILE.bak-$(date +%s)" || true + fi + + # Migrate existing per-project auth into the shared location once. + if [[ -f "$OPENCODE_AUTH_FILE" && ! -L "$OPENCODE_AUTH_FILE" ]]; then + if [[ -f "$OPENCODE_SHARED_AUTH_FILE" ]]; then + LOCAL_AUTH="$OPENCODE_AUTH_FILE" SHARED_AUTH="$OPENCODE_SHARED_AUTH_FILE" node - <<'NODE' +const fs = require("fs") +const localPath = process.env.LOCAL_AUTH +const sharedPath = process.env.SHARED_AUTH +const readJson = (p) => { + try { + return JSON.parse(fs.readFileSync(p, "utf8")) + } catch { + return {} + } +} +const local = readJson(localPath) +const shared = readJson(sharedPath) +const merged = { ...local, ...shared } // shared wins on conflicts +fs.writeFileSync(sharedPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) +NODE + else + cp "$OPENCODE_AUTH_FILE" "$OPENCODE_SHARED_AUTH_FILE" || true + chmod 600 "$OPENCODE_SHARED_AUTH_FILE" || true + fi + chown 1000:1000 "$OPENCODE_SHARED_AUTH_FILE" || true + rm -f "$OPENCODE_AUTH_FILE" || true + fi + + ln -sf "$OPENCODE_SHARED_AUTH_FILE" "$OPENCODE_AUTH_FILE" +fi + +# OpenCode: auto-seed auth from Codex (so /connect is automatic) +OPENCODE_AUTO_CONNECT="\${OPENCODE_AUTO_CONNECT:-1}" +if [[ "$OPENCODE_AUTO_CONNECT" == "1" ]]; then + CODEX_AUTH_FILE="__CODEX_HOME__/auth.json" + OPENCODE_SEED_AUTH="$OPENCODE_AUTH_FILE" + if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then + OPENCODE_SEED_AUTH="$OPENCODE_SHARED_AUTH_FILE" + fi + CODEX_AUTH="$CODEX_AUTH_FILE" OPENCODE_AUTH="$OPENCODE_SEED_AUTH" node - <<'NODE' +const fs = require("fs") +const path = require("path") + +const codexPath = process.env.CODEX_AUTH +const opencodePath = process.env.OPENCODE_AUTH + +if (!codexPath || !opencodePath) { + process.exit(0) +} + +const readJson = (p) => { + try { + return JSON.parse(fs.readFileSync(p, "utf8")) + } catch { + return undefined + } +} + +const writeJsonAtomic = (p, value) => { + const dir = path.dirname(p) + fs.mkdirSync(dir, { recursive: true }) + const tmp = path.join(dir, ".tmp-" + path.basename(p) + "-" + process.pid + "-" + Date.now()) + fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 }) + fs.renameSync(tmp, p) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +const decodeJwtClaims = (jwt) => { + if (typeof jwt !== "string") return undefined + const parts = jwt.split(".") + if (parts.length !== 3) return undefined + try { + const payload = Buffer.from(parts[1], "base64url").toString("utf8") + return JSON.parse(payload) + } catch { + return undefined + } +} + +const extractAccountIdFromClaims = (claims) => { + if (!isRecord(claims)) return undefined + if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id + const openaiAuth = claims["https://api.openai.com/auth"] + if (isRecord(openaiAuth) && typeof openaiAuth.chatgpt_account_id === "string") { + return openaiAuth.chatgpt_account_id + } + const orgs = claims.organizations + if (Array.isArray(orgs) && orgs.length > 0) { + const first = orgs[0] + if (isRecord(first) && typeof first.id === "string") return first.id + } + return undefined +} + +const extractJwtExpiryMs = (claims) => { + if (!isRecord(claims)) return undefined + if (typeof claims.exp !== "number") return undefined + return claims.exp * 1000 +} + +const codex = readJson(codexPath) +if (!isRecord(codex)) process.exit(0) + +let opencode = readJson(opencodePath) +if (!isRecord(opencode)) opencode = {} + +if (opencode.openai) { + process.exit(0) +} + +const apiKey = codex.OPENAI_API_KEY +if (typeof apiKey === "string" && apiKey.trim().length > 0) { + opencode.openai = { type: "api", key: apiKey.trim() } + writeJsonAtomic(opencodePath, opencode) + process.exit(0) +} + +const tokens = codex.tokens +if (!isRecord(tokens)) process.exit(0) + +const access = tokens.access_token +const refresh = tokens.refresh_token +if (typeof access !== "string" || access.length === 0) process.exit(0) +if (typeof refresh !== "string" || refresh.length === 0) process.exit(0) + +const accessClaims = decodeJwtClaims(access) +const expires = extractJwtExpiryMs(accessClaims) +if (typeof expires !== "number") process.exit(0) + +let accountId = undefined +if (typeof tokens.account_id === "string" && tokens.account_id.length > 0) { + accountId = tokens.account_id +} else { + const idClaims = decodeJwtClaims(tokens.id_token) + accountId = + extractAccountIdFromClaims(idClaims) || + extractAccountIdFromClaims(accessClaims) +} + +const entry = { + type: "oauth", + refresh, + access, + expires, + ...(typeof accountId === "string" && accountId.length > 0 ? { accountId } : {}) +} + +opencode.openai = entry +writeJsonAtomic(opencodePath, opencode) +NODE + chown 1000:1000 "$OPENCODE_SEED_AUTH" 2>/dev/null || true +fi + +# OpenCode: ensure global config exists (plugins + permissions) +OPENCODE_CONFIG_DIR="/home/__SSH_USER__/.config/opencode" +OPENCODE_CONFIG_JSON="$OPENCODE_CONFIG_DIR/opencode.json" +OPENCODE_CONFIG_JSONC="$OPENCODE_CONFIG_DIR/opencode.jsonc" + +mkdir -p "$OPENCODE_CONFIG_DIR" +chown -R 1000:1000 "$OPENCODE_CONFIG_DIR" || true + +if [[ ! -f "$OPENCODE_CONFIG_JSON" && ! -f "$OPENCODE_CONFIG_JSONC" ]]; then + cat <<'EOF' > "$OPENCODE_CONFIG_JSON" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["oh-my-opencode"], + "permission": { + "doom_loop": "allow", + "external_directory": "allow", + "read": { + "*": "allow", + "*.env": "allow", + "*.env.*": "allow", + "*.env.example": "allow" + } + } +} +EOF + chown 1000:1000 "$OPENCODE_CONFIG_JSON" || true +fi` + +// CHANGE: bootstrap OpenCode config (permissions + plugins) and share OpenCode auth.json across projects +// WHY: make OpenCode usable out-of-the-box inside disposable docker-git containers +// QUOTE(ТЗ): "Preinstall OpenCode and oh-my-opencode with full authorization of existing tools" +// REF: issue-34 +// SOURCE: n/a +// FORMAT THEOREM: forall s: start(s) -> config_exists(s) +// PURITY: CORE +// INVARIANT: never overwrites an existing opencode.json/opencode.jsonc +// COMPLEXITY: O(1) +export const renderEntrypointOpenCodeConfig = (config: TemplateConfig): string => + entrypointOpenCodeTemplate + .replaceAll("__SSH_USER__", config.sshUser) + .replaceAll("__CODEX_HOME__", config.codexHome) +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/project-rules.ts b/packages/app/src/lib/core/templates-entrypoint/project-rules.ts new file mode 100644 index 00000000..7e85cc1e --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/project-rules.ts @@ -0,0 +1,62 @@ +/* jscpd:ignore-start */ +// CHANGE: separate project rule preparation by active agent mode +// WHY: Codex, Claude Code, and Gemini CLI each have different native project-level config models +// REF: issue-207 +// PURITY: CORE +// INVARIANT: Codex gets a bridge for skills that live outside CODEX_HOME; Claude/Gemini stay on native project-local discovery +// COMPLEXITY: O(1) +const entrypointProjectAgentRulesTemplate = String + .raw`# Prepare project-local rules using each agent's native conventions. +docker_git_detect_claude_project_rules() { + local project_dir="${"$"}{TARGET_DIR:-}" + + if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then + return 0 + fi + + if [[ -f "$project_dir/CLAUDE.md" \ + || -f "$project_dir/.claude/CLAUDE.md" \ + || -f "$project_dir/.claude/settings.json" \ + || -d "$project_dir/.claude/agents" \ + || -f "$project_dir/.mcp.json" ]]; then + echo "[claude] project-local Claude rules available in $project_dir" + fi +} + +docker_git_detect_gemini_project_rules() { + local project_dir="${"$"}{TARGET_DIR:-}" + + if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then + return 0 + fi + + if [[ -f "$project_dir/GEMINI.md" \ + || -f "$project_dir/.gemini/settings.json" \ + || -d "$project_dir/.gemini/commands" \ + || -d "$project_dir/.gemini/skills" \ + || -d "$project_dir/.agents/skills" ]]; then + echo "[gemini] project-local Gemini rules available in $project_dir" + fi +} + +docker_git_prepare_active_agent_project_rules() { + case "$AGENT_MODE" in + "codex") + docker_git_sync_project_codex_skills + ;; + "claude") + docker_git_detect_claude_project_rules + ;; + "gemini") + docker_git_detect_gemini_project_rules + ;; + *) + docker_git_sync_project_codex_skills + docker_git_detect_claude_project_rules + docker_git_detect_gemini_project_rules + ;; + esac +}` + +export const renderEntrypointProjectAgentRules = (): string => entrypointProjectAgentRulesTemplate +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts new file mode 100644 index 00000000..5c93735e --- /dev/null +++ b/packages/app/src/lib/core/templates-entrypoint/tasks.ts @@ -0,0 +1,231 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "../domain.js" +import { renderAgentLaunch } from "./agent.js" + +const renderEntrypointAutoUpdate = (): string => + `# 1) Keep Codex CLI up to date if requested (bun only) +if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then + if command -v bun >/dev/null 2>&1; then + echo "[codex] updating via bun..." + BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true + else + echo "[codex] bun not found, skipping auto-update" + fi +fi` + +const renderClonePreamble = (): string => + `# 2) Auto-clone repo if not already present +mkdir -p /run/docker-git +CLONE_DONE_PATH="/run/docker-git/clone.done" +CLONE_FAIL_PATH="/run/docker-git/clone.failed" +rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" + +CLONE_OK=1` + +const renderCloneRemotes = (config: TemplateConfig): string => + `if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" ]]; then + if [[ -n "$FORK_REPO_URL" && "$FORK_REPO_URL" != "$REPO_URL" ]]; then + su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$FORK_REPO_URL'" || true + su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote add upstream '$REPO_URL' 2>/dev/null || git remote set-url upstream '$REPO_URL'" || true + else + su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$REPO_URL'" || true + su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote remove upstream >/dev/null 2>&1 || true" || true + fi +fi` + +const renderCloneGuard = (config: TemplateConfig): string => + `if [[ -z "$REPO_URL" ]]; then + echo "[clone] skip (no repo url)" +elif [[ -d "$TARGET_DIR/.git" ]]; then + echo "[clone] skip (already cloned)" +else + mkdir -p "$TARGET_DIR" + if [[ "$TARGET_DIR" != "/" ]]; then + chown -R 1000:1000 "$TARGET_DIR" + fi + chown -R 1000:1000 /home/${config.sshUser}` + +const renderCloneAuthSelection = (): string => + ` RESOLVED_GIT_AUTH_USER="$GIT_AUTH_USER" + RESOLVED_GIT_AUTH_TOKEN="$GIT_AUTH_TOKEN" + RESOLVED_GIT_AUTH_LABEL="" + GIT_TOKEN_LABEL_RAW="\${GIT_AUTH_LABEL:-\${GITHUB_AUTH_LABEL:-}}" + + if [[ -z "$GIT_TOKEN_LABEL_RAW" && "$REPO_URL" == https://github.com/* ]]; then + GIT_TOKEN_LABEL_RAW="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##' | cut -d/ -f1)" + fi + + if [[ -n "$GIT_TOKEN_LABEL_RAW" ]]; then + RESOLVED_GIT_AUTH_LABEL="$(printf "%s" "$GIT_TOKEN_LABEL_RAW" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+//; s/_+$//')" + if [[ "$RESOLVED_GIT_AUTH_LABEL" == "DEFAULT" ]]; then + RESOLVED_GIT_AUTH_LABEL="" + fi + fi + + if [[ -n "$RESOLVED_GIT_AUTH_LABEL" ]]; then + LABELED_GIT_TOKEN_KEY="GIT_AUTH_TOKEN__$RESOLVED_GIT_AUTH_LABEL" + LABELED_GITHUB_TOKEN_KEY="GITHUB_TOKEN__$RESOLVED_GIT_AUTH_LABEL" + LABELED_GIT_USER_KEY="GIT_AUTH_USER__$RESOLVED_GIT_AUTH_LABEL" + + LABELED_GIT_TOKEN="\${!LABELED_GIT_TOKEN_KEY-}" + LABELED_GITHUB_TOKEN="\${!LABELED_GITHUB_TOKEN_KEY-}" + LABELED_GIT_USER="\${!LABELED_GIT_USER_KEY-}" + + if [[ -n "$LABELED_GIT_TOKEN" ]]; then + RESOLVED_GIT_AUTH_TOKEN="$LABELED_GIT_TOKEN" + elif [[ -n "$LABELED_GITHUB_TOKEN" ]]; then + RESOLVED_GIT_AUTH_TOKEN="$LABELED_GITHUB_TOKEN" + fi + + if [[ -n "$LABELED_GIT_USER" ]]; then + RESOLVED_GIT_AUTH_USER="$LABELED_GIT_USER" + fi + fi` + +const renderCloneAuthRepoUrl = (): string => + ` AUTH_REPO_URL="$REPO_URL" + if [[ -n "$RESOLVED_GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then + AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" + fi` + +const renderCloneCacheInit = (config: TemplateConfig): string => + ` CLONE_CACHE_ARGS="" + CACHE_REPO_DIR="" + CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/git-mirrors" + if command -v sha256sum >/dev/null 2>&1; then + REPO_CACHE_KEY="$(printf "%s" "$REPO_URL" | sha256sum | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + REPO_CACHE_KEY="$(printf "%s" "$REPO_URL" | shasum -a 256 | awk '{print $1}')" + else + REPO_CACHE_KEY="$(printf "%s" "$REPO_URL" | tr '/:@' '_' | tr -cd '[:alnum:]_.-')" + fi + + if [[ -n "$REPO_CACHE_KEY" ]]; then + CACHE_REPO_DIR="$CACHE_ROOT/$REPO_CACHE_KEY.git" + mkdir -p "$CACHE_ROOT" + chown 1000:1000 "$CACHE_ROOT" || true + if [[ -d "$CACHE_REPO_DIR" ]]; then + if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/*:refs/*'"; then + echo "[clone-cache] mirror refresh failed for $REPO_URL" + fi + CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" + echo "[clone-cache] using mirror: $CACHE_REPO_DIR" + else + echo "[clone-cache] invalid mirror removed: $CACHE_REPO_DIR" + rm -rf "$CACHE_REPO_DIR" + fi + fi + fi` + +const renderCloneBodyStart = (config: TemplateConfig): string => + [ + renderCloneGuard(config), + renderCloneAuthSelection(), + renderCloneAuthRepoUrl(), + renderCloneCacheInit(config) + ].join("\n\n") + +const renderCloneBodyRef = (config: TemplateConfig): string => + ` if [[ -n "$REPO_REF" ]]; then + if [[ "$REPO_REF" == refs/pull/* ]]; then + REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + else + if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then + echo "[clone] git fetch failed for $REPO_REF" + CLONE_OK=0 + fi + fi + else + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] branch '$REPO_REF' missing; retrying without --branch" + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + elif [[ "$REPO_REF" == issue-* ]]; then + if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then + echo "[clone] failed to create local branch '$REPO_REF'" + CLONE_OK=0 + fi + fi + fi + fi + else + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi` + +const renderCloneCacheFinalize = (config: TemplateConfig): string => + `CACHE_REPO_DIR="\${CACHE_REPO_DIR:-}" +if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" && -n "$CACHE_REPO_DIR" && ! -d "$CACHE_REPO_DIR" ]]; then + CACHE_TMP_DIR="$CACHE_REPO_DIR.tmp-$$" + if su - ${config.sshUser} -c "rm -rf '$CACHE_TMP_DIR' && GIT_TERMINAL_PROMPT=0 git clone --mirror --progress '$TARGET_DIR/.git' '$CACHE_TMP_DIR'"; then + if mv "$CACHE_TMP_DIR" "$CACHE_REPO_DIR" 2>/dev/null; then + echo "[clone-cache] mirror created: $CACHE_REPO_DIR" + else + rm -rf "$CACHE_TMP_DIR" + fi + else + echo "[clone-cache] mirror bootstrap failed for $REPO_URL" + rm -rf "$CACHE_TMP_DIR" + fi +fi` + +const renderCloneBody = (config: TemplateConfig): string => + [ + renderCloneBodyStart(config), + renderCloneBodyRef(config), + "fi", + "", + renderCloneRemotes(config), + "", + renderCloneCacheFinalize(config) + ].join("\n") + +// CHANGE: provision docker-git scripts into workspace after successful clone +// WHY: git hooks reference scripts/ relative to repo root (e.g. "node scripts/session-backup-gist.js"); +// symlinking embedded /opt/docker-git/scripts makes them available in any cloned repo +// REF: issue-176 +// PURITY: SHELL +// INVARIANT: symlink created only when /opt/docker-git/scripts exists ∧ TARGET_DIR/scripts absent +// COMPLEXITY: O(1) +const renderCloneFinalize = (): string => + `if [[ "$CLONE_OK" -eq 1 ]]; then + echo "[clone] done" + touch "$CLONE_DONE_PATH" + + # Provision docker-git scripts into workspace (symlink if not already present) + if [[ -d /opt/docker-git/scripts && -n "$TARGET_DIR" && "$TARGET_DIR" != "/" ]]; then + if [[ ! -e "$TARGET_DIR/scripts" ]]; then + ln -s /opt/docker-git/scripts "$TARGET_DIR/scripts" || true + chown -h 1000:1000 "$TARGET_DIR/scripts" 2>/dev/null || true + echo "[scripts] provisioned docker-git scripts into workspace" + fi + fi +else + echo "[clone] failed" + touch "$CLONE_FAIL_PATH" +fi` + +const renderEntrypointClone = (config: TemplateConfig): string => + [renderClonePreamble(), renderCloneBody(config), renderCloneFinalize()].join("\n\n") + +export const renderEntrypointBackgroundTasks = (config: TemplateConfig): string => + `# 4) Start background tasks so SSH can come up immediately +( +${renderEntrypointAutoUpdate()} + +${renderEntrypointClone(config)} + +if [[ "$CLONE_OK" -eq 1 ]]; then + docker_git_prepare_active_agent_project_rules +fi + +${renderAgentLaunch(config)} +) &` +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-prompt.ts b/packages/app/src/lib/core/templates-prompt.ts new file mode 100644 index 00000000..e3e085f7 --- /dev/null +++ b/packages/app/src/lib/core/templates-prompt.ts @@ -0,0 +1,399 @@ +/* jscpd:ignore-start */ +// CHANGE: standardize docker-git prompt script for interactive shells +// WHY: keep prompt consistent between Dockerfile and entrypoint +// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" +// REF: user-request-2026-02-05-restore-prompt +// SOURCE: n/a +// FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: script is deterministic +// COMPLEXITY: O(1) +const dockerGitPromptScript = `docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_terminal_sanitize() { + # Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools. + if [ -t 0 ]; then + stty sane 2>/dev/null || true + fi + if [ -t 1 ]; then + printf "\\033[0m\\033[?25h\\033[?1l\\033>\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1005l\\033[?1006l\\033[?1015l\\033[?1007l\\033[?1004l\\033[?2004l\\033[>4;0m\\033[>4m\\033[ dockerGitPromptScript + +// CHANGE: enable bash completion for interactive shells +// WHY: allow tab completion for CLI tools in SSH terminals +// QUOTE(ТЗ): "А почему у меня не работает автодополенние в терминале?" +// REF: user-request-2026-02-05-bash-completion +// SOURCE: n/a +// FORMAT THEOREM: forall s in InteractiveShells: completion(s) -> enabled(s) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: only runs when bash completion files exist +// COMPLEXITY: O(1) +export const renderBashCompletionScript = (): string => + `if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi` + +// CHANGE: enable bash history persistence and prefix search +// WHY: keep command history between sessions and allow prefix-based navigation +// QUOTE(ТЗ): "Он не помнит прошлый вывод команд" +// REF: user-request-2026-02-05-bash-history +// SOURCE: n/a +// FORMAT THEOREM: forall s in InteractiveShells: history(s) -> persisted(s) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: PROMPT_COMMAND preserves existing prompt logic +// COMPLEXITY: O(1) +export const renderBashHistoryScript = (): string => + `if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + HISTFILE="\${HISTFILE:-$HOME/.bash_history}" + HISTSIZE="\${HISTSIZE:-10000}" + HISTFILESIZE="\${HISTFILESIZE:-20000}" + HISTCONTROL="\${HISTCONTROL:-ignoredups:erasedups}" + export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL + shopt -s histappend + if [ -n "\${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="history -a; \${PROMPT_COMMAND}" + else + PROMPT_COMMAND="history -a" + fi + ;; + esac +fi` + +// CHANGE: add readline bindings for prefix history search +// WHY: allow up/down arrows to search history by current prefix +// QUOTE(ТЗ): "если я писал cd ... то он должен запомнить и когда я напишу cd он мне предложит" +// REF: user-request-2026-02-05-inputrc +// SOURCE: n/a +// FORMAT THEOREM: forall p: prefix(p) -> history_search(p) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: does not override user inputrc when already present +// COMPLEXITY: O(1) +export const renderInputRc = (): string => + String.raw`set show-all-if-ambiguous on +set completion-ignore-case on +"\e[A": history-search-backward +"\e[B": history-search-forward` + +// CHANGE: configure zsh with autosuggestions, history search, and non-noisy completion UX +// WHY: avoid dumping completion candidates into the terminal scrollback on ambiguous prefixes +// QUOTE(ТЗ): "пусть будет zzh если он сделате то что я хочу" | "Почему при наборе текста он пишет в моём терминале какую-то билиберду?" +// REF: user-request-2026-02-05-zsh-autosuggest | user-request-2026-02-10-zsh-completion-noise +// SOURCE: n/a +// FORMAT THEOREM: forall s in ZshInteractive: autosuggest(s) -> enabled(s) ∧ completion(s) -> non_noisy(s) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: zsh config does not depend on user dotfiles +// COMPLEXITY: O(1) +const dockerGitZshConfig = `setopt PROMPT_SUBST + +# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), +# fall back to xterm-256color so ZLE doesn't garble the display. +if command -v infocmp >/dev/null 2>&1; then + if ! infocmp "$TERM" >/dev/null 2>&1; then + export TERM=xterm-256color + fi +fi + +autoload -Uz compinit +compinit + +# Completion UX: cycle matches instead of listing them into scrollback. +setopt AUTO_MENU +setopt MENU_COMPLETE +unsetopt AUTO_LIST +unsetopt LIST_BEEP + +# Command completion ordering: prefer real commands/builtins over internal helper functions. +zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions + +autoload -Uz add-zsh-hook +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_terminal_sanitize() { + # Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools. + if [[ -t 0 ]]; then + stty sane 2>/dev/null || true + fi + if [[ -t 1 ]]; then + printf "\\033[0m\\033[?25h\\033[?1l\\033>\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1005l\\033[?1006l\\033[?1015l\\033[?1007l\\033[?1004l\\033[?2004l\\033[>4;0m\\033[>4m\\033[/dev/null || true +fi +if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then + fc -R "$HOME/.bash_history" 2>/dev/null || true +fi + +bindkey '^[[A' history-search-backward +bindkey '^[[B' history-search-forward + +if [[ "\${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then + # Suggest from history first, then fall back to completion (commands + paths). + # This gives "ghost text" suggestions without needing to press . + ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="\${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" + if [[ -n "\${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then + ZSH_AUTOSUGGEST_STRATEGY=(\${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) + else + ZSH_AUTOSUGGEST_STRATEGY=(history completion) + fi + source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh +fi` + +export const renderZshConfig = (): string => dockerGitZshConfig + +// CHANGE: add git branch info to interactive shell prompt +// WHY: restore docker-git prompt with time + path + branch +// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" +// REF: user-request-2026-02-05-restore-prompt +// SOURCE: n/a +// FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: only interactive shells source /etc/profile.d/zz-prompt.sh +// COMPLEXITY: O(1) +export const renderDockerfilePrompt = (): string => + String.raw`# Shell prompt: show git branch for interactive sessions +RUN cat <<'EOF' > /etc/profile.d/zz-prompt.sh +${renderPromptScript()} +EOF +RUN chmod 0644 /etc/profile.d/zz-prompt.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" \ + >> /etc/bash.bashrc +RUN cat <<'EOF' > /etc/profile.d/zz-bash-completion.sh +${renderBashCompletionScript()} +EOF +RUN chmod 0644 /etc/profile.d/zz-bash-completion.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" \ + >> /etc/bash.bashrc +RUN cat <<'EOF' > /etc/profile.d/zz-bash-history.sh +${renderBashHistoryScript()} +EOF +RUN chmod 0644 /etc/profile.d/zz-bash-history.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" \ + >> /etc/bash.bashrc +RUN mkdir -p /etc/zsh +RUN cat <<'EOF' > /etc/zsh/zshrc +${renderZshConfig()} +EOF` + +// CHANGE: ensure the docker-git prompt is always available at runtime +// WHY: --force rebuilds can reuse cached layers that left an empty prompt file +// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" +// REF: user-request-2026-02-05-restore-prompt +// SOURCE: n/a +// FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: /etc/profile.d/zz-prompt.sh is non-empty after entrypoint +// COMPLEXITY: O(1) +export const renderEntrypointPrompt = (): string => + String.raw`# Ensure docker-git prompt is configured for interactive shells +PROMPT_PATH="/etc/profile.d/zz-prompt.sh" +if [[ ! -s "$PROMPT_PATH" ]]; then + cat <<'EOF' > "$PROMPT_PATH" +${renderPromptScript()} +EOF + chmod 0644 "$PROMPT_PATH" +fi +if ! grep -q "zz-prompt.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" >> /etc/bash.bashrc +fi` + +export const renderEntrypointBashCompletion = (): string => + String.raw`# Ensure bash completion is configured for interactive shells +COMPLETION_PATH="/etc/profile.d/zz-bash-completion.sh" +if [[ ! -s "$COMPLETION_PATH" ]]; then + cat <<'EOF' > "$COMPLETION_PATH" +${renderBashCompletionScript()} +EOF + chmod 0644 "$COMPLETION_PATH" +fi +if ! grep -q "zz-bash-completion.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" >> /etc/bash.bashrc +fi` + +export const renderEntrypointBashHistory = (): string => + String.raw`# Ensure bash history is configured for interactive shells +HISTORY_PATH="/etc/profile.d/zz-bash-history.sh" +if [[ ! -s "$HISTORY_PATH" ]]; then + cat <<'EOF' > "$HISTORY_PATH" +${renderBashHistoryScript()} +EOF + chmod 0644 "$HISTORY_PATH" +fi +if ! grep -q "zz-bash-history.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" >> /etc/bash.bashrc +fi` + +export const renderEntrypointZshConfig = (): string => + String.raw`# Ensure zsh config exists for autosuggestions +ZSHRC_PATH="/etc/zsh/zshrc" +if [[ ! -s "$ZSHRC_PATH" ]]; then + mkdir -p /etc/zsh + cat <<'EOF' > "$ZSHRC_PATH" +${renderZshConfig()} +EOF +fi` +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates.ts b/packages/app/src/lib/core/templates.ts new file mode 100644 index 00000000..d36a7469 --- /dev/null +++ b/packages/app/src/lib/core/templates.ts @@ -0,0 +1,79 @@ +/* jscpd:ignore-start */ +import type { TemplateConfig } from "./domain.js" +import type { ResolvedComposeResourceLimits } from "./resource-limits.js" +import { renderEntrypoint } from "./templates-entrypoint.js" +import { renderDockerCompose } from "./templates/docker-compose.js" +import { renderDockerfile } from "./templates/dockerfile.js" +import { renderPlaywrightBrowserDockerfile, renderPlaywrightStartExtra } from "./templates/playwright.js" + +export type FileSpec = + | { readonly _tag: "File"; readonly relativePath: string; readonly contents: string; readonly mode?: number } + | { readonly _tag: "Dir"; readonly relativePath: string } + +const renderGitignore = (): string => + `# docker-git project files +# NOTE: bootstrap secrets stay local-only and should not be committed. + +# docker-git scripts (copied from workspace, rebuilt on each project update) +scripts/ + +# Volatile Codex artifacts (do not commit) +authorized_keys +.orch/auth/codex/auth.json +.orch/auth/claude/ +.orch/auth/codex/log/ +.orch/auth/codex/tmp/ +.orch/auth/codex/sessions/ +.orch/auth/codex/models_cache.json +` + +const renderDockerignore = (): string => + `# docker-git build context +authorized_keys +.orch/env/ +.orch/auth/codex/ +.orch/auth/claude/ +.orch/auth/codex/log/ +.orch/auth/codex/tmp/ +.orch/auth/codex/sessions/ +.orch/auth/codex/models_cache.json +` + +const renderConfigJson = (config: TemplateConfig): string => + `${JSON.stringify({ schemaVersion: 1, template: config }, null, 2)} +` + +export const planFiles = ( + config: TemplateConfig, + composeResourceLimits?: ResolvedComposeResourceLimits +): ReadonlyArray => { + const maybePlaywrightFiles = config.enableMcpPlaywright + ? ([ + { _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() }, + { + _tag: "File", + relativePath: "mcp-playwright-start-extra.sh", + contents: renderPlaywrightStartExtra(), + mode: 0o755 + } + ] satisfies ReadonlyArray) + : ([] satisfies ReadonlyArray) + + return [ + { _tag: "File", relativePath: "Dockerfile", contents: renderDockerfile(config) }, + { _tag: "File", relativePath: "entrypoint.sh", contents: renderEntrypoint(config), mode: 0o755 }, + { + _tag: "File", + relativePath: "docker-compose.yml", + contents: renderDockerCompose(config, composeResourceLimits) + }, + { _tag: "File", relativePath: ".dockerignore", contents: renderDockerignore() }, + { _tag: "File", relativePath: "docker-git.json", contents: renderConfigJson(config) }, + { _tag: "File", relativePath: ".gitignore", contents: renderGitignore() }, + ...maybePlaywrightFiles, + { _tag: "Dir", relativePath: ".orch/auth/codex" }, + { _tag: "Dir", relativePath: ".orch/auth/claude" }, + { _tag: "Dir", relativePath: ".orch/env" } + ] +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates/docker-compose.ts b/packages/app/src/lib/core/templates/docker-compose.ts new file mode 100644 index 00000000..a05ddecb --- /dev/null +++ b/packages/app/src/lib/core/templates/docker-compose.ts @@ -0,0 +1,208 @@ +/* jscpd:ignore-start */ +import { + dockerGitSharedCacheVolumeName, + dockerGitSharedCodexVolumeName, + resolveComposeNetworkName, + resolveProjectBootstrapVolumeName, + type TemplateConfig +} from "../domain.js" +import type { ResolvedComposeResourceLimits } from "../resource-limits.js" + +type ComposeFragments = { + readonly networkMode: TemplateConfig["dockerNetworkMode"] + readonly networkName: string + readonly maybeGitTokenLabelEnv: string + readonly maybeCodexAuthLabelEnv: string + readonly maybeClaudeAuthLabelEnv: string + readonly maybeAgentModeEnv: string + readonly maybeAgentAutoEnv: string + readonly maybeDependsOn: string + readonly maybePlaywrightEnv: string + readonly maybeBrowserService: string + readonly maybeBrowserVolume: string + readonly maybeBootstrapMounts: string + readonly forkRepoUrl: string +} + +type PlaywrightFragments = Pick< + ComposeFragments, + "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume" +> + +const sharedCodexVolumeKey = "docker_git_shared_codex" +const sharedCacheVolumeKey = "docker_git_shared_cache" +const bootstrapVolumeKey = "docker_git_bootstrap" + +const renderGitTokenLabelEnv = (gitTokenLabel: string): string => + gitTokenLabel.length > 0 + ? ` GITHUB_AUTH_LABEL: "${gitTokenLabel}"\n GIT_AUTH_LABEL: "${gitTokenLabel}"\n` + : "" + +const renderCodexAuthLabelEnv = (codexAuthLabel: string): string => + codexAuthLabel.length > 0 + ? ` CODEX_AUTH_LABEL: "${codexAuthLabel}"\n` + : "" + +const renderClaudeAuthLabelEnv = (claudeAuthLabel: string): string => + claudeAuthLabel.length > 0 + ? ` CLAUDE_AUTH_LABEL: "${claudeAuthLabel}"\n` + : "" + +const renderAgentModeEnv = (agentMode: string | undefined): string => + agentMode !== undefined && agentMode.length > 0 + ? ` AGENT_MODE: "${agentMode}"\n` + : "" + +const renderAgentAutoEnv = (agentAuto: boolean | undefined): string => + agentAuto === true + ? ` AGENT_AUTO: "1"\n` + : "" + +const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string => + resourceLimits === undefined + ? "" + : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n` + +const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/docker-git/bootstrap/source:ro` + +const buildPlaywrightFragments = ( + config: TemplateConfig, + networkName: string, + resourceLimits: ResolvedComposeResourceLimits | undefined +): PlaywrightFragments => { + if (!config.enableMcpPlaywright) { + return { + maybeDependsOn: "", + maybePlaywrightEnv: "", + maybeBrowserService: "", + maybeBrowserVolume: "" + } + } + + const browserServiceName = `${config.serviceName}-browser` + const browserContainerName = `${config.containerName}-browser` + const browserVolumeName = `${config.volumeName}-browser` + const browserDockerfile = "Dockerfile.browser" + const browserCdpEndpoint = `http://${browserServiceName}:9223` + + return { + maybeDependsOn: ` depends_on:\n - ${browserServiceName}\n`, + maybePlaywrightEnv: + ` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`, + maybeBrowserService: + `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${ + renderResourceLimits(resourceLimits) + } environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, + maybeBrowserVolume: ` ${browserVolumeName}:` + } +} + +const buildComposeFragments = ( + config: TemplateConfig, + resourceLimits: ResolvedComposeResourceLimits | undefined +): ComposeFragments => { + const networkMode = config.dockerNetworkMode + const networkName = resolveComposeNetworkName(config) + const forkRepoUrl = config.forkRepoUrl ?? "" + const gitTokenLabel = config.gitTokenLabel?.trim() ?? "" + const codexAuthLabel = config.codexAuthLabel?.trim() ?? "" + const claudeAuthLabel = config.claudeAuthLabel?.trim() ?? "" + const maybeGitTokenLabelEnv = renderGitTokenLabelEnv(gitTokenLabel) + const maybeCodexAuthLabelEnv = renderCodexAuthLabelEnv(codexAuthLabel) + const maybeClaudeAuthLabelEnv = renderClaudeAuthLabelEnv(claudeAuthLabel) + const maybeAgentModeEnv = renderAgentModeEnv(config.agentMode) + const maybeAgentAutoEnv = renderAgentAutoEnv(config.agentAuto) + const playwright = buildPlaywrightFragments(config, networkName, resourceLimits) + + return { + networkMode, + networkName, + maybeGitTokenLabelEnv, + maybeCodexAuthLabelEnv, + maybeClaudeAuthLabelEnv, + maybeAgentModeEnv, + maybeAgentAutoEnv, + maybeDependsOn: playwright.maybeDependsOn, + maybePlaywrightEnv: playwright.maybePlaywrightEnv, + maybeBrowserService: playwright.maybeBrowserService, + maybeBrowserVolume: playwright.maybeBrowserVolume, + maybeBootstrapMounts: renderBootstrapMounts(), + forkRepoUrl + } +} + +const renderComposeServices = ( + config: TemplateConfig, + fragments: ComposeFragments, + resourceLimits: ResolvedComposeResourceLimits | undefined +): string => + `services: + ${config.serviceName}: + build: . + container_name: ${config.containerName} + restart: unless-stopped + environment: + REPO_URL: "${config.repoUrl}" + REPO_REF: "${config.repoRef}" + FORK_REPO_URL: "${fragments.forkRepoUrl}" +${fragments.maybeGitTokenLabelEnv} # Optional token label selector (maps to GITHUB_TOKEN__