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/Dockerfile b/packages/api/Dockerfile index d7528cc8..f299d2ce 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -14,6 +14,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json tsconfig.json ./ COPY patches ./patches +COPY scripts ./scripts COPY packages ./packages RUN pnpm install --frozen-lockfile 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..dcae6dd4 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -28,6 +28,58 @@ 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 CodexAuthImportRequest = { + readonly label?: string | null | undefined + readonly authText: string +} + +export type CodexAuthStatus = { + readonly label: string + readonly message: string + readonly present: boolean + readonly authPath: string +} + +export type CodexAuthLogoutRequest = { + readonly label?: string | null | undefined +} + +export type ApplyAllRequest = { + readonly activeOnly?: boolean | undefined +} + +export type UpProjectRequest = { + readonly authorizedKeysContents?: string | undefined +} + +export type ApiAuthRequired = { + readonly provider: "github" + readonly message: string + readonly command: string +} + export type CreateProjectRequest = { readonly repoUrl?: string | undefined readonly repoRef?: string | undefined @@ -39,6 +91,7 @@ export type CreateProjectRequest = { readonly volumeName?: string | undefined readonly secretsRoot?: string | undefined readonly authorizedKeysPath?: string | undefined + readonly authorizedKeysContents?: string | undefined readonly envGlobalPath?: string | undefined readonly envProjectPath?: string | undefined readonly codexAuthPath?: string | undefined @@ -50,6 +103,7 @@ export type CreateProjectRequest = { readonly enableMcpPlaywright?: boolean | undefined readonly outDir?: string | undefined readonly gitTokenLabel?: string | undefined + readonly skipGithubAuth?: boolean | undefined readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined readonly agentAutoMode?: string | undefined @@ -57,6 +111,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..3b34ef40 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, @@ -14,6 +15,7 @@ export const CreateProjectRequestSchema = Schema.Struct({ volumeName: OptionalString, secretsRoot: OptionalString, authorizedKeysPath: OptionalString, + authorizedKeysContents: OptionalString, envGlobalPath: OptionalString, envProjectPath: OptionalString, codexAuthPath: OptionalString, @@ -25,13 +27,42 @@ export const CreateProjectRequestSchema = Schema.Struct({ enableMcpPlaywright: OptionalBoolean, outDir: OptionalString, gitTokenLabel: OptionalString, + skipGithubAuth: OptionalBoolean, codexTokenLabel: OptionalString, claudeTokenLabel: OptionalString, agentAutoMode: OptionalString, 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 CodexAuthImportRequestSchema = Schema.Struct({ + label: OptionalNullableString, + authText: Schema.String +}) + +export const CodexAuthLogoutRequestSchema = Schema.Struct({ + label: OptionalNullableString +}) + +export const ApplyAllRequestSchema = Schema.Struct({ + activeOnly: OptionalBoolean +}) + +export const UpProjectRequestSchema = Schema.Struct({ + authorizedKeysContents: OptionalString }) export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom") @@ -84,5 +115,11 @@ 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 CodexAuthImportRequestInput = Schema.Schema.Type +export type CodexAuthLogoutRequestInput = Schema.Schema.Type +export type ApplyAllRequestInput = Schema.Schema.Type +export type UpProjectRequestInput = 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..c4ec82ae 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -9,9 +9,27 @@ 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, + CodexAuthImportRequestSchema, + CodexAuthLogoutRequestSchema, + CreateAgentRequestSchema, + CreateFollowRequestSchema, + CreateProjectRequestSchema, + GithubAuthLoginRequestSchema, + GithubAuthLogoutRequestSchema, + UpProjectRequestSchema +} from "./api/schema.js" import { uiHtml, uiScript, uiStyles } from "./ui.js" +import { + importCodexAuth, + loginGithubAuth, + logoutCodexAuth, + logoutGithubAuth, + readCodexAuthStatus, + 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 +45,10 @@ import { makeFederationOutboxCollection } from "./services/federation.js" import { + applyAllProjects, createProjectFromRequest, deleteProjectById, + downAllProjects, downProject, getProject, listProjects, @@ -48,6 +68,7 @@ const AgentParamsSchema = Schema.Struct({ }) type ApiError = + | ApiAuthRequiredError | ApiBadRequestError | ApiNotFoundError | ApiConflictError @@ -93,6 +114,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 +156,15 @@ 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 readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthImportRequestSchema) +const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema) +const readApplyAllRequest = () => HttpServerRequest.schemaBodyJson(ApplyAllRequestSchema) +const readUpProjectRequest = () => + HttpServerRequest.schemaBodyJson(UpProjectRequestSchema).pipe( + Effect.catchAll(() => Effect.succeed({ authorizedKeysContents: undefined })) + ) const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown) const configuredFederationPublicOrigin = @@ -184,6 +228,54 @@ 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( + "/auth/codex/status", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const label = new URL(request.url, "http://localhost").searchParams.get("label") + const status = yield* _(readCodexAuthStatus(label)) + return yield* _(jsonResponse({ status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/codex/import", + Effect.gen(function*(_) { + const request = yield* _(readCodexAuthImportRequest()) + const status = yield* _(importCodexAuth(request)) + return yield* _(jsonResponse({ ok: true, status }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/codex/logout", + Effect.gen(function*(_) { + const request = yield* _(readCodexAuthLogoutRequest()) + const status = yield* _(logoutCodexAuth(request)) + return yield* _(jsonResponse({ ok: true, status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/federation/issues", Effect.sync(() => ({ issues: listFederationIssues() })).pipe( @@ -274,6 +366,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( @@ -292,9 +399,12 @@ export const makeRouter = () => { ), HttpRouter.post( "/projects/:projectId/up", - projectParams.pipe( - Effect.flatMap(({ projectId }) => upProject(projectId)), - Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.gen(function*(_) { + const { projectId } = yield* _(projectParams) + const request = yield* _(readUpProjectRequest()) + yield* _(upProject(projectId, request.authorizedKeysContents)) + return yield* _(jsonResponse({ ok: true }, 200)) + }).pipe( Effect.catchAll(errorResponse) ) ), diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts new file mode 100644 index 00000000..ffeb56f9 --- /dev/null +++ b/packages/api/src/services/auth.ts @@ -0,0 +1,295 @@ +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 { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers" +import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers" +import { Effect, Match } from "effect" + +import type { + CodexAuthImportRequest, + CodexAuthLogoutRequest, + CodexAuthStatus, + GithubAuthLoginRequest, + GithubAuthLogoutRequest, + GithubAuthStatus, + GithubAuthTokenStatus +} from "../api/contracts.js" +import { ApiAuthRequiredError, ApiBadRequestError } 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 +export const codexAuthPath = defaultTemplateConfig.codexAuthPath + +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 resolveControllerCodexPath = (path: Path.Path, authPath: string): string => + resolvePathFromCwd(path, process.cwd(), authPath) + +const resolveCodexLabel = (label: string | null | undefined): string => + normalizeAccountLabel(label ?? null, "default") + +const resolveCodexAccountPath = ( + path: Path.Path, + authPath: string, + label: string | null | undefined +): string => { + const basePath = resolveControllerCodexPath(path, authPath) + const normalizedLabel = resolveCodexLabel(label) + return normalizedLabel === "default" ? basePath : path.join(basePath, normalizedLabel) +} + +const resolveCodexAuthFilePath = ( + path: Path.Path, + authPath: string, + label: string | null | undefined +): string => + path.join(resolveCodexAccountPath(path, authPath, label), "auth.json") + +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)) + }) + +const codexAuthStatus = ( + present: boolean, + label: string, + authPath: string +): CodexAuthStatus => ({ + label, + message: present + ? "Codex auth imported into controller state." + : "Codex auth not found in controller state.", + present, + authPath +}) + +export const readCodexAuthStatus = ( + label?: string | null | undefined +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedLabel = resolveCodexLabel(label) + const resolvedAuthPath = resolveCodexAuthFilePath(path, codexAuthPath, label) + const exists = yield* _(fs.exists(resolvedAuthPath)) + if (!exists) { + return codexAuthStatus(false, resolvedLabel, resolvedAuthPath) + } + + const info = yield* _(fs.stat(resolvedAuthPath)) + if (info.type !== "File") { + return codexAuthStatus(false, resolvedLabel, resolvedAuthPath) + } + + return codexAuthStatus(true, resolvedLabel, resolvedAuthPath) + }) + +export const importCodexAuth = ( + request: CodexAuthImportRequest +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedAuthPath = resolveCodexAuthFilePath(path, codexAuthPath, request.label) + const parsed = yield* _( + Effect.try({ + try: () => JSON.parse(request.authText), + catch: (cause) => + new ApiBadRequestError({ + message: "Invalid Codex auth JSON.", + details: cause + }) + }) + ) + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Codex auth JSON must be an object." + }) + ) + ) + } + + yield* _(fs.makeDirectory(path.dirname(resolvedAuthPath), { recursive: true })) + yield* _(fs.writeFileString(resolvedAuthPath, JSON.stringify(parsed, null, 2))) + return yield* _(readCodexAuthStatus(request.label)) + }) + +export const logoutCodexAuth = ( + request: CodexAuthLogoutRequest +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedAuthPath = resolveCodexAuthFilePath(path, codexAuthPath, request.label) + const exists = yield* _(fs.exists(resolvedAuthPath)) + + if (exists) { + yield* _(fs.remove(resolvedAuthPath)) + } + + return yield* _(readCodexAuthStatus(request.label)) + }) + +export const ensureGithubAuthForCreate = (config: { + readonly repoUrl: string + readonly gitTokenLabel?: string | undefined + readonly skipGithubAuth?: boolean | undefined + readonly envGlobalPath: string +}): Effect.Effect => + Effect.gen(function*(_) { + if (parseGithubRepoUrl(config.repoUrl) === null) { + return + } + + if (config.skipGithubAuth === true) { + 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..ba5abaf7 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -1,6 +1,18 @@ -import { buildCreateCommand, createProject, formatParseError, listProjectItems, readProjectConfig } from "@effect-template/lib" +import { + buildCreateCommand, + createProject, + formatParseError, + applyAllDockerGitProjects, + downAllDockerGitProjects, + listProjectItems, + readProjectConfig, + runDockerComposeUpWithPortCheck +} from "@effect-template/lib" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" import { runCommandCapture } from "@effect-template/lib/shell/command-runner" import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { defaultProjectsRoot, resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers" import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects" import type { RawOptions } from "@effect-template/lib/core/command-options" import type { ProjectItem } from "@effect-template/lib/usecases/projects" @@ -8,6 +20,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) => @@ -149,12 +162,66 @@ const resolveCreatedProject = ( }) ) +const normalizeAuthorizedKeys = (value: string): ReadonlyArray => + value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + +const mergeAuthorizedKeys = ( + current: ReadonlyArray, + next: ReadonlyArray +): string => { + const merged = [...current] + for (const line of next) { + if (!merged.includes(line)) { + merged.push(line) + } + } + return merged.length === 0 ? "" : `${merged.join("\n")}\n` +} + +export const seedAuthorizedKeysForCreate = ( + outDir: string, + authorizedKeysContents: string | undefined +) => + Effect.gen(function*(_) { + const normalized = normalizeAuthorizedKeys(authorizedKeysContents ?? "") + if (normalized.length === 0) { + return + } + + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const defaultAuthorizedKeysPath = path.join(defaultProjectsRoot(process.cwd()), "authorized_keys") + const resolvedOutDir = resolvePathFromCwd(path, process.cwd(), outDir) + const projectAuthorizedKeysPath = path.join(resolvedOutDir, "authorized_keys") + const targets = Array.from(new Set([defaultAuthorizedKeysPath, projectAuthorizedKeysPath])) + + for (const target of targets) { + const exists = yield* _(fs.exists(target)) + const current = exists ? yield* _(fs.readFileString(target)) : "" + const merged = mergeAuthorizedKeys(normalizeAuthorizedKeys(current), normalized) + + yield* _(fs.makeDirectory(path.dirname(target), { recursive: true })) + yield* _(fs.writeFileString(target, merged)) + } + }) + export const listProjects = () => listProjectItems.pipe( Effect.flatMap((projects) => Effect.forEach(projects, withProjectRuntime, { concurrency: "unbounded" })), Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) ) +export const applyAllProjects = (activeOnly: boolean) => + applyAllDockerGitProjects({ + _tag: "ApplyAll", + activeOnly + }) + +export const downAllProjects = () => downAllDockerGitProjects + export const getProject = ( projectId: string ) => @@ -200,6 +267,7 @@ export const createProjectFromRequest = ( ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), ...(request.outDir === undefined ? {} : { outDir: request.outDir }), ...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }), + ...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }), ...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }), ...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }), ...(request.agentAutoMode === undefined ? {} : { agentAutoMode: request.agentAutoMode }), @@ -223,9 +291,14 @@ export const createProjectFromRequest = ( const command = { ...parsed.right, - openSsh: false + openSsh: false, + waitForClone: request.waitForClone ?? parsed.right.waitForClone } + yield* _(seedAuthorizedKeysForCreate(command.outDir, request.authorizedKeysContents)) + + yield* _(ensureGithubAuthForCreate(command.config)) + yield* _( Effect.sync(() => { emitProjectEvent(command.outDir, "project.deployment.status", { @@ -276,13 +349,89 @@ const markDeployment = (projectId: string, phase: string, message: string) => emitProjectEvent(projectId, "project.deployment.status", { phase, message }) }) +const syncContainerAuthorizedKeys = ( + project: ProjectItem +) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const sourcePath = path.join(project.projectDir, "authorized_keys") + + yield* _( + runCommandCapture( + { + cwd: project.projectDir, + command: "docker", + args: [ + "exec", + project.containerName, + "sh", + "-c", + [ + "set -eu", + `mkdir -p /home/${project.sshUser}/.docker-git`, + `mkdir -p /home/${project.sshUser}/.ssh` + ].join("; ") + ] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker exec prepare authorized_keys sync", exitCode }) + ).pipe(Effect.asVoid) + ) + + yield* _( + runCommandCapture( + { + cwd: project.projectDir, + command: "docker", + args: [ + "cp", + sourcePath, + `${project.containerName}:/home/${project.sshUser}/.docker-git/authorized_keys` + ] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker cp authorized_keys", exitCode }) + ).pipe(Effect.asVoid) + ) + + yield* _( + runCommandCapture( + { + cwd: project.projectDir, + command: "docker", + args: [ + "exec", + project.containerName, + "sh", + "-c", + [ + "set -eu", + `cp /home/${project.sshUser}/.docker-git/authorized_keys /home/${project.sshUser}/.ssh/authorized_keys`, + `chown ${project.sshUser}:${project.sshUser} /home/${project.sshUser}/.docker-git/authorized_keys`, + `chmod 600 /home/${project.sshUser}/.docker-git/authorized_keys`, + `chown ${project.sshUser}:${project.sshUser} /home/${project.sshUser}/.ssh/authorized_keys`, + `chmod 600 /home/${project.sshUser}/.ssh/authorized_keys` + ].join("; ") + ] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker exec sync authorized_keys", exitCode }) + ).pipe(Effect.asVoid) + ) + }) + export const upProject = ( - projectId: string + projectId: string, + authorizedKeysContents?: string ) => Effect.gen(function*(_) { const project = yield* _(findProjectById(projectId)) + yield* _(seedAuthorizedKeysForCreate(project.projectDir, authorizedKeysContents)) yield* _(markDeployment(projectId, "build", "docker compose up -d --build")) - yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(runDockerComposeUpWithPortCheck(project.projectDir)) + if ((authorizedKeysContents ?? "").trim().length > 0) { + yield* _(syncContainerAuthorizedKeys(project)) + } yield* _(markDeployment(projectId, "running", "Container running")) }) @@ -319,7 +468,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..f079d621 --- /dev/null +++ b/packages/api/tests/auth.test.ts @@ -0,0 +1,265 @@ +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 { + ensureGithubAuthForCreate, + importCodexAuth, + logoutCodexAuth, + readCodexAuthStatus, + 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))) + + it.effect("skips API GitHub auth gate when anonymous clone override is enabled", () => + 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")) + + yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + ensureGithubAuthForCreate({ + repoUrl: "https://github.com/ProverCoderAI/docker-git", + gitTokenLabel: undefined, + skipGithubAuth: true, + envGlobalPath: ".docker-git/.orch/env/global.env" + }) + ) + ) + ) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("imports Codex auth into the controller-owned auth directory", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const authDir = path.join(projectsRoot, ".orch", "auth", "codex") + const authText = JSON.stringify({ openai: { type: "oauth", refresh: "refresh", access: "access" } }, null, 2) + + yield* _(fs.makeDirectory(projectsRoot, { recursive: true })) + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + importCodexAuth({ authText }) + ) + ) + ) + + expect(status.present).toBe(true) + expect(status.authPath).toBe(path.join(authDir, "auth.json")) + expect(status.message).toBe("Codex auth imported into controller state.") + + const fileText = yield* _(fs.readFileString(path.join(authDir, "auth.json"))) + expect(fileText).toContain('"refresh": "refresh"') + + const readStatus = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, readCodexAuthStatus()) + ) + ) + + expect(readStatus.present).toBe(true) + expect(readStatus.authPath).toBe(path.join(authDir, "auth.json")) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("removes labeled Codex auth from controller state", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const labeledAuthDir = path.join(projectsRoot, ".orch", "auth", "codex", "team-a") + const authText = JSON.stringify({ tokens: { access_token: "access", refresh_token: "refresh" } }, null, 2) + + yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + importCodexAuth({ label: "team-a", authText }) + ) + ) + ) + + const removed = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, logoutCodexAuth({ label: "team-a" })) + ) + ) + + expect(removed.present).toBe(false) + expect(removed.label).toBe("team-a") + expect(removed.authPath).toBe(path.join(labeledAuthDir, "auth.json")) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/api/tests/projects.test.ts b/packages/api/tests/projects.test.ts new file mode 100644 index 00000000..8ba366ea --- /dev/null +++ b/packages/api/tests/projects.test.ts @@ -0,0 +1,89 @@ +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 { seedAuthorizedKeysForCreate } 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-projects-" + }) + ) + 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"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = previous + } + }) + ) + +describe("projects service", () => { + it.effect("seeds host SSH keys into the controller managed authorized_keys file", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const expectedDefaultPath = path.join(projectsRoot, "authorized_keys") + const expectedProjectPath = path.join(projectsRoot, "org", "repo", "authorized_keys") + const hostKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestHostKey docker-git@test" + + yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + seedAuthorizedKeysForCreate(".docker-git/org/repo", hostKey) + ) + ) + ) + + const defaultContents = yield* _(fs.readFileString(expectedDefaultPath)) + const projectContents = yield* _(fs.readFileString(expectedProjectPath)) + expect(defaultContents).toBe(`${hostKey}\n`) + expect(projectContents).toBe(`${hostKey}\n`) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts index 2e77bc5b..f599caf2 100644 --- a/packages/api/tests/schema.test.ts +++ b/packages/api/tests/schema.test.ts @@ -1,7 +1,17 @@ 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, + CodexAuthImportRequestSchema, + CodexAuthLogoutRequestSchema, + CreateAgentRequestSchema, + CreateFollowRequestSchema, + CreateProjectRequestSchema, + GithubAuthLoginRequestSchema, + GithubAuthLogoutRequestSchema, + UpProjectRequestSchema +} from "../src/api/schema.js" describe("api schemas", () => { it.effect("decodes create project payload", () => @@ -9,6 +19,8 @@ describe("api schemas", () => { const result = Schema.decodeUnknownEither(CreateProjectRequestSchema)({ repoUrl: "https://github.com/ProverCoderAI/docker-git", repoRef: "main", + authorizedKeysContents: "ssh-ed25519 AAAA-test test@example\n", + skipGithubAuth: true, up: true, force: false }) @@ -19,6 +31,8 @@ describe("api schemas", () => { }, onRight: (value) => { expect(value.repoRef).toBe("main") + expect(value.authorizedKeysContents).toContain("ssh-ed25519") + expect(value.skipGithubAuth).toBe(true) expect(value.up).toBe(true) } }) @@ -61,4 +75,106 @@ 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 codex auth import payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CodexAuthImportRequestSchema)({ + label: "team-a", + authText: JSON.stringify({ openai: { type: "api", key: "test" } }) + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.label).toBe("team-a") + expect(value.authText).toContain('"key":"test"') + } + }) + })) + + it.effect("decodes codex auth logout payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CodexAuthLogoutRequestSchema)({ + label: "team-a" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.label).toBe("team-a") + } + }) + })) + + 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) + } + }) + })) + + it.effect("decodes up-project payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(UpProjectRequestSchema)({ + authorizedKeysContents: "ssh-ed25519 AAAA-test test@example\n" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.authorizedKeysContents).toContain("ssh-ed25519") + } + }) + })) }) 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..c57ac4e5 --- /dev/null +++ b/packages/app/src/docker-git/api-client.ts @@ -0,0 +1,162 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { + AuthCodexImportCommand, + AuthCodexLogoutCommand, + AuthCodexStatusCommand, + AuthGithubLoginCommand, + AuthGithubLogoutCommand, + AuthGithubStatusCommand, + CreateCommand +} from "@lib/core/domain" +import { resolvePathFromCwd } from "@lib/usecases/path-helpers" + +import { request, requestVoid } from "./api-http.js" +import { asArray, asObject, asString, type JsonRequest, type JsonValue } from "./api-json.js" +import { decodeProjectDetails, decodeProjectSummary } from "./api-project-codec.js" +import { resolveHostSshMaterial, resolveManagedHostSshMaterial } from "./host-ssh-material.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" + +const projectPath = (projectId: string, suffix = ""): string => `/projects/${encodeURIComponent(projectId)}${suffix}` + +const readProjectOutput = (payload: JsonValue): string => { + const object = asObject(payload) + return asString(object?.["output"]) ?? "" +} + +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 getProject = (projectId: string) => + request("GET", projectPath(projectId)).pipe( + Effect.map((payload) => { + const object = asObject(payload) + return object === null ? decodeProjectDetails(payload) : decodeProjectDetails(object["project"] ?? payload) + }) + ) + +export const createProject = (command: CreateCommand) => + Effect.gen(function*(_) { + const config = command.config + const sshMaterial = yield* _(resolveHostSshMaterial(command)) + 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, + skipGithubAuth: config.skipGithubAuth, + authorizedKeysContents: sshMaterial.authorizedKeysContents.length > 0 + ? sshMaterial.authorizedKeysContents + : undefined, + 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 + + const payload = yield* _(request("POST", "/projects", body)) + const object = asObject(payload) + return object === null ? decodeProjectDetails(payload) : decodeProjectDetails(object["project"] ?? payload) + }) + +export const deleteProject = (projectId: string) => requestVoid("DELETE", projectPath(projectId)) + +export const upProject = (projectId: string) => + Effect.gen(function*(_) { + const sshMaterial = yield* _(resolveManagedHostSshMaterial()) + return yield* _( + requestVoid("POST", projectPath(projectId, "/up"), { + authorizedKeysContents: sshMaterial.authorizedKeysContents.length > 0 + ? sshMaterial.authorizedKeysContents + : undefined + }) + ) + }) + +export const downProject = (projectId: string) => requestVoid("POST", projectPath(projectId, "/down")) + +export const readProjectPs = (projectId: string) => + request("GET", projectPath(projectId, "/ps")).pipe( + Effect.map((payload) => readProjectOutput(payload)) + ) + +export const readProjectLogs = (projectId: string) => + request("GET", projectPath(projectId, "/logs")).pipe( + Effect.map((payload) => readProjectOutput(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 + }) + +const readCodexAuthText = (command: AuthCodexImportCommand) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedCodexAuthDir = resolvePathFromCwd(path, process.cwd(), command.codexAuthPath) + const authFilePath = path.join(resolvedCodexAuthDir, "auth.json") + return yield* _(fs.readFileString(authFilePath)) + }) + +export const codexImport = (command: AuthCodexImportCommand) => + Effect.gen(function*(_) { + const authText = yield* _(readCodexAuthText(command)) + return yield* _(request("POST", "/auth/codex/import", { label: command.label, authText })) + }) + +export const codexStatus = (command: AuthCodexStatusCommand) => { + const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}` + return request("GET", `/auth/codex/status${query}`) +} + +export const codexLogout = (command: AuthCodexLogoutCommand) => + requestVoid("POST", "/auth/codex/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..443291f7 --- /dev/null +++ b/packages/app/src/docker-git/api-http.ts @@ -0,0 +1,177 @@ +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 { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js" +import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" + +type ApiTransportError = ApiRequestError | ApiAuthRequiredError +type ApiHttpMethod = "GET" | "POST" | "DELETE" + +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) + return isAuthRequired(status, error) + ? toAuthRequiredError(error, status) + : toApiRequestError(method, path, status, error) +} + +const requestBody = (body: JsonRequest | undefined) => body === undefined ? HttpBody.empty : HttpBody.unsafeJson(body) + +const executeRequest = ( + client: HttpClient.HttpClient, + apiBaseUrl: string, + method: ApiHttpMethod, + path: string, + body: JsonRequest | undefined +) => { + const url = `${apiBaseUrl}${path}` + + if (method === "GET") { + return client.get(url, { headers: jsonHeaders }) + } + + if (method === "DELETE") { + return client.del(url, { + headers: jsonHeaders, + body: requestBody(body) + }) + } + + return client.post(url, { + headers: jsonHeaders, + body: requestBody(body) + }) +} + +export const request = ( + method: ApiHttpMethod, + path: string, + body?: JsonRequest +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _( + executeRequest(client, resolveApiBaseUrl(), method, path, body).pipe( + Effect.matchEffect({ + onFailure: (error) => + ensureControllerReady().pipe( + Effect.matchEffect({ + onFailure: () => Effect.fail(error), + onSuccess: () => executeRequest(client, resolveApiBaseUrl(), method, path, body) + }) + ), + onSuccess: (value) => Effect.succeed(value) + }) + ) + ) + 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: ApiHttpMethod, 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..c92dffb9 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" @@ -82,6 +82,12 @@ const buildCodexCommand = (action: string, options: AuthOptions): Either.Either< label: options.label, codexAuthPath: options.codexAuthPath })), + Match.when("import", () => + Either.right({ + _tag: "AuthCodexImport", + label: options.label, + codexAuthPath: options.codexAuthPath + })), Match.when("status", () => Either.right({ _tag: "AuthCodexStatus", 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-open.ts b/packages/app/src/docker-git/cli/parser-open.ts new file mode 100644 index 00000000..ee605079 --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-open.ts @@ -0,0 +1,57 @@ +import { Either } from "effect" + +import { type OpenCommand, type ParseError } from "@lib/core/domain" + +import { parseRawOptions } from "./parser-options.js" + +type OpenParts = { + readonly projectRef?: string | undefined + readonly projectDir?: string | undefined +} + +const splitOpenArgs = ( + args: ReadonlyArray +): { readonly positionalRef: string | undefined; readonly rest: ReadonlyArray } => { + const first = args[0] + const positionalRef = first !== undefined && !first.startsWith("-") ? first : undefined + return { + positionalRef, + rest: positionalRef === undefined ? args : args.slice(1) + } +} + +const buildOpenCommand = (parts: OpenParts): OpenCommand => ({ + _tag: "Open", + ...(parts.projectRef === undefined ? {} : { projectRef: parts.projectRef }), + ...(parts.projectDir === undefined ? {} : { projectDir: parts.projectDir }) +}) + +const normalizeSelector = (value: string | undefined): string | undefined => { + const trimmed = value?.trim() ?? "" + return trimmed.length > 0 ? trimmed : undefined +} + +// CHANGE: parse open as a distinct selector-based command +// WHY: open must resolve existing projects by raw selector without tmux semantics +// QUOTE(ТЗ): "open should parse to a distinct _tag: \"Open\" command" +// REF: user-request-2026-04-02-open-command-parser +// SOURCE: n/a +// FORMAT THEOREM: forall argv: parseOpen(argv) = cmd -> cmd._tag = "Open" +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: preserves raw selector and optional explicit projectDir override +// COMPLEXITY: O(n) where n = |argv| +export const parseOpen = (args: ReadonlyArray): Either.Either => { + const { positionalRef, rest } = splitOpenArgs(args) + return Either.flatMap(parseRawOptions(rest), (raw) => + Either.right( + buildOpenCommand({ + ...(normalizeSelector(raw.projectDir) === undefined + ? {} + : { projectDir: normalizeSelector(raw.projectDir) }), + ...(normalizeSelector(raw.containerName ?? raw.repoUrl ?? positionalRef) === undefined + ? {} + : { projectRef: normalizeSelector(raw.containerName ?? raw.repoUrl ?? positionalRef) }) + }) + )) +} diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 5a733c61..1022af98 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 @@ -98,6 +98,7 @@ const booleanFlagUpdaters: Readonly RawOptio "--no-up": (raw) => ({ ...raw, up: false }), "--ssh": (raw) => ({ ...raw, openSsh: true }), "--no-ssh": (raw) => ({ ...raw, openSsh: false }), + "--gh-skip": (raw) => ({ ...raw, skipGithubAuth: true }), "--force": (raw) => ({ ...raw, force: true }), "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), @@ -280,4 +281,4 @@ export const parseRawOptions = (args: ReadonlyArray): Either.Either): Either.Either parseApplyAll(rest)), Match.when("update-all", () => parseApplyAll(rest)), Match.when("auth", () => parseAuth(rest)), - Match.when("open", () => parseAttach(rest)), + Match.when("open", () => parseOpen(rest)), Match.when("apply", () => parseApply(rest)), Match.when("state", () => parseState(rest)), Match.when("session-gists", () => parseSessionGists(rest)), diff --git a/packages/app/src/docker-git/cli/read-command.ts b/packages/app/src/docker-git/cli/read-command.ts index a93408f6..58554bde 100644 --- a/packages/app/src/docker-git/cli/read-command.ts +++ b/packages/app/src/docker-git/cli/read-command.ts @@ -1,6 +1,6 @@ import { Effect, Either, pipe } from "effect" -import { type Command, type ParseError } from "@effect-template/lib/core/domain" +import { type Command, type ParseError } from "@lib/core/domain" import { parseArgs } from "./parser.js" diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index c6bd5aec..0a1851f5 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -1,14 +1,12 @@ -import { Match } from "effect" - -import type { ParseError } from "@effect-template/lib/core/domain" +export { formatParseError } from "@lib/core/parse-errors" export const usageText = `docker-git menu docker-git create [--repo-url ] [options] docker-git clone [options] -docker-git open [] [options] +docker-git open [] [options] docker-git apply [] [options] docker-git mcp-playwright [] [options] -docker-git attach [] [options] +docker-git attach [] [options] docker-git panes [] [options] docker-git scrap [] [options] docker-git sessions [list] [] [options] @@ -28,10 +26,10 @@ Commands: menu Interactive menu (default when no args) create, init Generate docker development environment (repo URL optional) clone Create + run container and clone repo - open Open existing docker-git project workspace + open Open an existing docker-git project by selector, URL, or path apply Apply docker-git config to an existing project/container (current dir by default) mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir - attach, tmux Alias for open + attach, tmux Attach to an existing docker-git project workspace with tmux panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) sessions List/kill/log container terminal processes @@ -66,6 +64,7 @@ Options: --archive Scrap snapshot directory (default: .orch/scrap/session) --mode Scrap mode (default: session) --git-token