From 29229de83d00a07ad99022127845ac50d32acc9f Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 10 Feb 2026 20:05:46 +0000 Subject: [PATCH 1/6] fix(ci): restore checkout permissions - Grant GITHUB_TOKEN contents:read so actions/checkout can fetch. - Reduce lint complexity in resolveCreateInputs. --- .github/workflows/check.yml | 3 ++- .github/workflows/snapshot.yml | 3 ++- packages/app/src/docker-git/menu-create.ts | 6 +++--- packages/app/src/docker-git/menu-render.ts | 3 +-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7389bccf..4b9893f7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,7 +11,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: {} +permissions: + contents: read jobs: build: diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index a999b9d2..ff7373ed 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -5,7 +5,8 @@ on: branches: [main, next-minor, next-major] workflow_dispatch: -permissions: {} +permissions: + contents: read jobs: snapshot: diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 83ac0563..d0a2c1d1 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -108,9 +108,9 @@ export const resolveCreateInputs = ( repoRef: values.repoRef ?? "main", outDir, secretsRoot, - runUp: values.runUp ?? true, - enableMcpPlaywright: values.enableMcpPlaywright ?? false, - force: values.force ?? false + runUp: values.runUp !== false, + enableMcpPlaywright: values.enableMcpPlaywright === true, + force: values.force === true } } diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 747f6653..52ab1a21 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -25,8 +25,7 @@ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): strin Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`), Match.when( "mcpPlaywright", - () => - `Enable Playwright MCP (Chromium sidecar)? [${defaults.enableMcpPlaywright ? "y" : "N"}]` + () => `Enable Playwright MCP (Chromium sidecar)? [${defaults.enableMcpPlaywright ? "y" : "N"}]` ), Match.when( "force", From e341822aa26acc7916c7cb0d81aebdbc92bc9d24 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 10 Feb 2026 20:10:48 +0000 Subject: [PATCH 2/6] fix(test): build lib before running app tests CI test job runs `pnpm test` without building @effect-template/lib, but the package exports types from dist. Add `pretest` to build lib so lint:tests + vitest can resolve imports. --- packages/app/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/package.json b/packages/app/package.json index 130c27a9..ca05ce93 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -22,6 +22,7 @@ "list": "pnpm -C ../.. run list", "prestart": "pnpm run build", "start": "node dist/main.js", + "pretest": "pnpm -C ../lib build", "test": "pnpm run lint:tests && vitest run", "pretypecheck": "pnpm -C ../lib build", "typecheck": "tsc --noEmit" From cef15833c54687da6a401dbe392fe6092fe75d70 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 10 Feb 2026 21:00:13 +0000 Subject: [PATCH 3/6] chore(ci): add lib to checks --- .github/workflows/check.yml | 9 +++++++++ packages/lib/biome.json | 3 +++ packages/lib/package.json | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4b9893f7..d4dfbd6f 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -33,6 +33,8 @@ jobs: - name: Install dependencies uses: ./.github/actions/setup - run: pnpm check + - name: Types (lib) + run: pnpm --filter @effect-template/lib typecheck lint: name: Lint @@ -49,6 +51,9 @@ jobs: - name: Install global linter dependencies run: npm install -g typescript @biomejs/biome - run: pnpm lint + - name: Lint (lib, informational) + continue-on-error: true + run: pnpm --filter @effect-template/lib lint test: name: Test @@ -62,6 +67,8 @@ jobs: - name: Install global linter dependencies run: npm install -g typescript @biomejs/biome - run: pnpm test + - name: Test (lib) + run: pnpm --filter @effect-template/lib test lint-effect: name: Lint Effect-TS @@ -72,3 +79,5 @@ jobs: - name: Install dependencies uses: ./.github/actions/setup - run: pnpm lint:effect + - name: Lint Effect-TS (lib) + run: pnpm --filter @effect-template/lib lint:effect diff --git a/packages/lib/biome.json b/packages/lib/biome.json index c6bcae66..27008332 100644 --- a/packages/lib/biome.json +++ b/packages/lib/biome.json @@ -8,6 +8,9 @@ "files": { "ignoreUnknown": false }, + "assist": { + "enabled": false + }, "formatter": { "enabled": false, "indentStyle": "tab" diff --git a/packages/lib/package.json b/packages/lib/package.json index 6a999788..965e242a 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -12,7 +12,7 @@ "lint": "npx @ton-ai-core/vibecode-linter src/", "lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "vitest run" + "test": "vitest run --passWithNoTests" }, "repository": { "type": "git", From 86ca11b34f0985671b1b47a81c24753761397edd Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 10 Feb 2026 21:00:28 +0000 Subject: [PATCH 4/6] fix(lib): pass effect lint --- packages/lib/src/core/templates-entrypoint.ts | 20 +- packages/lib/src/core/templates.ts | 30 ++- packages/lib/src/shell/docker.ts | 28 +- packages/lib/src/usecases/actions.ts | 17 +- packages/lib/src/usecases/menu-helpers.ts | 12 +- packages/lib/src/usecases/path-helpers.ts | 12 +- packages/lib/src/usecases/projects-core.ts | 3 +- packages/lib/src/usecases/projects-delete.ts | 14 +- packages/lib/src/usecases/projects-down.ts | 4 +- packages/lib/src/usecases/projects-list.ts | 4 +- packages/lib/src/usecases/projects-ssh.ts | 16 +- packages/lib/src/usecases/projects-up.ts | 14 +- packages/lib/src/usecases/projects.ts | 4 +- packages/lib/src/usecases/state-normalize.ts | 14 +- packages/lib/src/usecases/state-repo.ts | 255 +++++++++--------- 15 files changed, 241 insertions(+), 206 deletions(-) diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 150db1fc..a4072259 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -101,19 +101,19 @@ fi` // INVARIANT: config.toml is only appended once per container (idempotent) // COMPLEXITY: O(1) const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => - `# Optional: configure Playwright MCP for Codex (browser automation) + String.raw`# Optional: configure Playwright MCP for Codex (browser automation) CODEX_CONFIG_FILE="${config.codexHome}/config.toml" # Keep config.toml consistent with the container build. # If Playwright MCP is disabled for this container, remove the block so Codex # doesn't try (and fail) to spawn docker-git-playwright-mcp. if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then - if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\\[mcp_servers\\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' BEGIN { skip=0 } /^# docker-git: Playwright MCP/ { next } - /^\\[mcp_servers[.]playwright([.]|\\])/ { skip=1; next } - skip==1 && /^\\[/ { skip=0 } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } skip==0 { print } ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" @@ -146,12 +146,12 @@ EOF fi # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. - if grep -q "^\\[mcp_servers\\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then awk ' BEGIN { skip=0 } /^# docker-git: Playwright MCP/ { next } - /^\\[mcp_servers[.]playwright([.]|\\])/ { skip=1; next } - skip==1 && /^\\[/ { skip=0 } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } skip==0 { print } ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" @@ -573,11 +573,11 @@ fi` // INVARIANT: edits /etc/pam.d/sshd idempotently (comments only) // COMPLEXITY: O(n) where n = number of pam lines const renderEntrypointDisableMotd = (): string => - `# 4.75) Disable Ubuntu MOTD noise for SSH sessions + String.raw`# 4.75) Disable Ubuntu MOTD noise for SSH sessions PAM_SSHD="/etc/pam.d/sshd" if [[ -f "$PAM_SSHD" ]]; then - sed -i 's/^[[:space:]]*session[[:space:]]\\+optional[[:space:]]\\+pam_motd\\.so/#&/' "$PAM_SSHD" || true - sed -i 's/^[[:space:]]*session[[:space:]]\\+optional[[:space:]]\\+pam_lastlog\\.so/#&/' "$PAM_SSHD" || true + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true fi # Also disable sshd's own banners (e.g. "Last login") diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 67f33c43..4bffa2c6 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -45,8 +45,9 @@ RUN curl -fsSL https://bun.sh/install | bash RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex -${config.enableMcpPlaywright - ? `RUN npm install -g @playwright/mcp@latest +${ + config.enableMcpPlaywright + ? `RUN npm install -g @playwright/mcp@latest # docker-git: wrapper that converts a CDP HTTP endpoint into a usable WS endpoint # Some Chromium images return webSocketDebuggerUrl pointing at 127.0.0.1 (container-local). @@ -70,15 +71,15 @@ fi # kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. JSON="$(curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version")" -WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require(\"fs\"); const j=JSON.parse(fs.readFileSync(0,\"utf8\")); process.stdout.write(j.webSocketDebuggerUrl || \"\")')" +WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')" if [[ -z "$WS_URL" ]]; then echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2 exit 1 fi # Rewrite ws origin to match the CDP endpoint origin (docker DNS). -BASE_WS="$(CDP_ENDPOINT="$CDP_ENDPOINT" node -e 'const { URL } = require(\"url\"); const u=new URL(process.env.CDP_ENDPOINT); const proto=u.protocol===\"https:\"?\"wss:\":\"ws:\"; process.stdout.write(proto + \"//\" + u.host)')" -WS_REWRITTEN="$(BASE_WS="$BASE_WS" WS_URL="$WS_URL" node -e 'const { URL } = require(\"url\"); const base=new URL(process.env.BASE_WS); const ws=new URL(process.env.WS_URL); ws.protocol=base.protocol; ws.host=base.host; process.stdout.write(ws.toString())')" +BASE_WS="$(CDP_ENDPOINT="$CDP_ENDPOINT" node -e 'const { URL } = require("url"); const u=new URL(process.env.CDP_ENDPOINT); const proto=u.protocol==="https:"?"wss:":"ws:"; process.stdout.write(proto + "//" + u.host)')" +WS_REWRITTEN="$(BASE_WS="$BASE_WS" WS_URL="$WS_URL" node -e 'const { URL } = require("url"); const base=new URL(process.env.BASE_WS); const ws=new URL(process.env.WS_URL); ws.protocol=base.protocol; ws.host=base.host; process.stdout.write(ws.toString())')" EXTRA_ARGS=() if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then @@ -88,7 +89,8 @@ fi exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` - : ""} + : "" + } RUN printf "export BUN_INSTALL=/usr/local/bun\\nexport PATH=/usr/local/bun/bin:$PATH\\n" \ > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` @@ -303,14 +305,14 @@ const renderConfigJson = (config: TemplateConfig): string => export const planFiles = (config: TemplateConfig): ReadonlyArray => { const maybePlaywrightFiles = config.enableMcpPlaywright ? ([ - { _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() }, - { - _tag: "File", - relativePath: "mcp-playwright-start-extra.sh", - contents: renderPlaywrightStartExtra(), - mode: 0o755 - } - ] satisfies ReadonlyArray) + { _tag: "File", relativePath: "Dockerfile.browser", contents: renderPlaywrightBrowserDockerfile() }, + { + _tag: "File", + relativePath: "mcp-playwright-start-extra.sh", + contents: renderPlaywrightStartExtra(), + mode: 0o755 + } + ] satisfies ReadonlyArray) : ([] satisfies ReadonlyArray) return [ diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 231231e4..97cb16a1 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -13,6 +13,20 @@ const composeSpec = (cwd: string, args: ReadonlyArray) => ({ args: ["compose", "--ansi", "never", "--progress", "plain", ...args] }) +const parseInspectNetworkEntry = (line: string): ReadonlyArray => { + const idx = line.indexOf("=") + if (idx <= 0) { + return [] + } + const network = line.slice(0, idx).trim() + const ip = line.slice(idx + 1).trim() + if (network.length === 0 || ip.length === 0) { + return [] + } + const entry: readonly [string, string] = [network, ip] + return [entry] +} + const runCompose = ( cwd: string, args: ReadonlyArray, @@ -215,7 +229,7 @@ export const runDockerInspectContainerIp = ( // Example output: // bridge=172.17.0.4 // _dg--net=192.168.64.3 - '{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\\n" $k $v.IPAddress}}{{end}}', + String.raw`{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\n" $k $v.IPAddress}}{{end}}`, containerName ] }, @@ -229,15 +243,7 @@ export const runDockerInspectContainerIp = ( .map((line) => line.trim()) .filter((line) => line.length > 0) - const entries = lines.flatMap((line) => { - const idx = line.indexOf("=") - if (idx <= 0) { - return [] - } - const network = line.slice(0, idx).trim() - const ip = line.slice(idx + 1).trim() - return network.length > 0 && ip.length > 0 ? ([[network, ip]] as const) : [] - }) + const entries = lines.flatMap((line) => parseInspectNetworkEntry(line)) if (entries.length === 0) { return "" @@ -270,7 +276,7 @@ export const runDockerInspectContainerBridgeIp = ( args: [ "inspect", "-f", - '{{with (index .NetworkSettings.Networks "bridge")}}{{.IPAddress}}{{end}}', + "{{with (index .NetworkSettings.Networks \"bridge\")}}{{.IPAddress}}{{end}}", containerName ] }, diff --git a/packages/lib/src/usecases/actions.ts b/packages/lib/src/usecases/actions.ts index 24b172cf..73ba6205 100644 --- a/packages/lib/src/usecases/actions.ts +++ b/packages/lib/src/usecases/actions.ts @@ -168,8 +168,7 @@ const buildProjectConfigs = ( ): ProjectConfigs => { // docker-compose resolves relative host paths from the project directory (where docker-compose.yml lives). // To keep generated projects portable across host OSes, we avoid embedding absolute host paths in templates. - const relativeFromOutDir = (absolutePath: string): string => - toPosixPath(path.relative(resolvedOutDir, absolutePath)) + const relativeFromOutDir = (absolutePath: string): string => toPosixPath(path.relative(resolvedOutDir, absolutePath)) const globalConfig = { ...resolvedConfig, @@ -277,11 +276,15 @@ const runDockerUpIfNeeded = ( ? Effect.void : runDockerNetworkConnectBridge(resolvedOutDir, containerName) ), - Effect.catchAll((error) => - Effect.logWarning( - `Failed to connect ${containerName} to bridge network: ${error instanceof Error ? error.message : String(error)}` - ).pipe(Effect.asVoid) - ) + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning( + `Failed to connect ${containerName} to bridge network: ${ + error instanceof Error ? error.message : String(error) + }` + ), + onSuccess: () => Effect.void + }) ) // Make container ports reachable from other (non-compose) containers by IP. diff --git a/packages/lib/src/usecases/menu-helpers.ts b/packages/lib/src/usecases/menu-helpers.ts index fa588e73..93763e69 100644 --- a/packages/lib/src/usecases/menu-helpers.ts +++ b/packages/lib/src/usecases/menu-helpers.ts @@ -16,7 +16,17 @@ const expandHome = (value: string): string => { return value } -const trimTrailingSlash = (value: string): string => value.replace(/[\\/]+$/, "") +const trimTrailingSlash = (value: string): string => { + let end = value.length + while (end > 0) { + const char = value[end - 1] + if (char !== "/" && char !== "\\") { + break + } + end -= 1 + } + return value.slice(0, end) +} export const defaultProjectsRoot = (cwd: string): string => { const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() diff --git a/packages/lib/src/usecases/path-helpers.ts b/packages/lib/src/usecases/path-helpers.ts index e410e144..5681aecf 100644 --- a/packages/lib/src/usecases/path-helpers.ts +++ b/packages/lib/src/usecases/path-helpers.ts @@ -34,7 +34,17 @@ export const resolvePathFromCwd = ( return value } - const trimTrailingSlash = (value: string): string => value.replace(/[\\/]+$/, "") + const trimTrailingSlash = (value: string): string => { + let end = value.length + while (end > 0) { + const char = value[end - 1] + if (char !== "/" && char !== "\\") { + break + } + end -= 1 + } + return value.slice(0, end) + } const defaultProjectsRoot = (): string => { const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 48064ddc..5a36458c 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -13,8 +13,7 @@ import { defaultProjectsRoot, formatConnectionInfo } from "./menu-helpers.js" import { findSshPrivateKey, resolveAuthorizedKeysPath, resolvePathFromCwd } from "./path-helpers.js" import { withFsPathContext } from "./runtime.js" -const sshOptions = - "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError diff --git a/packages/lib/src/usecases/projects-delete.ts b/packages/lib/src/usecases/projects-delete.ts index 4fa9c187..849e05f5 100644 --- a/packages/lib/src/usecases/projects-delete.ts +++ b/packages/lib/src/usecases/projects-delete.ts @@ -3,14 +3,13 @@ import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" - +import { deriveRepoPathParts } from "../core/domain.js" import { runDockerComposeDown } from "../shell/docker.js" import type { DockerCommandError } from "../shell/errors.js" -import { deriveRepoPathParts } from "../core/domain.js" -import { autoSyncState } from "./state-repo.js" +import { renderError } from "./errors.js" import { defaultProjectsRoot } from "./menu-helpers.js" import type { ProjectItem } from "./projects-core.js" -import { renderError } from "./errors.js" +import { autoSyncState } from "./state-repo.js" const isWithinProjectsRoot = (path: Path.Path, root: string, target: string): boolean => { const relative = path.relative(root, target) @@ -60,8 +59,10 @@ export const deleteDockerGitProject = ( // Best-effort: stop the container if possible before removing the compose dir. yield* _( runDockerComposeDown(targetDir).pipe( - Effect.catchTag("DockerCommandError", (error: DockerCommandError) => - Effect.logWarning(`docker compose down failed before delete: ${renderError(error)}`) + Effect.catchTag( + "DockerCommandError", + (error: DockerCommandError) => + Effect.logWarning(`docker compose down failed before delete: ${renderError(error)}`) ) ) ) @@ -72,4 +73,3 @@ export const deleteDockerGitProject = ( const label = repoParts.length > 0 ? repoParts.join("/") : item.repoUrl yield* _(autoSyncState(`chore(state): delete ${label}`)) }).pipe(Effect.asVoid) - diff --git a/packages/lib/src/usecases/projects-down.ts b/packages/lib/src/usecases/projects-down.ts index 120a0d0a..e999be6c 100644 --- a/packages/lib/src/usecases/projects-down.ts +++ b/packages/lib/src/usecases/projects-down.ts @@ -54,8 +54,7 @@ export const downAllDockerGitProjects: Effect.Effect< Effect.catchTag("DockerCommandError", (error: DockerCommandError) => Effect.logWarning( `docker compose down failed for ${status.projectDir}: ${renderError(error)}` - ) - ) + )) ) ) } @@ -63,4 +62,3 @@ export const downAllDockerGitProjects: Effect.Effect< ), Effect.asVoid ) - diff --git a/packages/lib/src/usecases/projects-list.ts b/packages/lib/src/usecases/projects-list.ts index 2f8b05b2..ce8837e4 100644 --- a/packages/lib/src/usecases/projects-list.ts +++ b/packages/lib/src/usecases/projects-list.ts @@ -149,7 +149,5 @@ export const listRunningProjectItems: Effect.Effect< FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor > = pipe( Effect.all([listProjectItems, runDockerPsNames(process.cwd())]), - Effect.map(([items, runningNames]) => - items.filter((item) => runningNames.includes(item.containerName)) - ) + Effect.map(([items, runningNames]) => items.filter((item) => runningNames.includes(item.containerName))) ) diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 947345c9..5d0b1878 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -2,18 +2,18 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import type { FileSystem as Fs } from "@effect/platform/FileSystem" import type { Path as PathService } from "@effect/platform/Path" -import { Duration, Effect, Schedule, pipe } from "effect" +import { Duration, Effect, pipe, Schedule } from "effect" import { runCommandExitCode, runCommandWithExitCodes } from "../shell/command-runner.js" import { runDockerComposePsFormatted } from "../shell/docker.js" -import type { - ConfigDecodeError, - ConfigNotFoundError, - DockerCommandError, - FileExistsError, - PortProbeError +import { + CommandFailedError, + type ConfigDecodeError, + type ConfigNotFoundError, + type DockerCommandError, + type FileExistsError, + type PortProbeError } from "../shell/errors.js" -import { CommandFailedError } from "../shell/errors.js" import { renderError } from "./errors.js" import { buildSshCommand, diff --git a/packages/lib/src/usecases/projects-up.ts b/packages/lib/src/usecases/projects-up.ts index 247118a6..53cf6a58 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -105,11 +105,15 @@ export const runDockerComposeUpWithPortCheck = ( ? Effect.void : runDockerNetworkConnectBridge(projectDir, containerName) ), - Effect.catchAll((error) => - Effect.logWarning( - `Failed to connect ${containerName} to bridge network: ${error instanceof Error ? error.message : String(error)}` - ).pipe(Effect.asVoid) - ) + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning( + `Failed to connect ${containerName} to bridge network: ${ + error instanceof Error ? error.message : String(error) + }` + ), + onSuccess: () => Effect.void + }) ) yield* _(ensureBridgeAccess(updated.containerName)) diff --git a/packages/lib/src/usecases/projects.ts b/packages/lib/src/usecases/projects.ts index f09ac8f0..0bad9f49 100644 --- a/packages/lib/src/usecases/projects.ts +++ b/packages/lib/src/usecases/projects.ts @@ -7,7 +7,7 @@ export { type ProjectLoadError, type ProjectStatus } from "./projects-core.js" +export { deleteDockerGitProject } from "./projects-delete.js" +export { downAllDockerGitProjects } from "./projects-down.js" export { listProjectItems, listProjects, listProjectSummaries, listRunningProjectItems } from "./projects-list.js" export { connectProjectSsh, connectProjectSshWithUp, listProjectStatus } from "./projects-ssh.js" -export { downAllDockerGitProjects } from "./projects-down.js" -export { deleteDockerGitProject } from "./projects-delete.js" diff --git a/packages/lib/src/usecases/state-normalize.ts b/packages/lib/src/usecases/state-normalize.ts index ceb9dc10..f07bc108 100644 --- a/packages/lib/src/usecases/state-normalize.ts +++ b/packages/lib/src/usecases/state-normalize.ts @@ -30,8 +30,7 @@ const normalizeTemplateConfig = ( projectDir: string, template: TemplateConfig ): TemplateConfig | null => { - const needs = - shouldNormalizePath(path, template.authorizedKeysPath) || + const needs = shouldNormalizePath(path, template.authorizedKeysPath) || shouldNormalizePath(path, template.envGlobalPath) || shouldNormalizePath(path, template.envProjectPath) || shouldNormalizePath(path, template.codexAuthPath) || @@ -54,9 +53,9 @@ const normalizeTemplateConfig = ( return { ...template, authorizedKeysPath: authorizedKeysRel.length > 0 ? authorizedKeysRel : "./authorized_keys", - envGlobalPath: envGlobalPath, - envProjectPath: envProjectPath, - codexAuthPath: codexAuthPath, + envGlobalPath, + envProjectPath, + codexAuthPath, codexSharedAuthPath: codexSharedRel.length > 0 ? codexSharedRel : "./.orch/auth/codex" } } @@ -125,7 +124,10 @@ export const normalizeLegacyStateProjects = ( const projectDir = path.dirname(configPath) const config = yield* _( readProjectConfig(projectDir).pipe( - Effect.catchAll(() => Effect.succeed(null)) + Effect.matchEffect({ + onFailure: () => Effect.succeed(null), + onSuccess: (value) => Effect.succeed(value) + }) ) ) if (config === null) { diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index 723bc445..12df7f45 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -4,12 +4,11 @@ import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" - -import { defaultProjectsRoot } from "./menu-helpers.js" -import { parseEnvEntries } from "./env-file.js" -import { normalizeLegacyStateProjects } from "./state-normalize.js" import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "../shell/command-runner.js" import { CommandFailedError } from "../shell/errors.js" +import { parseEnvEntries } from "./env-file.js" +import { defaultProjectsRoot } from "./menu-helpers.js" +import { normalizeLegacyStateProjects } from "./state-normalize.js" const successExitCode = Number(ExitCode(0)) @@ -117,13 +116,12 @@ const ensureStateGitignore = ( // INVARIANT: never deletes user data; only runs git commands in the state root // COMPLEXITY: O(command) -export const statePath: Effect.Effect = - Effect.gen(function*(_) { - const path = yield* _(Path.Path) - const cwd = process.cwd() - const root = resolveStateRoot(path, cwd) - yield* _(Effect.log(root)) - }).pipe(Effect.asVoid) +export const statePath: Effect.Effect = Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const cwd = process.cwd() + const root = resolveStateRoot(path, cwd) + yield* _(Effect.log(root)) +}).pipe(Effect.asVoid) const git = ( cwd: string, @@ -202,31 +200,41 @@ const sanitizeBranchComponent = (value: string): string => .replaceAll("^", "-") .replaceAll("~", "-") +const githubHttpsRemoteRe = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/ +const githubSshRemoteRe = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/ +const githubSshUrlRemoteRe = /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/ + const tryBuildGithubCompareUrl = ( originUrl: string, baseBranch: string, headBranch: string ): string | null => { const trimmed = originUrl.trim() - const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/) + const httpsMatch = githubHttpsRemoteRe.exec(trimmed) if (httpsMatch) { const owner = httpsMatch[1] ?? "" const repo = httpsMatch[2] ?? "" - return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(headBranch)}?expand=1` + return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${ + encodeURIComponent(headBranch) + }?expand=1` } - const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/) + const sshMatch = githubSshRemoteRe.exec(trimmed) if (sshMatch) { const owner = sshMatch[1] ?? "" const repo = sshMatch[2] ?? "" - return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(headBranch)}?expand=1` + return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${ + encodeURIComponent(headBranch) + }?expand=1` } - const sshUrlMatch = trimmed.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/) + const sshUrlMatch = githubSshUrlRemoteRe.exec(trimmed) if (sshUrlMatch) { const owner = sshUrlMatch[1] ?? "" const repo = sshUrlMatch[2] ?? "" - return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(headBranch)}?expand=1` + return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${ + encodeURIComponent(headBranch) + }?expand=1` } return null @@ -267,7 +275,7 @@ const pushToNewBranch = ( ): Effect.Effect => Effect.gen(function*(_) { const headShort = yield* _( - gitCapture(root, ["rev-parse", "--short", "HEAD"], env).pipe(Effect.map((s) => s.trim())) + gitCapture(root, ["rev-parse", "--short", "HEAD"], env).pipe(Effect.map((value) => value.trim())) ) const timestamp = yield* _(Effect.sync(() => new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"))) const branch = sanitizeBranchComponent(`state-sync/${baseBranch}/${timestamp}-${headShort}`) @@ -307,12 +315,12 @@ const resolveGithubToken = ( const text = yield* _(fs.readFileString(envPath)) const entries = parseEnvEntries(text) - const direct = entries.find((e) => e.key === githubTokenKey)?.value?.trim() ?? "" + const direct = entries.find((e) => e.key === githubTokenKey)?.value.trim() ?? "" if (direct.length > 0) { return direct } - const labeled = entries.find((e) => e.key.startsWith("GITHUB_TOKEN__"))?.value?.trim() ?? "" + const labeled = entries.find((e) => e.key.startsWith("GITHUB_TOKEN__"))?.value.trim() ?? "" return labeled.length > 0 ? labeled : null }) @@ -345,6 +353,67 @@ const withGithubAskpassEnv = ( }) ) +type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + +type GitAuthEnv = Readonly> + +const resolveBaseBranch = (value: string): string => (value === "HEAD" ? "main" : value) + +const getCurrentBranch = ( + root: string, + env: GitAuthEnv +): Effect.Effect => + gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env).pipe(Effect.map((value) => value.trim())) + +const runStateSyncOps = ( + root: string, + originUrl: string, + message: string | null, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(normalizeLegacyStateProjects(root)) + const commitMessage = message && message.trim().length > 0 ? message.trim() : defaultSyncMessage + yield* _(commitAllIfNeeded(root, commitMessage, env)) + + const branch = yield* _(getCurrentBranch(root, env)) + const baseBranch = resolveBaseBranch(branch) + + const rebaseResult = yield* _(rebaseOntoOriginIfPossible(root, baseBranch, env)) + if (rebaseResult === "conflict") { + const prBranch = yield* _(pushToNewBranch(root, baseBranch, env)) + const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) + + yield* _(Effect.logWarning(`State sync needs manual merge: pushed changes to branch '${prBranch}'.`)) + yield* (compareUrl + ? _(Effect.log(`Open PR: ${compareUrl}`)) + : _(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`))) + return + } + + const pushExit = yield* _(gitExitCode(root, ["push", "-u", "origin", "HEAD"], env)) + if (pushExit === successExitCode) { + return + } + + const prBranch = yield* _(pushToNewBranch(root, baseBranch, env)) + const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) + yield* _(Effect.logWarning(`State push failed (exit ${pushExit}); pushed changes to branch '${prBranch}'.`)) + if (compareUrl) { + yield* _(Effect.log(`Open PR: ${compareUrl}`)) + return + } + yield* _(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) + }).pipe(Effect.asVoid) + +const runStateSyncWithToken = ( + token: string, + root: string, + originUrl: string, + message: string | null +): Effect.Effect => + withGithubAskpassEnv(token, (env) => runStateSyncOps(root, originUrl, message, env)) + // CHANGE: sync state repo with remote (commit + pull --rebase + push) // WHY: provide a single command to keep git-synced state up to date across machines // QUOTE(ТЗ): "иметь команд синхронизации с гит версией" @@ -360,7 +429,7 @@ export const stateSync = ( ): Effect.Effect< void, CommandFailedError | PlatformError, - FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + StateRepoEnv > => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) @@ -387,94 +456,15 @@ export const stateSync = ( Effect.fail(new CommandFailedError({ command: "git remote get-url origin", exitCode: originUrlExit })) ) } - const originUrl = yield* _(gitCapture(root, ["remote", "get-url", "origin"], baseEnv).pipe(Effect.map((s) => s.trim()))) + const originUrl = yield* _( + gitCapture(root, ["remote", "get-url", "origin"], baseEnv).pipe(Effect.map((value) => value.trim())) + ) const token = yield* _(resolveGithubToken(fs, path, root)) - const envEffect = - token && token.length > 0 && isGithubHttpsRemote(originUrl) - ? withGithubAskpassEnv( - token, - (env) => - Effect.gen(function*(__) { - yield* __(normalizeLegacyStateProjects(root)) - const commitMessage = message && message.trim().length > 0 ? message.trim() : defaultSyncMessage - yield* __(commitAllIfNeeded(root, commitMessage, env)) - - const branch = yield* __( - gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env).pipe(Effect.map((s) => s.trim())) - ) - const baseBranch = branch === "HEAD" ? "main" : branch - - const rebaseResult = yield* __(rebaseOntoOriginIfPossible(root, baseBranch, env)) - if (rebaseResult === "conflict") { - const prBranch = yield* __(pushToNewBranch(root, baseBranch, env)) - const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) - - yield* __(Effect.logWarning(`State sync needs manual merge: pushed changes to branch '${prBranch}'.`)) - if (compareUrl) { - yield* __(Effect.log(`Open PR: ${compareUrl}`)) - } else { - yield* __(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) - } - return - } - - const pushExit = yield* __(gitExitCode(root, ["push", "-u", "origin", "HEAD"], env)) - if (pushExit === successExitCode) { - return - } - - const prBranch = yield* __(pushToNewBranch(root, baseBranch, env)) - const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) - yield* __( - Effect.logWarning(`State push failed (exit ${pushExit}); pushed changes to branch '${prBranch}'.`) - ) - if (compareUrl) { - yield* __(Effect.log(`Open PR: ${compareUrl}`)) - return - } - yield* __(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) - }) - ) - : Effect.gen(function*(__) { - yield* __(normalizeLegacyStateProjects(root)) - const commitMessage = message && message.trim().length > 0 ? message.trim() : defaultSyncMessage - yield* __(commitAllIfNeeded(root, commitMessage, baseEnv)) - - const branch = yield* __( - gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], baseEnv).pipe(Effect.map((s) => s.trim())) - ) - const baseBranch = branch === "HEAD" ? "main" : branch - - const rebaseResult = yield* __(rebaseOntoOriginIfPossible(root, baseBranch, baseEnv)) - if (rebaseResult === "conflict") { - const prBranch = yield* __(pushToNewBranch(root, baseBranch, baseEnv)) - const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) - - yield* __(Effect.logWarning(`State sync needs manual merge: pushed changes to branch '${prBranch}'.`)) - if (compareUrl) { - yield* __(Effect.log(`Open PR: ${compareUrl}`)) - } else { - yield* __(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) - } - return - } - - const pushExit = yield* __(gitExitCode(root, ["push", "-u", "origin", "HEAD"], baseEnv)) - if (pushExit === successExitCode) { - return - } - - const prBranch = yield* __(pushToNewBranch(root, baseBranch, baseEnv)) - const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) - yield* __(Effect.logWarning(`State push failed (exit ${pushExit}); pushed changes to branch '${prBranch}'.`)) - if (compareUrl) { - yield* __(Effect.log(`Open PR: ${compareUrl}`)) - return - } - yield* __(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) - }) + const syncEffect = token && token.length > 0 && isGithubHttpsRemote(originUrl) + ? runStateSyncWithToken(token, root, originUrl, message) + : runStateSyncOps(root, originUrl, message, baseEnv) - yield* _(envEffect) + yield* _(syncEffect) }).pipe(Effect.asVoid) const isAutoSyncEnabled = ( @@ -534,17 +524,24 @@ export const autoSyncState = ( } yield* _( effect.pipe( - Effect.catchAll((error) => - Effect.logWarning( - `State auto-sync failed: ${error._tag === "CommandFailedError" - ? `${error.command} (exit ${error.exitCode})` - : String(error)}` - ) - ) + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning( + `State auto-sync failed: ${ + error._tag === "CommandFailedError" + ? `${error.command} (exit ${error.exitCode})` + : String(error) + }` + ), + onSuccess: () => Effect.void + }) ) ) }).pipe( - Effect.catchAll((error) => Effect.logWarning(`State auto-sync failed: ${String(error)}`)), + Effect.matchEffect({ + onFailure: (error) => Effect.logWarning(`State auto-sync failed: ${String(error)}`), + onSuccess: () => Effect.void + }), Effect.asVoid ) @@ -553,7 +550,11 @@ export const stateInit = ( readonly repoUrl: string readonly repoRef: string } -): Effect.Effect => +): Effect.Effect< + void, + CommandFailedError | PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) @@ -625,12 +626,13 @@ export const statePull = Effect.gen(function*(_) { yield* _(git(root, ["pull", "--rebase"], gitBaseEnv)) return } - const originUrl = yield* _(gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((s) => s.trim()))) + const originUrl = yield* _( + gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim())) + ) const token = yield* _(resolveGithubToken(fs, path, root)) - const effect = - token && token.length > 0 && isGithubHttpsRemote(originUrl) - ? withGithubAskpassEnv(token, (env) => git(root, ["pull", "--rebase"], env)) - : git(root, ["pull", "--rebase"], gitBaseEnv) + const effect = token && token.length > 0 && isGithubHttpsRemote(originUrl) + ? withGithubAskpassEnv(token, (env) => git(root, ["pull", "--rebase"], env)) + : git(root, ["pull", "--rebase"], gitBaseEnv) yield* _(effect) }).pipe(Effect.asVoid) @@ -643,12 +645,13 @@ export const statePush = Effect.gen(function*(_) { yield* _(git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv)) return } - const originUrl = yield* _(gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((s) => s.trim()))) + const originUrl = yield* _( + gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim())) + ) const token = yield* _(resolveGithubToken(fs, path, root)) - const effect = - token && token.length > 0 && isGithubHttpsRemote(originUrl) - ? withGithubAskpassEnv(token, (env) => git(root, ["push", "-u", "origin", "HEAD"], env)) - : git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv) + const effect = token && token.length > 0 && isGithubHttpsRemote(originUrl) + ? withGithubAskpassEnv(token, (env) => git(root, ["push", "-u", "origin", "HEAD"], env)) + : git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv) yield* _(effect) }).pipe(Effect.asVoid) From d2d98f3e97c44723a29ffef910552498855febb2 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 10 Feb 2026 22:07:30 +0000 Subject: [PATCH 5/6] fix(lib): pass vibecode-linter --- packages/lib/.jscpd.json | 4 +- packages/lib/src/core/templates-entrypoint.ts | 607 +---------------- .../lib/src/core/templates-entrypoint/base.ts | 109 ++++ .../src/core/templates-entrypoint/codex.ts | 172 +++++ .../lib/src/core/templates-entrypoint/git.ts | 63 ++ .../src/core/templates-entrypoint/tasks.ts | 109 ++++ packages/lib/src/core/templates-prompt.ts | 5 +- packages/lib/src/core/templates.ts | 278 +------- .../lib/src/core/templates/docker-compose.ts | 56 ++ packages/lib/src/core/templates/dockerfile.ts | 154 +++++ packages/lib/src/core/templates/playwright.ts | 36 ++ packages/lib/src/usecases/actions.ts | 499 +------------- .../src/usecases/actions/create-project.ts | 101 +++ .../lib/src/usecases/actions/docker-up.ts | 157 +++++ packages/lib/src/usecases/actions/paths.ts | 66 ++ packages/lib/src/usecases/actions/ports.ts | 35 + .../lib/src/usecases/actions/prepare-files.ts | 145 +++++ .../src/usecases/docker-git-config-search.ts | 67 ++ packages/lib/src/usecases/menu-helpers.ts | 44 +- packages/lib/src/usecases/path-helpers.ts | 96 +-- packages/lib/src/usecases/projects-core.ts | 59 +- packages/lib/src/usecases/projects-down.ts | 31 +- packages/lib/src/usecases/projects-ssh.ts | 31 +- packages/lib/src/usecases/state-normalize.ts | 43 +- packages/lib/src/usecases/state-repo.ts | 609 +++--------------- packages/lib/src/usecases/state-repo/env.ts | 31 + .../src/usecases/state-repo/git-commands.ts | 48 ++ .../src/usecases/state-repo/github-auth.ts | 143 ++++ .../lib/src/usecases/state-repo/gitignore.ts | 75 +++ .../lib/src/usecases/state-repo/sync-ops.ts | 139 ++++ 30 files changed, 1934 insertions(+), 2078 deletions(-) create mode 100644 packages/lib/src/core/templates-entrypoint/base.ts create mode 100644 packages/lib/src/core/templates-entrypoint/codex.ts create mode 100644 packages/lib/src/core/templates-entrypoint/git.ts create mode 100644 packages/lib/src/core/templates-entrypoint/tasks.ts create mode 100644 packages/lib/src/core/templates/docker-compose.ts create mode 100644 packages/lib/src/core/templates/dockerfile.ts create mode 100644 packages/lib/src/core/templates/playwright.ts create mode 100644 packages/lib/src/usecases/actions/create-project.ts create mode 100644 packages/lib/src/usecases/actions/docker-up.ts create mode 100644 packages/lib/src/usecases/actions/paths.ts create mode 100644 packages/lib/src/usecases/actions/ports.ts create mode 100644 packages/lib/src/usecases/actions/prepare-files.ts create mode 100644 packages/lib/src/usecases/docker-git-config-search.ts create mode 100644 packages/lib/src/usecases/state-repo/env.ts create mode 100644 packages/lib/src/usecases/state-repo/git-commands.ts create mode 100644 packages/lib/src/usecases/state-repo/github-auth.ts create mode 100644 packages/lib/src/usecases/state-repo/gitignore.ts create mode 100644 packages/lib/src/usecases/state-repo/sync-ops.ts diff --git a/packages/lib/.jscpd.json b/packages/lib/.jscpd.json index dbb0615e..e675ea0d 100644 --- a/packages/lib/.jscpd.json +++ b/packages/lib/.jscpd.json @@ -1,7 +1,7 @@ { "threshold": 0, - "minTokens": 30, - "minLines": 5, + "minTokens": 50, + "minLines": 15, "ignore": [ "**/node_modules/**", "**/build/**", diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index a4072259..96d1de5d 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -1,596 +1,31 @@ import type { TemplateConfig } from "./domain.js" +import { + renderEntrypointAuthorizedKeys, + renderEntrypointBaseline, + renderEntrypointDisableMotd, + renderEntrypointDockerSocket, + renderEntrypointHeader, + renderEntrypointInputRc, + renderEntrypointSshd, + renderEntrypointZshShell, + renderEntrypointZshUserRc +} from "./templates-entrypoint/base.js" +import { + renderEntrypointAgentsNotice, + renderEntrypointCodexHome, + renderEntrypointCodexResumeHint, + renderEntrypointCodexSharedAuth, + renderEntrypointMcpPlaywright +} from "./templates-entrypoint/codex.js" +import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" +import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js" import { renderEntrypointBashCompletion, renderEntrypointBashHistory, renderEntrypointPrompt, - renderEntrypointZshConfig, - renderInputRc + renderEntrypointZshConfig } from "./templates-prompt.js" -// CHANGE: ensure target dir ownership and git identity in entrypoint -// WHY: allow cloning into root-level workspaces + auto-config git for commits -// QUOTE(ТЗ): "Клонирует он в \"/\"" | "git config should be set automatically" -// REF: user-request-2026-01-27 -// SOURCE: n/a -// FORMAT THEOREM: forall env: name/email set -> gitconfig set for user -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: emits a deterministic entrypoint script -// COMPLEXITY: O(1) -const renderEntrypointHeader = (config: TemplateConfig): string => - `#!/usr/bin/env bash -set -euo pipefail - -REPO_URL="\${REPO_URL:-}" -REPO_REF="\${REPO_REF:-}" -FORK_REPO_URL="\${FORK_REPO_URL:-}" -TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}" -GIT_AUTH_USER="\${GIT_AUTH_USER:-\${GITHUB_USER:-x-access-token}}" -GIT_AUTH_TOKEN="\${GIT_AUTH_TOKEN:-\${GITHUB_TOKEN:-}}" -GH_TOKEN="\${GH_TOKEN:-\${GIT_AUTH_TOKEN:-}}" -GIT_USER_NAME="\${GIT_USER_NAME:-}" -GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}" -CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}" -MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" -MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" -MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}"` - -const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string => - `# 1) Authorized keys are mounted from host at /authorized_keys -mkdir -p /home/${config.sshUser}/.ssh -chmod 700 /home/${config.sshUser}/.ssh - -if [[ -f /authorized_keys ]]; then - cp /authorized_keys /home/${config.sshUser}/.ssh/authorized_keys - chmod 600 /home/${config.sshUser}/.ssh/authorized_keys -fi - -chown -R 1000:1000 /home/${config.sshUser}/.ssh` - -const renderEntrypointCodexHome = (config: TemplateConfig): string => - `# Ensure Codex home exists if mounted -mkdir -p ${config.codexHome} -chown -R 1000:1000 ${config.codexHome} - -# Ensure home ownership matches the dev UID/GID (volumes may be stale) -HOME_OWNER="$(stat -c "%u:%g" /home/${config.sshUser} 2>/dev/null || echo "")" -if [[ "$HOME_OWNER" != "1000:1000" ]]; then - chown -R 1000:1000 /home/${config.sshUser} || true -fi` - -// CHANGE: share Codex credentials across projects while keeping per-project sessions -// WHY: ChatGPT refresh tokens are rotating; copying auth.json into each project causes stale refresh tokens -// QUOTE(ТЗ): "везде в контейнерах хотим использовать наши креды из .docker-git" | "каждый проект использовал бы свою папку .orch" -// REF: user-request-2026-02-09-orch-per-project-codex-shared-auth -// SOURCE: n/a -// FORMAT THEOREM: ∀p: start(p) → codex_auth(p)=shared ∧ codex_state(p)=local -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: CODEX_HOME/auth.json is a symlink into CODEX_HOME-shared/auth.json when enabled -// COMPLEXITY: O(1) -const renderEntrypointCodexSharedAuth = (config: TemplateConfig): string => - `# Share Codex auth.json across projects (avoids refresh_token_reused) -CODEX_SHARE_AUTH="\${CODEX_SHARE_AUTH:-1}" -if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then - CODEX_SHARED_HOME="${config.codexHome}-shared" - mkdir -p "$CODEX_SHARED_HOME" - chown -R 1000:1000 "$CODEX_SHARED_HOME" || true - - AUTH_FILE="${config.codexHome}/auth.json" - SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" - - # Guard against a bad bind mount creating a directory at auth.json. - if [[ -d "$AUTH_FILE" ]]; then - mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true - fi - if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then - rm -f "$AUTH_FILE" || true - fi - - ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" -fi` - -// CHANGE: configure Playwright MCP inside Codex when enabled -// WHY: allow browser automation in containers via an MCP server connected to Chromium (CDP) -// QUOTE(ТЗ): "подключить mcp playright ... нужен хром браузер" | "подключать доп контейнеры с хромом" -// REF: user-request-2026-02-10-mcp-playwright -// SOURCE: n/a -// FORMAT THEOREM: ∀c: MCP_ENABLE(c) → ∃srv: mcp(playwright,srv) ∧ cdp(srv)=endpoint(c) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: config.toml is only appended once per container (idempotent) -// COMPLEXITY: O(1) -const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => - String.raw`# Optional: configure Playwright MCP for Codex (browser automation) -CODEX_CONFIG_FILE="${config.codexHome}/config.toml" - -# Keep config.toml consistent with the container build. -# If Playwright MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn docker-git-playwright-mcp. -if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then - if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then - awk ' - BEGIN { skip=0 } - /^# docker-git: Playwright MCP/ { next } - /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } - skip==1 && /^\[/ { skip=0 } - skip==0 { print } - ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" - mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" - fi -else - if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then - mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true - cat <<'EOF' > "$CODEX_CONFIG_FILE" -# docker-git codex config -model = "gpt-5.3-codex" -model_reasoning_effort = "xhigh" -personality = "pragmatic" - -approval_policy = "never" -sandbox_mode = "danger-full-access" -web_search = "live" - -[features] -web_search_request = true -shell_snapshot = true -collab = true -apps = true -shell_tool = true -EOF - chown 1000:1000 "$CODEX_CONFIG_FILE" || true - fi - - if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then - MCP_PLAYWRIGHT_CDP_ENDPOINT="http://${config.serviceName}-browser:9223" - fi - - # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. - if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then - awk ' - BEGIN { skip=0 } - /^# docker-git: Playwright MCP/ { next } - /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } - skip==1 && /^\[/ { skip=0 } - skip==0 { print } - ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" - mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" - fi - - cat <> "$CODEX_CONFIG_FILE" - -# docker-git: Playwright MCP (connects to Chromium via CDP) -[mcp_servers.playwright] -command = "docker-git-playwright-mcp" -args = [] -EOF -fi` - -// CHANGE: ensure readline config exists for history search and completion -// WHY: provide prefix history search and predictable completion UX -// QUOTE(ТЗ): "когда я напишу cd он мне предложит" -// REF: user-request-2026-02-05-inputrc -// SOURCE: n/a -// FORMAT THEOREM: forall s in InteractiveShells: inputrc(s) -> history_search(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: does not overwrite existing ~/.inputrc -// COMPLEXITY: O(1) -const renderEntrypointInputRc = (config: TemplateConfig): string => - String.raw`# Ensure readline history search bindings for ${config.sshUser} -INPUTRC_PATH="/home/${config.sshUser}/.inputrc" -if [[ ! -f "$INPUTRC_PATH" ]]; then - cat <<'EOF' > "$INPUTRC_PATH" -${renderInputRc()} -EOF - chown 1000:1000 "$INPUTRC_PATH" || true -fi` - -// CHANGE: show codex resume hint on interactive shells -// WHY: remind users how to resume older Codex sessions after SSH login -// QUOTE(ТЗ): "Старые сесси можно запустить с помощью codex resume или codex resume id если знаю айди" -// REF: user-request-2026-02-06-codex-resume-hint -// SOURCE: n/a -// FORMAT THEOREM: ∀s ∈ InteractiveShells: hint(s) → visible(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: hint prints at most once per shell session -// COMPLEXITY: O(1) -const renderEntrypointCodexResumeHint = (): string => - `# Ensure codex resume hint is shown for interactive shells -CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" -if [[ ! -s "$CODEX_HINT_PATH" ]]; then - cat <<'EOF' > "$CODEX_HINT_PATH" -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi - ;; - esac -fi -if [ -n "$ZSH_VERSION" ]; then - if [[ "$-" == *i* ]]; then - if [[ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi - fi -fi -EOF - chmod 0644 "$CODEX_HINT_PATH" -fi -if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc -fi -if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then - printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc -fi` - -// CHANGE: ensure the ssh user defaults to zsh when available -// WHY: enable autosuggestions and zsh prompt for interactive sessions -// QUOTE(ТЗ): "пусть будет zzh если он сделате то что я хочу" -// REF: user-request-2026-02-05-zsh-autosuggest -// SOURCE: n/a -// FORMAT THEOREM: ∀u: zsh(u) -> shell(u)=zsh -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: only changes shell if zsh exists -// COMPLEXITY: O(1) -const renderEntrypointZshShell = (config: TemplateConfig): string => - String.raw`# Prefer zsh for ${config.sshUser} when available -if command -v zsh >/dev/null 2>&1; then - usermod -s /usr/bin/zsh ${config.sshUser} || true -fi` - -// CHANGE: prevent zsh new-user wizard and ensure user zshrc exists -// WHY: avoid interactive zsh-newuser-install prompt on SSH login -// QUOTE(ТЗ): "Что за дичь меня встречает при подключение через SSH?" -// REF: user-request-2026-02-05-zsh-newuser -// SOURCE: n/a -// FORMAT THEOREM: ∀u: zsh(u) → exists(u/.zshrc) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: does not overwrite existing ~/.zshrc -// COMPLEXITY: O(1) -const renderEntrypointZshUserRc = (config: TemplateConfig): string => - String.raw`# Ensure ${config.sshUser} has a zshrc and disable newuser wizard -ZSHENV_PATH="/etc/zsh/zshenv" -if [[ -f "$ZSHENV_PATH" ]]; then - if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" - fi -else - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" -fi -USER_ZSHRC="/home/${config.sshUser}/.zshrc" -if [[ ! -f "$USER_ZSHRC" ]]; then - cat <<'EOF' > "$USER_ZSHRC" -# docker-git default zshrc -if [ -f /etc/zsh/zshrc ]; then - source /etc/zsh/zshrc -fi -EOF - chown 1000:1000 "$USER_ZSHRC" || true -fi` - -// CHANGE: create a global AGENTS.md under the Codex home -// WHY: ensure agents read instructions from ~/.codex/AGENTS.md -// QUOTE(ТЗ): "он должен лежать в ~/.codex/" -// REF: user-request-2026-02-05-codex-agents-path -// SOURCE: n/a -// FORMAT THEOREM: ∀c: start(c) → exists(${config.codexHome}/AGENTS.md) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: does not overwrite AGENTS.md if user already created it -// COMPLEXITY: O(1) -const renderEntrypointAgentsNotice = (config: TemplateConfig): string => - String.raw`# Ensure global AGENTS.md exists for container context -AGENTS_PATH="${config.codexHome}/AGENTS.md" -LEGACY_AGENTS_PATH="/home/${config.sshUser}/AGENTS.md" -PROJECT_LINE="Рабочая папка проекта (git clone): ${config.targetDir}" -INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." -if [[ ! -f "$AGENTS_PATH" ]]; then - cat <<'AGENTS_EOF' > "$AGENTS_PATH" -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -Рабочая папка проекта (git clone): ${config.targetDir} -Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. -Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. -AGENTS_EOF - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$AGENTS_PATH" ]]; then - if grep -q "^Рабочая папка проекта (git clone):" "$AGENTS_PATH"; then - sed -i "s|^Рабочая папка проекта (git clone):.*$|$PROJECT_LINE|" "$AGENTS_PATH" - else - printf "%s\n" "$PROJECT_LINE" >> "$AGENTS_PATH" - fi - if grep -q "^Доступ к интернету:" "$AGENTS_PATH"; then - sed -i "s|^Доступ к интернету:.*$|$INTERNET_LINE|" "$AGENTS_PATH" - else - printf "%s\n" "$INTERNET_LINE" >> "$AGENTS_PATH" - fi -fi -if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then - LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 ":" $2}')" - CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 ":" $2}')" - if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then - rm -f "$LEGACY_AGENTS_PATH" - fi -fi` - -const renderEntrypointDockerSocket = (config: TemplateConfig): string => - `# Ensure docker socket access for ${config.sshUser} -if [[ -S /var/run/docker.sock ]]; then - DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" - DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" - if [[ -z "$DOCKER_GROUP" ]]; then - DOCKER_GROUP="docker" - groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true - fi - usermod -aG "$DOCKER_GROUP" ${config.sshUser} || true - printf "export DOCKER_HOST=unix:///var/run/docker.sock\n" > /etc/profile.d/docker-host.sh -fi` - -const renderEntrypointAutoUpdate = (): string => - `# 1) Keep Codex CLI up to date if requested (bun only) -if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then - if command -v bun >/dev/null 2>&1; then - echo "[codex] updating via bun..." - script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true - else - echo "[codex] bun not found, skipping auto-update" - fi -fi` - -const renderClonePreamble = (): string => - `# 2) Auto-clone repo if not already present -mkdir -p /run/docker-git -CLONE_DONE_PATH="/run/docker-git/clone.done" -CLONE_FAIL_PATH="/run/docker-git/clone.failed" -rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" - -CLONE_OK=1` - -// CHANGE: configure fork/upstream remotes after clone -// WHY: allow auto-fork to become the default push target -// QUOTE(ТЗ): "Сразу на issues и он бы делал форк репы если это надо" -// REF: user-request-2026-02-05-issues-fork -// SOURCE: n/a -// FORMAT THEOREM: ∀r: fork(r) → origin=fork ∧ upstream=repo -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: only runs when clone succeeded -// COMPLEXITY: O(1) -const renderCloneRemotes = (config: TemplateConfig): string => - `if [[ "$CLONE_OK" -eq 1 && -n "$FORK_REPO_URL" && -d "$TARGET_DIR/.git" ]]; then - AUTH_FORK_URL="$FORK_REPO_URL" - if [[ -n "$GIT_AUTH_TOKEN" && "$FORK_REPO_URL" == https://* ]]; then - AUTH_FORK_URL="$(printf "%s" "$FORK_REPO_URL" | sed "s#^https://#https://\${GIT_AUTH_USER}:\${GIT_AUTH_TOKEN}@#")" - fi - if [[ "$FORK_REPO_URL" != "$REPO_URL" ]]; then - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$AUTH_FORK_URL'" || true - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote add upstream '$AUTH_REPO_URL' 2>/dev/null || git remote set-url upstream '$AUTH_REPO_URL'" || true - fi -fi` - -const renderCloneBodyStart = (config: TemplateConfig): string => - `if [[ -z "$REPO_URL" ]]; then - echo "[clone] skip (no repo url)" -elif [[ -d "$TARGET_DIR/.git" ]]; then - echo "[clone] skip (already cloned)" -else - mkdir -p "$TARGET_DIR" - if [[ "$TARGET_DIR" != "/" ]]; then - chown -R 1000:1000 "$TARGET_DIR" - fi - chown -R 1000:1000 /home/${config.sshUser} - - AUTH_REPO_URL="$REPO_URL" - if [[ -n "$GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then - AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${GIT_AUTH_USER}:\${GIT_AUTH_TOKEN}@#")" - fi` - -// CHANGE: fallback to the remote default branch when requested branch is missing -// WHY: allow cloning repos whose default branch is not "main" -// QUOTE(ТЗ): "fatal: Remote branch main not found in upstream origin" -// REF: user-request-2026-02-05-default-branch -// SOURCE: n/a -// FORMAT THEOREM: ∀r: missing(ref) → clone(default_ref) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: fallback only runs after a failed branch clone -// COMPLEXITY: O(1) -const renderCloneBodyRef = (config: TemplateConfig): string => - ` if [[ -n "$REPO_REF" ]]; then - if [[ "$REPO_REF" == refs/pull/* ]]; then - REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - else - if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then - echo "[clone] git fetch failed for $REPO_REF" - CLONE_OK=0 - fi - fi - else - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)" - DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')" - if [[ -n "$DEFAULT_BRANCH" ]]; then - echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'" - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - else - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi - fi - else - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi` - -const renderCloneBody = (config: TemplateConfig): string => - [renderCloneBodyStart(config), renderCloneBodyRef(config), "", renderCloneRemotes(config), "fi"].join("\n") - -const renderCloneFinalize = (): string => - `if [[ "$CLONE_OK" -eq 1 ]]; then - echo "[clone] done" - touch "$CLONE_DONE_PATH" -else - echo "[clone] failed" - touch "$CLONE_FAIL_PATH" -fi` - -const renderEntrypointClone = (config: TemplateConfig): string => - [renderClonePreamble(), renderCloneBody(config), renderCloneFinalize()].join("\n\n") - -// CHANGE: propagate GitHub tokens into SSH sessions for gh/git usage -// WHY: ensure gh and git can authenticate using configured tokens -// QUOTE(ТЗ): "git, gh должны получать наши ключи которые у нас заданы" -// REF: user-request-2026-02-05-gh-auth-env -// SOURCE: n/a -// FORMAT THEOREM: ∀t: token(t) → env(GH_TOKEN)=t -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: does not write env files when token is empty -// COMPLEXITY: O(1) -const renderEntrypointGitConfig = (config: TemplateConfig): string => - String.raw`# 2) Ensure GH_TOKEN is available for SSH sessions if provided -if [[ -n "$GH_TOKEN" ]]; then - printf "export GH_TOKEN=%q\n" "$GH_TOKEN" > /etc/profile.d/gh-token.sh - chmod 0644 /etc/profile.d/gh-token.sh - SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" - printf "%s\n" "GH_TOKEN=$GH_TOKEN" > "$SSH_ENV_PATH" - if [[ -n "$GITHUB_TOKEN" ]]; then - printf "%s\n" "GITHUB_TOKEN=$GITHUB_TOKEN" >> "$SSH_ENV_PATH" - fi - chmod 600 "$SSH_ENV_PATH" - chown 1000:1000 "$SSH_ENV_PATH" || true -fi - -# 3) Configure git identity for the dev user if provided -if [[ -n "$GIT_USER_NAME" ]]; then - SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" - su - ${config.sshUser} -c "git config --global user.name $SAFE_GIT_USER_NAME" -fi - -if [[ -n "$GIT_USER_EMAIL" ]]; then - SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" - su - ${config.sshUser} -c "git config --global user.email $SAFE_GIT_USER_EMAIL" -fi` - -// CHANGE: enforce protected branches via global git hooks in container -// WHY: prevent AI from pushing to main/master or deleting branches -// QUOTE(ТЗ): "Пусть создаёт ветки" -// REF: user-request-2026-02-05-git-hooks -// SOURCE: n/a -// FORMAT THEOREM: ∀p: push(p) ∧ protected(p) → reject(p) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: hook is installed once and is executable -// COMPLEXITY: O(1) -const renderEntrypointGitHooks = (): string => - String.raw`# 3) Install global git hooks to protect main/master -HOOKS_DIR="/opt/docker-git/hooks" -PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" -mkdir -p "$HOOKS_DIR" -if [[ ! -f "$PRE_PUSH_HOOK" ]]; then - cat <<'EOF' > "$PRE_PUSH_HOOK" -#!/usr/bin/env bash -set -euo pipefail - -protected_branches=("refs/heads/main" "refs/heads/master") -allow_delete="${"${"}DOCKER_GIT_ALLOW_DELETE:-}" - -while read -r local_ref local_sha remote_ref remote_sha; do - if [[ -z "$remote_ref" ]]; then - continue - fi - for protected in "${"${"}protected_branches[@]}"; do - if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then - echo "docker-git: push to protected branch '${"${"}protected##*/}' is disabled." - echo "docker-git: create a new branch: git checkout -b " - exit 1 - fi - done - if [[ "$local_sha" == "0000000000000000000000000000000000000000" && "$remote_ref" == refs/heads/* ]]; then - if [[ "$allow_delete" != "1" ]]; then - echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." - exit 1 - fi - fi -done -EOF - chmod 0755 "$PRE_PUSH_HOOK" -fi -git config --system core.hooksPath "$HOOKS_DIR" || true -git config --global core.hooksPath "$HOOKS_DIR" || true` - -const renderEntrypointBackgroundTasks = (config: TemplateConfig): string => - `# 4) Start background tasks so SSH can come up immediately -( -${renderEntrypointAutoUpdate()} - -${renderEntrypointClone(config)} -) &` - -// CHANGE: snapshot baseline processes for terminal session filtering -// WHY: allow "sessions list" to hide default processes by default -// QUOTE(ТЗ): "Можно ли запомнить какие процессы изначально запущены и просто их не отображать как терминалы?" -// REF: user-request-2026-02-05-sessions-baseline -// SOURCE: n/a -// FORMAT THEOREM: ∀p: baseline(p) → stored(p) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: baseline path is stable across container restarts -// COMPLEXITY: O(n) where n = number of processes -const renderEntrypointBaseline = (): string => - `# 4.5) Snapshot baseline processes for terminal session filtering -mkdir -p /run/docker-git -BASELINE_PATH="/run/docker-git/terminal-baseline.pids" -if [[ ! -f "$BASELINE_PATH" ]]; then - ps -eo pid= > "$BASELINE_PATH" || true -fi` - -// CHANGE: disable noisy Ubuntu MOTD for SSH logins -// WHY: keep SSH login clean, docker-git shows its own UX hints -// QUOTE(ТЗ): "Нашей информации не вижу ЗА то вижу кучу мусора" -// REF: user-request-2026-02-06-disable-motd -// SOURCE: n/a -// FORMAT THEOREM: ∀login: motd_disabled(login) → ¬ubuntu_banner(login) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: edits /etc/pam.d/sshd idempotently (comments only) -// COMPLEXITY: O(n) where n = number of pam lines -const renderEntrypointDisableMotd = (): string => - String.raw`# 4.75) Disable Ubuntu MOTD noise for SSH sessions -PAM_SSHD="/etc/pam.d/sshd" -if [[ -f "$PAM_SSHD" ]]; then - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true -fi - -# Also disable sshd's own banners (e.g. "Last login") -mkdir -p /etc/ssh/sshd_config.d || true -DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" -cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" -PrintMotd no -PrintLastLog no -EOF -chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true` - -const renderEntrypointSshd = (): string => `# 5) Run sshd in foreground\nexec /usr/sbin/sshd -D` - export const renderEntrypoint = (config: TemplateConfig): string => [ renderEntrypointHeader(config), diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts new file mode 100644 index 00000000..22a815e9 --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -0,0 +1,109 @@ +import type { TemplateConfig } from "../domain.js" +import { renderInputRc } from "../templates-prompt.js" + +export const renderEntrypointHeader = (config: TemplateConfig): string => + `#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="\${REPO_URL:-}" +REPO_REF="\${REPO_REF:-}" +FORK_REPO_URL="\${FORK_REPO_URL:-}" +TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}" +GIT_AUTH_USER="\${GIT_AUTH_USER:-\${GITHUB_USER:-x-access-token}}" +GIT_AUTH_TOKEN="\${GIT_AUTH_TOKEN:-\${GITHUB_TOKEN:-}}" +GH_TOKEN="\${GH_TOKEN:-\${GIT_AUTH_TOKEN:-}}" +GIT_USER_NAME="\${GIT_USER_NAME:-}" +GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}" +CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}" +MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" +MCP_PLAYWRIGHT_CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" +MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-1}"` + +export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string => + `# 1) Authorized keys are mounted from host at /authorized_keys +mkdir -p /home/${config.sshUser}/.ssh +chmod 700 /home/${config.sshUser}/.ssh + +if [[ -f /authorized_keys ]]; then + cp /authorized_keys /home/${config.sshUser}/.ssh/authorized_keys + chmod 600 /home/${config.sshUser}/.ssh/authorized_keys +fi + +chown -R 1000:1000 /home/${config.sshUser}/.ssh` + +export const renderEntrypointDockerSocket = (config: TemplateConfig): string => + `# Ensure docker socket access for ${config.sshUser} +if [[ -S /var/run/docker.sock ]]; then + DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" + DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" + if [[ -z "$DOCKER_GROUP" ]]; then + DOCKER_GROUP="docker" + groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true + fi + usermod -aG "$DOCKER_GROUP" ${config.sshUser} || true + printf "export DOCKER_HOST=unix:///var/run/docker.sock\n" > /etc/profile.d/docker-host.sh +fi` + +export const renderEntrypointZshShell = (config: TemplateConfig): string => + String.raw`# Prefer zsh for ${config.sshUser} when available +if command -v zsh >/dev/null 2>&1; then + usermod -s /usr/bin/zsh ${config.sshUser} || true +fi` + +export const renderEntrypointZshUserRc = (config: TemplateConfig): string => + String.raw`# Ensure ${config.sshUser} has a zshrc and disable newuser wizard +ZSHENV_PATH="/etc/zsh/zshenv" +if [[ -f "$ZSHENV_PATH" ]]; then + if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" + fi +else + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" +fi +USER_ZSHRC="/home/${config.sshUser}/.zshrc" +if [[ ! -f "$USER_ZSHRC" ]]; then + cat <<'EOF' > "$USER_ZSHRC" +# docker-git default zshrc +if [ -f /etc/zsh/zshrc ]; then + source /etc/zsh/zshrc +fi +EOF + chown 1000:1000 "$USER_ZSHRC" || true +fi` + +export const renderEntrypointInputRc = (config: TemplateConfig): string => + String.raw`# Ensure readline history search bindings for ${config.sshUser} +INPUTRC_PATH="/home/${config.sshUser}/.inputrc" +if [[ ! -f "$INPUTRC_PATH" ]]; then + cat <<'EOF' > "$INPUTRC_PATH" +${renderInputRc()} +EOF + chown 1000:1000 "$INPUTRC_PATH" || true +fi` + +export const renderEntrypointBaseline = (): string => + `# 4.5) Snapshot baseline processes for terminal session filtering +mkdir -p /run/docker-git +BASELINE_PATH="/run/docker-git/terminal-baseline.pids" +if [[ ! -f "$BASELINE_PATH" ]]; then + ps -eo pid= > "$BASELINE_PATH" || true +fi` + +export const renderEntrypointDisableMotd = (): string => + String.raw`# 4.75) Disable Ubuntu MOTD noise for SSH sessions +PAM_SSHD="/etc/pam.d/sshd" +if [[ -f "$PAM_SSHD" ]]; then + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true +fi + +# Also disable sshd's own banners (e.g. "Last login") +mkdir -p /etc/ssh/sshd_config.d || true +DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" +cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" +PrintMotd no +PrintLastLog no +EOF +chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true` + +export const renderEntrypointSshd = (): string => `# 5) Run sshd in foreground\nexec /usr/sbin/sshd -D` diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts new file mode 100644 index 00000000..fc68dabc --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -0,0 +1,172 @@ +import type { TemplateConfig } from "../domain.js" + +export const renderEntrypointCodexHome = (config: TemplateConfig): string => + `# Ensure Codex home exists if mounted +mkdir -p ${config.codexHome} +chown -R 1000:1000 ${config.codexHome} + +# Ensure home ownership matches the dev UID/GID (volumes may be stale) +HOME_OWNER="$(stat -c "%u:%g" /home/${config.sshUser} 2>/dev/null || echo "")" +if [[ "$HOME_OWNER" != "1000:1000" ]]; then + chown -R 1000:1000 /home/${config.sshUser} || true +fi` + +export const renderEntrypointCodexSharedAuth = (config: TemplateConfig): string => + `# Share Codex auth.json across projects (avoids refresh_token_reused) +CODEX_SHARE_AUTH="\${CODEX_SHARE_AUTH:-1}" +if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then + CODEX_SHARED_HOME="${config.codexHome}-shared" + mkdir -p "$CODEX_SHARED_HOME" + chown -R 1000:1000 "$CODEX_SHARED_HOME" || true + + AUTH_FILE="${config.codexHome}/auth.json" + SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" + + # Guard against a bad bind mount creating a directory at auth.json. + if [[ -d "$AUTH_FILE" ]]; then + mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true + fi + if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then + rm -f "$AUTH_FILE" || true + fi + + ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" +fi` + +const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Playwright MCP for Codex (browser automation) +CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" + +# Keep config.toml consistent with the container build. +# If Playwright MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn docker-git-playwright-mcp. +if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.3-codex" +model_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +web_search_request = true +shell_snapshot = true +collab = true +apps = true +shell_tool = true +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then + MCP_PLAYWRIGHT_CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223" + fi + + # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Playwright MCP (connects to Chromium via CDP) +[mcp_servers.playwright] +command = "docker-git-playwright-mcp" +args = [] +EOF +fi` + +export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => + entrypointMcpPlaywrightTemplate + .replaceAll("__CODEX_HOME__", config.codexHome) + .replaceAll("__SERVICE_NAME__", config.serviceName) + +export const renderEntrypointCodexResumeHint = (): string => + `# Ensure codex resume hint is shown for interactive shells +CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" +if [[ ! -s "$CODEX_HINT_PATH" ]]; then + cat <<'EOF' > "$CODEX_HINT_PATH" +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi + ;; + esac +fi +if [ -n "$ZSH_VERSION" ]; then + if [[ "$-" == *i* ]]; then + if [[ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]]; then + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi + fi +fi +EOF + chmod 0644 "$CODEX_HINT_PATH" +fi +if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc +fi +if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then + printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc +fi` + +export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => + String.raw`# Ensure global AGENTS.md exists for container context +AGENTS_PATH="${config.codexHome}/AGENTS.md" +LEGACY_AGENTS_PATH="/home/${config.sshUser}/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): ${config.targetDir}" +INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +if [[ ! -f "$AGENTS_PATH" ]]; then + cat <<'AGENTS_EOF' > "$AGENTS_PATH" +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +Рабочая папка проекта (git clone): ${config.targetDir} +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +AGENTS_EOF + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$AGENTS_PATH" ]]; then + if grep -q "^Рабочая папка проекта (git clone):" "$AGENTS_PATH"; then + sed -i "s|^Рабочая папка проекта (git clone):.*$|$PROJECT_LINE|" "$AGENTS_PATH" + else + printf "%s\n" "$PROJECT_LINE" >> "$AGENTS_PATH" + fi + if grep -q "^Доступ к интернету:" "$AGENTS_PATH"; then + sed -i "s|^Доступ к интернету:.*$|$INTERNET_LINE|" "$AGENTS_PATH" + else + printf "%s\n" "$INTERNET_LINE" >> "$AGENTS_PATH" + fi +fi +if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then + LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then + rm -f "$LEGACY_AGENTS_PATH" + fi +fi` diff --git a/packages/lib/src/core/templates-entrypoint/git.ts b/packages/lib/src/core/templates-entrypoint/git.ts new file mode 100644 index 00000000..a6d8d44e --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/git.ts @@ -0,0 +1,63 @@ +import type { TemplateConfig } from "../domain.js" + +export const renderEntrypointGitConfig = (config: TemplateConfig): string => + String.raw`# 2) Ensure GH_TOKEN is available for SSH sessions if provided +if [[ -n "$GH_TOKEN" ]]; then + printf "export GH_TOKEN=%q\n" "$GH_TOKEN" > /etc/profile.d/gh-token.sh + chmod 0644 /etc/profile.d/gh-token.sh + SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" + printf "%s\n" "GH_TOKEN=$GH_TOKEN" > "$SSH_ENV_PATH" + if [[ -n "$GITHUB_TOKEN" ]]; then + printf "%s\n" "GITHUB_TOKEN=$GITHUB_TOKEN" >> "$SSH_ENV_PATH" + fi + chmod 600 "$SSH_ENV_PATH" + chown 1000:1000 "$SSH_ENV_PATH" || true +fi + +# 3) Configure git identity for the dev user if provided +if [[ -n "$GIT_USER_NAME" ]]; then + SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" + su - ${config.sshUser} -c "git config --global user.name $SAFE_GIT_USER_NAME" +fi + +if [[ -n "$GIT_USER_EMAIL" ]]; then + SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" + su - ${config.sshUser} -c "git config --global user.email $SAFE_GIT_USER_EMAIL" +fi` + +export const renderEntrypointGitHooks = (): string => + String.raw`# 3) Install global git hooks to protect main/master +HOOKS_DIR="/opt/docker-git/hooks" +PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" +mkdir -p "$HOOKS_DIR" +if [[ ! -f "$PRE_PUSH_HOOK" ]]; then + cat <<'EOF' > "$PRE_PUSH_HOOK" +#!/usr/bin/env bash +set -euo pipefail + +protected_branches=("refs/heads/main" "refs/heads/master") +allow_delete="${"${"}DOCKER_GIT_ALLOW_DELETE:-}" + +while read -r local_ref local_sha remote_ref remote_sha; do + if [[ -z "$remote_ref" ]]; then + continue + fi + for protected in "${"${"}protected_branches[@]}"; do + if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then + echo "docker-git: push to protected branch '${"${"}protected##*/}' is disabled." + echo "docker-git: create a new branch: git checkout -b " + exit 1 + fi + done + if [[ "$local_sha" == "0000000000000000000000000000000000000000" && "$remote_ref" == refs/heads/* ]]; then + if [[ "$allow_delete" != "1" ]]; then + echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." + exit 1 + fi + fi +done +EOF + chmod 0755 "$PRE_PUSH_HOOK" +fi +git config --system core.hooksPath "$HOOKS_DIR" || true +git config --global core.hooksPath "$HOOKS_DIR" || true` diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts new file mode 100644 index 00000000..7f148f9c --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -0,0 +1,109 @@ +import type { TemplateConfig } from "../domain.js" + +const renderEntrypointAutoUpdate = (): string => + `# 1) Keep Codex CLI up to date if requested (bun only) +if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then + if command -v bun >/dev/null 2>&1; then + echo "[codex] updating via bun..." + script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true + else + echo "[codex] bun not found, skipping auto-update" + fi +fi` + +const renderClonePreamble = (): string => + `# 2) Auto-clone repo if not already present +mkdir -p /run/docker-git +CLONE_DONE_PATH="/run/docker-git/clone.done" +CLONE_FAIL_PATH="/run/docker-git/clone.failed" +rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" + +CLONE_OK=1` + +const renderCloneRemotes = (config: TemplateConfig): string => + `if [[ "$CLONE_OK" -eq 1 && -n "$FORK_REPO_URL" && -d "$TARGET_DIR/.git" ]]; then + AUTH_FORK_URL="$FORK_REPO_URL" + if [[ -n "$GIT_AUTH_TOKEN" && "$FORK_REPO_URL" == https://* ]]; then + AUTH_FORK_URL="$(printf "%s" "$FORK_REPO_URL" | sed "s#^https://#https://\${GIT_AUTH_USER}:\${GIT_AUTH_TOKEN}@#")" + fi + if [[ "$FORK_REPO_URL" != "$REPO_URL" ]]; then + su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$AUTH_FORK_URL'" || true + su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote add upstream '$AUTH_REPO_URL' 2>/dev/null || git remote set-url upstream '$AUTH_REPO_URL'" || true + fi +fi` + +const renderCloneBodyStart = (config: TemplateConfig): string => + `if [[ -z "$REPO_URL" ]]; then + echo "[clone] skip (no repo url)" +elif [[ -d "$TARGET_DIR/.git" ]]; then + echo "[clone] skip (already cloned)" +else + mkdir -p "$TARGET_DIR" + if [[ "$TARGET_DIR" != "/" ]]; then + chown -R 1000:1000 "$TARGET_DIR" + fi + chown -R 1000:1000 /home/${config.sshUser} + + AUTH_REPO_URL="$REPO_URL" + if [[ -n "$GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then + AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${GIT_AUTH_USER}:\${GIT_AUTH_TOKEN}@#")" + fi` + +const renderCloneBodyRef = (config: TemplateConfig): string => + ` if [[ -n "$REPO_REF" ]]; then + if [[ "$REPO_REF" == refs/pull/* ]]; then + REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + else + if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then + echo "[clone] git fetch failed for $REPO_REF" + CLONE_OK=0 + fi + fi + else + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)" + DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')" + if [[ -n "$DEFAULT_BRANCH" ]]; then + echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'" + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + else + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi + fi + else + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi` + +const renderCloneBody = (config: TemplateConfig): string => + [renderCloneBodyStart(config), renderCloneBodyRef(config), "", renderCloneRemotes(config), "fi"].join("\n") + +const renderCloneFinalize = (): string => + `if [[ "$CLONE_OK" -eq 1 ]]; then + echo "[clone] done" + touch "$CLONE_DONE_PATH" +else + echo "[clone] failed" + touch "$CLONE_FAIL_PATH" +fi` + +const renderEntrypointClone = (config: TemplateConfig): string => + [renderClonePreamble(), renderCloneBody(config), renderCloneFinalize()].join("\n\n") + +export const renderEntrypointBackgroundTasks = (config: TemplateConfig): string => + `# 4) Start background tasks so SSH can come up immediately +( +${renderEntrypointAutoUpdate()} + +${renderEntrypointClone(config)} +) &` diff --git a/packages/lib/src/core/templates-prompt.ts b/packages/lib/src/core/templates-prompt.ts index edda28dd..2db68c67 100644 --- a/packages/lib/src/core/templates-prompt.ts +++ b/packages/lib/src/core/templates-prompt.ts @@ -100,8 +100,7 @@ set completion-ignore-case on // EFFECT: n/a // INVARIANT: zsh config does not depend on user dotfiles // COMPLEXITY: O(1) -export const renderZshConfig = (): string => - `setopt PROMPT_SUBST +const dockerGitZshConfig = `setopt PROMPT_SUBST # Terminal compatibility: if terminfo for $TERM is missing (common over SSH), # fall back to xterm-256color so ZLE doesn't garble the display. @@ -166,6 +165,8 @@ if [[ "\${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autos source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh fi` +export const renderZshConfig = (): string => dockerGitZshConfig + // CHANGE: add git branch info to interactive shell prompt // WHY: restore docker-git prompt with time + path + branch // QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 4bffa2c6..00b3b610 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -1,265 +1,13 @@ import type { TemplateConfig } from "./domain.js" import { renderEntrypoint } from "./templates-entrypoint.js" -import { renderDockerfilePrompt } from "./templates-prompt.js" +import { renderDockerCompose } from "./templates/docker-compose.js" +import { renderDockerfile } from "./templates/dockerfile.js" +import { renderPlaywrightBrowserDockerfile, renderPlaywrightStartExtra } from "./templates/playwright.js" export type FileSpec = | { readonly _tag: "File"; readonly relativePath: string; readonly contents: string; readonly mode?: number } | { readonly _tag: "Dir"; readonly relativePath: string } -const renderDockerfilePrelude = (): string => - `FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV NVM_DIR=/usr/local/nvm - -RUN apt-get update && apt-get install -y --no-install-recommends \ - openssh-server git gh ca-certificates curl unzip bsdutils sudo \ - make docker.io docker-compose bash-completion zsh zsh-autosuggestions xauth \ - ncurses-term \ - && rm -rf /var/lib/apt/lists/* - -# Passwordless sudo for all users (container is disposable) -RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ - && chmod 0440 /etc/sudoers.d/zz-all` - -const renderDockerfileNode = (): string => - `# Tooling: Node 24 (NodeSource) + nvm -RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && node -v \ - && npm -v \ - && corepack --version \ - && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /usr/local/nvm \ - && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash -RUN printf "export NVM_DIR=/usr/local/nvm\\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\\n" \ - > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh` - -const renderDockerfileBun = (config: TemplateConfig): string => - `# Tooling: pnpm + Codex CLI (bun) -RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate -ENV BUN_INSTALL=/usr/local/bun -ENV TERM=xterm-256color -ENV PATH="/usr/local/bun/bin:$PATH" -RUN curl -fsSL https://bun.sh/install | bash -RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun -RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null -RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex -${ - config.enableMcpPlaywright - ? `RUN npm install -g @playwright/mcp@latest - -# docker-git: wrapper that converts a CDP HTTP endpoint into a usable WS endpoint -# Some Chromium images return webSocketDebuggerUrl pointing at 127.0.0.1 (container-local). -RUN cat <<'EOF' > /usr/local/bin/docker-git-playwright-mcp -#!/usr/bin/env bash -set -euo pipefail - -# Fast-path for help/version (avoid waiting for the browser sidecar). -for arg in "$@"; do - case "$arg" in - -h|--help|-V|--version) - exec playwright-mcp "$@" - ;; - esac -done - -CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" -if [[ -z "$CDP_ENDPOINT" ]]; then - CDP_ENDPOINT="http://${config.serviceName}-browser:9223" -fi - -# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. -JSON="$(curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version")" -WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')" -if [[ -z "$WS_URL" ]]; then - echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2 - exit 1 -fi - -# Rewrite ws origin to match the CDP endpoint origin (docker DNS). -BASE_WS="$(CDP_ENDPOINT="$CDP_ENDPOINT" node -e 'const { URL } = require("url"); const u=new URL(process.env.CDP_ENDPOINT); const proto=u.protocol==="https:"?"wss:":"ws:"; process.stdout.write(proto + "//" + u.host)')" -WS_REWRITTEN="$(BASE_WS="$BASE_WS" WS_URL="$WS_URL" node -e 'const { URL } = require("url"); const base=new URL(process.env.BASE_WS); const ws=new URL(process.env.WS_URL); ws.protocol=base.protocol; ws.host=base.host; process.stdout.write(ws.toString())')" - -EXTRA_ARGS=() -if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then - EXTRA_ARGS+=(--isolated) -fi - -exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" -EOF -RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` - : "" - } -RUN printf "export BUN_INSTALL=/usr/local/bun\\nexport PATH=/usr/local/bun/bin:$PATH\\n" \ - > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` - -const renderPlaywrightBrowserDockerfile = (): string => - `FROM kechangdev/browser-vnc:latest - -# bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP proxy -# python3/net-tools for diagnostics -RUN apk add --no-cache bash procps socat python3 net-tools - -COPY mcp-playwright-start-extra.sh /usr/local/bin/mcp-playwright-start-extra.sh -RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh - -# Start extra services in background, keep base stack in foreground -# Clear stale Chromium profile locks before boot -ENTRYPOINT ["/bin/sh", "-lc", "rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true; /usr/local/bin/mcp-playwright-start-extra.sh & exec /start.sh"]` - -const renderPlaywrightStartExtra = (): string => - `#!/bin/sh -set -eu - -# Clear stale Chromium locks from previous container runs -rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true - -# Wait for chromium/x11vnc/noVNC to come up -sleep 2 - -# CDP proxy: expose 9223 on the docker network, forward to 127.0.0.1:9222 inside the browser container -socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 & - -# Optional VNC password disabling (useful if you publish VNC/noVNC ports) -if [ "\${VNC_NOPW:-1}" = "1" ]; then - pkill x11vnc || true - x11vnc -display :99 -rfbport 5900 -nopw -forever -shared -bg -o /var/log/x11vnc-nopw.log -fi - -echo "extra services started" -exit 0 -` - -// CHANGE: normalize default ubuntu user to configured ssh user -// WHY: ensure ssh sessions show the configured username and prompt -// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" -// REF: user-request-2026-02-05-restore-prompt -// SOURCE: n/a -// FORMAT THEOREM: forall u in Users: uid(u)=1000 -> username(u)=sshUser -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: ssh user exists with uid/gid 1000 -// COMPLEXITY: O(1) -const renderDockerfileUsers = (config: TemplateConfig): string => - `# Create non-root user for SSH (align UID/GID with host user 1000) -RUN if id -u ubuntu >/dev/null 2>&1; then \ - if getent group 1000 >/dev/null 2>&1; then \ - EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; \ - if [ "$EXISTING_GROUP" != "${config.sshUser}" ]; then groupmod -n ${config.sshUser} "$EXISTING_GROUP" || true; fi; \ - fi; \ - usermod -l ${config.sshUser} -d /home/${config.sshUser} -m -s /usr/bin/zsh ubuntu || true; \ - fi -RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ - usermod -u 1000 -g 1000 -o ${config.sshUser}; \ - else \ - groupadd -g 1000 ${config.sshUser} || true; \ - useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o ${config.sshUser}; \ - fi -RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${config.sshUser} \ - && chmod 0440 /etc/sudoers.d/${config.sshUser} - -# sshd runtime dir -RUN mkdir -p /run/sshd - -# Harden sshd: disable password auth and root login -RUN printf "%s\\n" \ - "PasswordAuthentication no" \ - "PermitRootLogin no" \ - "PubkeyAuthentication yes" \ - "X11Forwarding yes" \ - "X11UseLocalhost yes" \ - "PermitUserEnvironment yes" \ - "AllowUsers ${config.sshUser}" \ - > /etc/ssh/sshd_config.d/${config.sshUser}.conf` - -const renderDockerfileWorkspace = (config: TemplateConfig): string => - `# Workspace path (supports root-level dirs like /repo) -RUN mkdir -p ${config.targetDir} \ - && chown -R 1000:1000 /home/${config.sshUser} \ - && if [ "${config.targetDir}" != "/" ]; then chown -R 1000:1000 "${config.targetDir}"; fi - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 22 -ENTRYPOINT ["/entrypoint.sh"]` - -// CHANGE: install bun+codex and ensure workspace ownership inside the container -// WHY: allow tooling + cloning into root-level workspaces outside /home -// QUOTE(ТЗ): "Клонирует он в \"/\"" -// REF: user-request-2026-01-27 -// SOURCE: n/a -// FORMAT THEOREM: forall cfg: dockerfile(cfg) exposes bun+codex in PATH -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: base image and ssh setup preserved -// COMPLEXITY: O(1) -const renderDockerfile = (config: TemplateConfig): string => - [ - renderDockerfilePrelude(), - renderDockerfilePrompt(), - renderDockerfileNode(), - renderDockerfileBun(config), - renderDockerfileUsers(config), - renderDockerfileWorkspace(config) - ].join("\n\n") - -const renderDockerCompose = (config: TemplateConfig): string => { - const networkName = `${config.serviceName}-net` - const forkRepoUrl = config.forkRepoUrl ?? "" - - const browserServiceName = `${config.serviceName}-browser` - const browserContainerName = `${config.containerName}-browser` - const browserVolumeName = `${config.volumeName}-browser` - const browserDockerfile = "Dockerfile.browser" - const browserCdpEndpoint = `http://${browserServiceName}:9223` - - const maybeDependsOn = config.enableMcpPlaywright - ? ` depends_on:\n - ${browserServiceName}\n` - : "" - const maybePlaywrightEnv = config.enableMcpPlaywright - ? ` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n` - : "" - const maybeBrowserService = config.enableMcpPlaywright - ? `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n` - : "" - const maybeBrowserVolume = config.enableMcpPlaywright ? ` ${browserVolumeName}:\n` : "" - - return `services: - ${config.serviceName}: - build: . - container_name: ${config.containerName} - environment: - REPO_URL: "${config.repoUrl}" - REPO_REF: "${config.repoRef}" - FORK_REPO_URL: "${forkRepoUrl}" - TARGET_DIR: "${config.targetDir}" - CODEX_HOME: "${config.codexHome}" -${maybePlaywrightEnv}${maybeDependsOn} env_file: - - ${config.envGlobalPath} - - ${config.envProjectPath} - ports: - - "127.0.0.1:${config.sshPort}:22" - volumes: - - ${config.volumeName}:/home/${config.sshUser} - - ${config.authorizedKeysPath}:/authorized_keys:ro - - ${config.codexAuthPath}:${config.codexHome} - - ${config.codexSharedAuthPath}:${config.codexHome}-shared - - /var/run/docker.sock:/var/run/docker.sock - networks: - - ${networkName} -${maybeBrowserService} - -networks: - ${networkName}: - driver: bridge - -volumes: - ${config.volumeName}: -${maybeBrowserVolume}` -} - const renderGitignore = (): string => `# docker-git project files # NOTE: this directory is intended to be committed to the docker-git state repository. @@ -272,16 +20,6 @@ const renderGitignore = (): string => .orch/auth/codex/models_cache.json ` -// CHANGE: ignore local secrets in docker build context -// WHY: avoid build failures on unreadable auth files and keep secrets out of images -// QUOTE(ТЗ): "What is wrong?" -// REF: user-request-2026-01-14 -// SOURCE: n/a -// FORMAT THEOREM: forall p in ignored: p not in build_context -// PURITY: CORE -// EFFECT:U: Effect -// INVARIANT: excludes .orch and authorized_keys from build context -// COMPLEXITY: O(1) const renderDockerignore = (): string => `# docker-git build context .orch/ @@ -292,16 +30,6 @@ const renderConfigJson = (config: TemplateConfig): string => `${JSON.stringify({ schemaVersion: 1, template: config }, null, 2)} ` -// CHANGE: generate the file plan for a docker-git project -// WHY: keep templates pure and deterministic for testability -// QUOTE(ТЗ): "Надо написать CLI команду с помощью которой мы будем создавать докер образы" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall cfg: plan(cfg) -> deterministic(files(cfg)) -// PURITY: CORE -// EFFECT: Effect, never, never> -// INVARIANT: same cfg yields identical file specs -// COMPLEXITY: O(1) export const planFiles = (config: TemplateConfig): ReadonlyArray => { const maybePlaywrightFiles = config.enableMcpPlaywright ? ([ diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts new file mode 100644 index 00000000..8102214e --- /dev/null +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -0,0 +1,56 @@ +import type { TemplateConfig } from "../domain.js" + +export const renderDockerCompose = (config: TemplateConfig): string => { + const networkName = `${config.serviceName}-net` + const forkRepoUrl = config.forkRepoUrl ?? "" + + const browserServiceName = `${config.serviceName}-browser` + const browserContainerName = `${config.containerName}-browser` + const browserVolumeName = `${config.volumeName}-browser` + const browserDockerfile = "Dockerfile.browser" + const browserCdpEndpoint = `http://${browserServiceName}:9223` + + const maybeDependsOn = config.enableMcpPlaywright + ? ` depends_on:\n - ${browserServiceName}\n` + : "" + const maybePlaywrightEnv = config.enableMcpPlaywright + ? ` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n` + : "" + const maybeBrowserService = config.enableMcpPlaywright + ? `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n` + : "" + const maybeBrowserVolume = config.enableMcpPlaywright ? ` ${browserVolumeName}:\n` : "" + + return `services: + ${config.serviceName}: + build: . + container_name: ${config.containerName} + environment: + REPO_URL: "${config.repoUrl}" + REPO_REF: "${config.repoRef}" + FORK_REPO_URL: "${forkRepoUrl}" + TARGET_DIR: "${config.targetDir}" + CODEX_HOME: "${config.codexHome}" +${maybePlaywrightEnv}${maybeDependsOn} env_file: + - ${config.envGlobalPath} + - ${config.envProjectPath} + ports: + - "127.0.0.1:${config.sshPort}:22" + volumes: + - ${config.volumeName}:/home/${config.sshUser} + - ${config.authorizedKeysPath}:/authorized_keys:ro + - ${config.codexAuthPath}:${config.codexHome} + - ${config.codexSharedAuthPath}:${config.codexHome}-shared + - /var/run/docker.sock:/var/run/docker.sock + networks: + - ${networkName} +${maybeBrowserService} + +networks: + ${networkName}: + driver: bridge + +volumes: + ${config.volumeName}: +${maybeBrowserVolume}` +} diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts new file mode 100644 index 00000000..381c6ba7 --- /dev/null +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -0,0 +1,154 @@ +import type { TemplateConfig } from "../domain.js" +import { renderDockerfilePrompt } from "../templates-prompt.js" + +const renderDockerfilePrelude = (): string => + `FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV NVM_DIR=/usr/local/nvm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssh-server git gh ca-certificates curl unzip bsdutils sudo \ + make docker.io docker-compose bash-completion zsh zsh-autosuggestions xauth \ + ncurses-term \ + && rm -rf /var/lib/apt/lists/* + +# Passwordless sudo for all users (container is disposable) +RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ + && chmod 0440 /etc/sudoers.d/zz-all` + +const renderDockerfileNode = (): string => + `# Tooling: Node 24 (NodeSource) + nvm +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && node -v \ + && npm -v \ + && corepack --version \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /usr/local/nvm \ + && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash +RUN printf "export NVM_DIR=/usr/local/nvm\\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\\n" \ + > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh` + +const renderDockerfileBunPrelude = (config: TemplateConfig): string => + `# Tooling: pnpm + Codex CLI (bun) +RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate +ENV BUN_INSTALL=/usr/local/bun +ENV TERM=xterm-256color +ENV PATH="/usr/local/bun/bin:$PATH" +RUN curl -fsSL https://bun.sh/install | bash +RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun +RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null +RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex` + +const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest + +# docker-git: wrapper that converts a CDP HTTP endpoint into a usable WS endpoint +# Some Chromium images return webSocketDebuggerUrl pointing at 127.0.0.1 (container-local). +RUN cat <<'EOF' > /usr/local/bin/docker-git-playwright-mcp +#!/usr/bin/env bash +set -euo pipefail + +# Fast-path for help/version (avoid waiting for the browser sidecar). +for arg in "$@"; do + case "$arg" in + -h|--help|-V|--version) + exec playwright-mcp "$@" + ;; + esac +done + +CDP_ENDPOINT="\${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" +if [[ -z "$CDP_ENDPOINT" ]]; then + CDP_ENDPOINT="http://__SERVICE_NAME__-browser:9223" +fi + +# kechangdev/browser-vnc binds Chromium CDP on 127.0.0.1:9222; it also host-checks HTTP requests. +JSON="$(curl -sSf --connect-timeout 3 --max-time 10 -H 'Host: 127.0.0.1:9222' "\${CDP_ENDPOINT%/}/json/version")" +WS_URL="$(printf "%s" "$JSON" | node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(j.webSocketDebuggerUrl || "")')" +if [[ -z "$WS_URL" ]]; then + echo "docker-git-playwright-mcp: webSocketDebuggerUrl missing" >&2 + exit 1 +fi + +# Rewrite ws origin to match the CDP endpoint origin (docker DNS). +BASE_WS="$(CDP_ENDPOINT="$CDP_ENDPOINT" node -e 'const { URL } = require("url"); const u=new URL(process.env.CDP_ENDPOINT); const proto=u.protocol==="https:"?"wss:":"ws:"; process.stdout.write(proto + "//" + u.host)')" +WS_REWRITTEN="$(BASE_WS="$BASE_WS" WS_URL="$WS_URL" node -e 'const { URL } = require("url"); const base=new URL(process.env.BASE_WS); const ws=new URL(process.env.WS_URL); ws.protocol=base.protocol; ws.host=base.host; process.stdout.write(ws.toString())')" + +EXTRA_ARGS=() +if [[ "\${MCP_PLAYWRIGHT_ISOLATED:-1}" == "1" ]]; then + EXTRA_ARGS+=(--isolated) +fi + +exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" +EOF +RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` + +const renderDockerfileBunProfile = (): string => + `RUN printf "export BUN_INSTALL=/usr/local/bun\\nexport PATH=/usr/local/bun/bin:$PATH\\n" \ + > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` + +const renderDockerfileBun = (config: TemplateConfig): string => + [ + renderDockerfileBunPrelude(config), + config.enableMcpPlaywright + ? dockerfilePlaywrightMcpBlock.replaceAll("__SERVICE_NAME__", config.serviceName) + : "", + renderDockerfileBunProfile() + ] + .filter((chunk) => chunk.trim().length > 0) + .join("\n") + +const renderDockerfileUsers = (config: TemplateConfig): string => + `# Create non-root user for SSH (align UID/GID with host user 1000) +RUN if id -u ubuntu >/dev/null 2>&1; then \ + if getent group 1000 >/dev/null 2>&1; then \ + EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; \ + if [ "$EXISTING_GROUP" != "${config.sshUser}" ]; then groupmod -n ${config.sshUser} "$EXISTING_GROUP" || true; fi; \ + fi; \ + usermod -l ${config.sshUser} -d /home/${config.sshUser} -m -s /usr/bin/zsh ubuntu || true; \ + fi +RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ + usermod -u 1000 -g 1000 -o ${config.sshUser}; \ + else \ + groupadd -g 1000 ${config.sshUser} || true; \ + useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o ${config.sshUser}; \ + fi +RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${config.sshUser} \ + && chmod 0440 /etc/sudoers.d/${config.sshUser} + +# sshd runtime dir +RUN mkdir -p /run/sshd + +# Harden sshd: disable password auth and root login +RUN printf "%s\\n" \ + "PasswordAuthentication no" \ + "PermitRootLogin no" \ + "PubkeyAuthentication yes" \ + "X11Forwarding yes" \ + "X11UseLocalhost yes" \ + "PermitUserEnvironment yes" \ + "AllowUsers ${config.sshUser}" \ + > /etc/ssh/sshd_config.d/${config.sshUser}.conf` + +const renderDockerfileWorkspace = (config: TemplateConfig): string => + `# Workspace path (supports root-level dirs like /repo) +RUN mkdir -p ${config.targetDir} \ + && chown -R 1000:1000 /home/${config.sshUser} \ + && if [ "${config.targetDir}" != "/" ]; then chown -R 1000:1000 "${config.targetDir}"; fi + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 22 +ENTRYPOINT ["/entrypoint.sh"]` + +export const renderDockerfile = (config: TemplateConfig): string => + [ + renderDockerfilePrelude(), + renderDockerfilePrompt(), + renderDockerfileNode(), + renderDockerfileBun(config), + renderDockerfileUsers(config), + renderDockerfileWorkspace(config) + ].join("\n\n") diff --git a/packages/lib/src/core/templates/playwright.ts b/packages/lib/src/core/templates/playwright.ts new file mode 100644 index 00000000..0dbdf1a7 --- /dev/null +++ b/packages/lib/src/core/templates/playwright.ts @@ -0,0 +1,36 @@ +export const renderPlaywrightBrowserDockerfile = (): string => + `FROM kechangdev/browser-vnc:latest + +# bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP proxy +# python3/net-tools for diagnostics +RUN apk add --no-cache bash procps socat python3 net-tools + +COPY mcp-playwright-start-extra.sh /usr/local/bin/mcp-playwright-start-extra.sh +RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh + +# Start extra services in background, keep base stack in foreground +# Clear stale Chromium profile locks before boot +ENTRYPOINT ["/bin/sh", "-lc", "rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true; /usr/local/bin/mcp-playwright-start-extra.sh & exec /start.sh"]` + +export const renderPlaywrightStartExtra = (): string => + `#!/bin/sh +set -eu + +# Clear stale Chromium locks from previous container runs +rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true + +# Wait for chromium/x11vnc/noVNC to come up +sleep 2 + +# CDP proxy: expose 9223 on the docker network, forward to 127.0.0.1:9222 inside the browser container +socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 & + +# Optional VNC password disabling (useful if you publish VNC/noVNC ports) +if [ "\${VNC_NOPW:-1}" = "1" ]; then + pkill x11vnc || true + x11vnc -display :99 -rfbport 5900 -nopw -forever -shared -bg -o /var/log/x11vnc-nopw.log +fi + +echo "extra services started" +exit 0 +` diff --git a/packages/lib/src/usecases/actions.ts b/packages/lib/src/usecases/actions.ts index 73ba6205..90d89015 100644 --- a/packages/lib/src/usecases/actions.ts +++ b/packages/lib/src/usecases/actions.ts @@ -1,498 +1 @@ -import type * as CommandExecutor from "@effect/platform/CommandExecutor" -import type { PlatformError } from "@effect/platform/Error" -import * as FileSystem from "@effect/platform/FileSystem" -import * as Path from "@effect/platform/Path" -import { Duration, Effect, Fiber, Schedule } from "effect" - -import type { CreateCommand } from "../core/domain.js" -import { deriveRepoPathParts } from "../core/domain.js" -import { - runDockerComposeDownVolumes, - runDockerComposeLogsFollow, - runDockerComposeUp, - runDockerExecExitCode, - runDockerInspectContainerBridgeIp, - runDockerNetworkConnectBridge -} from "../shell/docker.js" -import type { DockerCommandError, FileExistsError, PortProbeError } from "../shell/errors.js" -import { CloneFailedError } from "../shell/errors.js" -import { writeProjectFiles } from "../shell/files.js" -import { logDockerAccessInfo } from "./access-log.js" -import { ensureCodexConfigFile, migrateLegacyOrchLayout, syncAuthArtifacts } from "./auth-sync.js" -import { applyGithubForkConfig } from "./github-fork.js" -import { defaultProjectsRoot } from "./menu-helpers.js" -import { findAuthorizedKeysSource, findSshPrivateKey, resolveAuthorizedKeysPath } from "./path-helpers.js" -import { loadReservedPorts, selectAvailablePort } from "./ports-reserve.js" -import { buildSshCommand } from "./projects.js" -import { withFsPathContext } from "./runtime.js" -import { autoSyncState } from "./state-repo.js" - -const resolvePathFromBase = (path: Path.Path, baseDir: string, targetPath: string): string => - path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) - -const toPosixPath = (value: string): string => value.replaceAll("\\", "/") - -const resolveDockerGitRootRelativePath = ( - path: Path.Path, - projectsRoot: string, - inputPath: string -): string => { - if (path.isAbsolute(inputPath)) { - return inputPath - } - const normalized = inputPath - .replaceAll("\\", "/") - .replace(/^\.\//, "") - if (normalized === ".docker-git") { - return projectsRoot - } - const prefix = ".docker-git/" - if (normalized.startsWith(prefix)) { - return path.join(projectsRoot, normalized.slice(prefix.length)) - } - return inputPath -} - -type ExistingFileState = "exists" | "missing" - -const ensureFileReady = ( - fs: FileSystem.FileSystem, - resolved: string, - onDirectoryMessage: (resolvedPath: string, backupPath: string) => string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(resolved)) - if (!exists) { - return "missing" - } - - const info = yield* _(fs.stat(resolved)) - if (info.type === "Directory") { - const backupPath = `${resolved}.bak-${Date.now()}` - yield* _(fs.rename(resolved, backupPath)) - yield* _(Effect.logWarning(onDirectoryMessage(resolved, backupPath))) - return "missing" - } - - return "exists" - }) - -const ensureAuthorizedKeys = ( - baseDir: string, - authorizedKeysPath: string -): Effect.Effect => - withFsPathContext(({ fs, path }) => - Effect.gen(function*(_) { - const resolved = resolveAuthorizedKeysPath(path, baseDir, authorizedKeysPath) - const state = yield* _( - ensureFileReady(fs, resolved, (resolvedPath, backupPath) => - `Authorized keys was a directory, moved to ${backupPath}. Creating a file at ${resolvedPath}.`) - ) - if (state === "exists") { - return - } - - const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd())) - if (source === null) { - yield* _( - Effect.logError( - `Authorized keys not found. Create ${resolved} with your public key to enable SSH.` - ) - ) - return - } - - yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) - yield* _(fs.copyFile(source, resolved)) - yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`)) - }) - ) - -const defaultEnvContents = "# docker-git env\n# KEY=value\n" - -// CHANGE: ensure env files exist for shared credentials -// WHY: allow containers to read secrets from env_file without failing -// QUOTE(ТЗ): "удобная настройка ENV" -// REF: user-request-2026-01-09 -// SOURCE: n/a -// FORMAT THEOREM: forall p: exists(file(p)) -> env_file(p) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: creates file if missing -// COMPLEXITY: O(1) -const ensureEnvFile = ( - baseDir: string, - envPath: string -): Effect.Effect => - withFsPathContext(({ fs, path }) => - Effect.gen(function*(_) { - const resolved = resolvePathFromBase(path, baseDir, envPath) - const state = yield* _( - ensureFileReady( - fs, - resolved, - (_resolvedPath, backupPath) => `Env file was a directory, moved to ${backupPath}.` - ) - ) - if (state === "exists") { - return - } - - yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) - yield* _(fs.writeFileString(resolved, defaultEnvContents)) - }) - ) - -type ProjectConfigs = { - readonly globalConfig: CreateCommand["config"] - readonly projectConfig: CreateCommand["config"] -} - -type PrepareProjectFilesError = FileExistsError | PlatformError - -// CHANGE: derive global + per-project paths for docker-git config -// WHY: keep shared auth/env under .docker-git while copying into project-local .orch -// QUOTE(ТЗ): "по умолчанию все конфиги хранились вместе ... .docker-git" -// REF: user-request-2026-01-29-orch-layout -// SOURCE: n/a -// FORMAT THEOREM: forall cfg: project(cfg) -> global(cfg) + local(cfg) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: project paths always live under outDir/.orch -// COMPLEXITY: O(1) -const buildProjectConfigs = ( - path: Path.Path, - baseDir: string, - resolvedOutDir: string, - resolvedConfig: CreateCommand["config"] -): ProjectConfigs => { - // docker-compose resolves relative host paths from the project directory (where docker-compose.yml lives). - // To keep generated projects portable across host OSes, we avoid embedding absolute host paths in templates. - const relativeFromOutDir = (absolutePath: string): string => toPosixPath(path.relative(resolvedOutDir, absolutePath)) - - const globalConfig = { - ...resolvedConfig, - authorizedKeysPath: resolvePathFromBase(path, baseDir, resolvedConfig.authorizedKeysPath), - envGlobalPath: resolvePathFromBase(path, baseDir, resolvedConfig.envGlobalPath), - envProjectPath: resolvePathFromBase(path, baseDir, resolvedConfig.envProjectPath), - codexAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexAuthPath), - codexSharedAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexSharedAuthPath) - } - const projectConfig = { - ...resolvedConfig, - authorizedKeysPath: relativeFromOutDir(globalConfig.authorizedKeysPath), - envGlobalPath: "./.orch/env/global.env", - envProjectPath: path.isAbsolute(resolvedConfig.envProjectPath) - ? relativeFromOutDir(resolvedConfig.envProjectPath) - : toPosixPath(resolvedConfig.envProjectPath), - // Project-local Codex state (sessions/logs/etc) is kept under .orch. - codexAuthPath: "./.orch/auth/codex", - // Shared credentials root is mounted separately; entrypoint links auth.json into CODEX_HOME. - codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath) - } - return { globalConfig, projectConfig } -} - -// CHANGE: write project files and sync shared auth into the project -// WHY: ensure each container has local .orch with auth + env data -// QUOTE(ТЗ): "авторизацию и .env копирует в каждый контейнер" -// REF: user-request-2026-01-29-auth-copy -// SOURCE: n/a -// FORMAT THEOREM: forall p: create(p) -> orch(p) -// PURITY: SHELL -// EFFECT: Effect, FileExistsError | PlatformError, FileSystem | Path> -// INVARIANT: creates files before docker compose up -// COMPLEXITY: O(n) where n = |files| -const prepareProjectFiles = ( - resolvedOutDir: string, - baseDir: string, - globalConfig: CreateCommand["config"], - projectConfig: CreateCommand["config"], - force: boolean -): Effect.Effect, PrepareProjectFilesError, FileSystem.FileSystem | Path.Path> => - Effect.gen(function*(_) { - const createdFiles = yield* _(writeProjectFiles(resolvedOutDir, projectConfig, force)) - yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath)) - yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath)) - yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envProjectPath)) - yield* _(ensureCodexConfigFile(baseDir, globalConfig.codexAuthPath)) - yield* _( - syncAuthArtifacts({ - sourceBase: baseDir, - targetBase: resolvedOutDir, - source: { - envGlobalPath: globalConfig.envGlobalPath, - envProjectPath: globalConfig.envProjectPath, - codexAuthPath: globalConfig.codexAuthPath - }, - target: { - envGlobalPath: projectConfig.envGlobalPath, - envProjectPath: projectConfig.envProjectPath, - codexAuthPath: projectConfig.codexAuthPath - } - }) - ) - // Ensure per-project config stays in sync even when `.orch/auth/codex` already exists. - yield* _(ensureCodexConfigFile(resolvedOutDir, projectConfig.codexAuthPath)) - return createdFiles - }) - -// CHANGE: optionally start docker compose and stream clone logs -// WHY: keep create flow readable and under lint limits -// QUOTE(ТЗ): "должен работать синхронно отображая весь процесс" -// REF: user-request-2026-01-28-clone-logs -// SOURCE: n/a -// FORMAT THEOREM: forall cfg: up(cfg) -> docker_up(cfg) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: only runs when runUp = true -// COMPLEXITY: O(1) -const runDockerUpIfNeeded = ( - resolvedOutDir: string, - projectConfig: CreateCommand["config"], - runUp: boolean, - waitForClone: boolean, - force: boolean -): Effect.Effect< - void, - CloneFailedError | DockerCommandError | PlatformError, - CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path -> => - Effect.gen(function*(_) { - if (!runUp) { - return - } - if (force) { - yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)...")) - yield* _(runDockerComposeDownVolumes(resolvedOutDir)) - } - yield* _(Effect.log("Running: docker compose up -d --build")) - yield* _(runDockerComposeUp(resolvedOutDir)) - - const ensureBridgeAccess = (containerName: string) => - runDockerInspectContainerBridgeIp(resolvedOutDir, containerName).pipe( - Effect.flatMap((bridgeIp) => - bridgeIp.length > 0 - ? Effect.void - : runDockerNetworkConnectBridge(resolvedOutDir, containerName) - ), - Effect.matchEffect({ - onFailure: (error) => - Effect.logWarning( - `Failed to connect ${containerName} to bridge network: ${ - error instanceof Error ? error.message : String(error) - }` - ), - onSuccess: () => Effect.void - }) - ) - - // Make container ports reachable from other (non-compose) containers by IP. - yield* _(ensureBridgeAccess(projectConfig.containerName)) - if (projectConfig.enableMcpPlaywright) { - yield* _(ensureBridgeAccess(`${projectConfig.containerName}-browser`)) - } - - if (waitForClone) { - yield* _(Effect.log("Streaming container logs until clone completes...")) - yield* _(waitForCloneCompletion(resolvedOutDir, projectConfig)) - } - yield* _(Effect.log("Docker environment is up")) - yield* _(logSshAccess(resolvedOutDir, projectConfig)) - }) - -// CHANGE: log SSH access command after container creation -// WHY: provide a single copy-paste command for immediate access -// QUOTE(ТЗ): "должен сразу же написать доступы по SSH" -// REF: user-request-2026-01-27 -// SOURCE: n/a -// FORMAT THEOREM: forall cfg: log(cfg) -> ssh_command(cfg) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: command string is deterministic for given config and key lookup -// COMPLEXITY: O(1) -const logSshAccess = ( - baseDir: string, - config: CreateCommand["config"] -): Effect.Effect => - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath) - const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) - const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) - const sshCommand = buildSshCommand(config, sshKey) - - yield* _(Effect.log(`SSH access: ${sshCommand}`)) - if (!authExists) { - yield* _( - Effect.logWarning( - `Authorized keys file missing: ${resolvedAuthorizedKeys} (SSH may fail without a matching key).` - ) - ) - } - }) - -const maxPortAttempts = 25 -const clonePollInterval = Duration.seconds(1) -const cloneDonePath = "/run/docker-git/clone.done" -const cloneFailPath = "/run/docker-git/clone.failed" - -// CHANGE: reserve a stable SSH port per docker-git project -// WHY: prevent later conflicts when older projects are started -// QUOTE(ТЗ): "для каждого докера брать должен свой порт" -// REF: user-request-2026-02-05-port-reserve -// SOURCE: n/a -// FORMAT THEOREM: ∀p: create(p) → reserved(port(p)) ∧ available(port(p)) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: does not reuse ports already assigned in other docker-git configs -// COMPLEXITY: O(n) where n = maxPortAttempts -const resolveSshPort = ( - config: CreateCommand["config"], - outDir: string -): Effect.Effect< - CreateCommand["config"], - PortProbeError | PlatformError, - FileSystem.FileSystem | Path.Path -> => - Effect.gen(function*(_) { - const reserved = yield* _(loadReservedPorts(outDir)) - const reservedPorts = new Set(reserved.map((entry) => entry.port)) - const selected = yield* _(selectAvailablePort(config.sshPort, maxPortAttempts, reservedPorts)) - if (selected !== config.sshPort) { - const reason = reservedPorts.has(config.sshPort) - ? "already reserved by another docker-git project" - : "already in use" - yield* _( - Effect.logWarning( - `SSH port ${config.sshPort} is ${reason}; using ${selected} instead.` - ) - ) - } - return selected === config.sshPort ? config : { ...config, sshPort: selected } - }) - -type CloneState = "pending" | "done" | "failed" - -const checkCloneState = ( - cwd: string, - containerName: string -): Effect.Effect => - Effect.gen(function*(_) { - const failed = yield* _(runDockerExecExitCode(cwd, containerName, ["test", "-f", cloneFailPath])) - if (failed === 0) { - return "failed" - } - - const done = yield* _(runDockerExecExitCode(cwd, containerName, ["test", "-f", cloneDonePath])) - return done === 0 ? "done" : "pending" - }) - -const waitForCloneCompletion = ( - cwd: string, - config: CreateCommand["config"] -): Effect.Effect => - Effect.gen(function*(_) { - const logsFiber = yield* _( - runDockerComposeLogsFollow(cwd).pipe( - Effect.tapError((error) => - Effect.logWarning( - `docker compose logs --follow failed: ${error instanceof Error ? error.message : String(error)}` - ) - ), - Effect.fork - ) - ) - const result = yield* _( - checkCloneState(cwd, config.containerName).pipe( - Effect.repeat( - Schedule.addDelay( - Schedule.recurUntil((state) => state !== "pending"), - () => clonePollInterval - ) - ) - ) - ) - yield* _(Fiber.interrupt(logsFiber)) - if (result === "failed") { - return yield* _( - Effect.fail( - new CloneFailedError({ - repoUrl: config.repoUrl, - repoRef: config.repoRef, - targetDir: config.targetDir - }) - ) - ) - } - }) - -// CHANGE: orchestrate project creation in the shell layer -// WHY: reuse the same creation flow for CLI and interactive menu -// QUOTE(ТЗ): "Надо написать CLI команду с помощью которой мы будем создавать докер образы" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall cmd: create(cmd) -> files_written(cmd.outDir) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: docker compose runs only when runUp = true -// COMPLEXITY: O(n) where n = |files| -export const createProject = (command: CreateCommand) => - Effect.gen(function*(_) { - const path = yield* _(Path.Path) - const baseDir = process.cwd() - const projectsRoot = path.resolve(defaultProjectsRoot(baseDir)) - const resolveRootPath = (value: string): string => resolveDockerGitRootRelativePath(path, projectsRoot, value) - - const resolvedOutDir = path.resolve(resolveRootPath(command.outDir)) - const resolvedConfig = yield* _( - resolveSshPort( - { - ...command.config, - authorizedKeysPath: resolveRootPath(command.config.authorizedKeysPath), - envGlobalPath: resolveRootPath(command.config.envGlobalPath), - envProjectPath: resolveRootPath(command.config.envProjectPath), - codexAuthPath: resolveRootPath(command.config.codexAuthPath), - codexSharedAuthPath: resolveRootPath(command.config.codexSharedAuthPath) - }, - resolvedOutDir - ) - ) - const forkedConfig = yield* _(applyGithubForkConfig(resolvedConfig)) - const { globalConfig, projectConfig } = buildProjectConfigs(path, baseDir, resolvedOutDir, forkedConfig) - yield* _(migrateLegacyOrchLayout( - baseDir, - globalConfig.envGlobalPath, - globalConfig.envProjectPath, - globalConfig.codexAuthPath, - resolveRootPath(".docker-git/.orch/auth/gh") - )) - const createdFiles = yield* _(prepareProjectFiles( - resolvedOutDir, - baseDir, - globalConfig, - projectConfig, - command.force - )) - yield* _(Effect.log(`Created docker-git project in ${resolvedOutDir}`)) - for (const file of createdFiles) { - yield* _(Effect.log(` - ${file}`)) - } - yield* _( - runDockerUpIfNeeded( - resolvedOutDir, - projectConfig, - command.runUp, - command.waitForClone, - command.force - ) - ) - if (command.runUp) { - yield* _(logDockerAccessInfo(resolvedOutDir, projectConfig)) - } - - const repoPath = deriveRepoPathParts(projectConfig.repoUrl).pathParts.join("/") - const syncLabel = repoPath.length > 0 ? repoPath : projectConfig.repoUrl - yield* _(autoSyncState(`chore(state): update ${syncLabel}`)) - }) +export { createProject } from "./actions/create-project.js" diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts new file mode 100644 index 00000000..32b7c7ce --- /dev/null +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -0,0 +1,101 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { CreateCommand } from "../../core/domain.js" +import { deriveRepoPathParts } from "../../core/domain.js" +import type { CloneFailedError, DockerCommandError, FileExistsError, PortProbeError } from "../../shell/errors.js" +import { logDockerAccessInfo } from "../access-log.js" +import { applyGithubForkConfig } from "../github-fork.js" +import { defaultProjectsRoot } from "../menu-helpers.js" +import { autoSyncState } from "../state-repo.js" +import { runDockerUpIfNeeded } from "./docker-up.js" +import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" +import { resolveSshPort } from "./ports.js" +import { migrateProjectOrchLayout, prepareProjectFiles } from "./prepare-files.js" + +type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + +type CreateProjectError = + | FileExistsError + | CloneFailedError + | DockerCommandError + | PortProbeError + | PlatformError + +type CreateContext = { + readonly baseDir: string + readonly resolveRootPath: (value: string) => string +} + +const makeCreateContext = (path: Path.Path, baseDir: string): CreateContext => { + const projectsRoot = path.resolve(defaultProjectsRoot(baseDir)) + const resolveRootPath = (value: string): string => resolveDockerGitRootRelativePath(path, projectsRoot, value) + return { baseDir, resolveRootPath } +} + +const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): CreateCommand["config"] => ({ + ...command.config, + authorizedKeysPath: ctx.resolveRootPath(command.config.authorizedKeysPath), + envGlobalPath: ctx.resolveRootPath(command.config.envGlobalPath), + envProjectPath: ctx.resolveRootPath(command.config.envProjectPath), + codexAuthPath: ctx.resolveRootPath(command.config.codexAuthPath), + codexSharedAuthPath: ctx.resolveRootPath(command.config.codexSharedAuthPath) +}) + +const resolveCreateConfig = ( + command: CreateCommand, + ctx: CreateContext, + resolvedOutDir: string +): Effect.Effect< + CreateCommand["config"], + PortProbeError | PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => + resolveSshPort(resolveRootedConfig(command, ctx), resolvedOutDir).pipe( + Effect.flatMap((config) => applyGithubForkConfig(config)) + ) + +const logCreatedProject = (resolvedOutDir: string, createdFiles: ReadonlyArray) => + Effect.gen(function*(_) { + yield* _(Effect.log(`Created docker-git project in ${resolvedOutDir}`)) + for (const file of createdFiles) { + yield* _(Effect.log(` - ${file}`)) + } + }).pipe(Effect.asVoid) + +const formatStateSyncLabel = (repoUrl: string): string => { + const repoPath = deriveRepoPathParts(repoUrl).pathParts.join("/") + return repoPath.length > 0 ? repoPath : repoUrl +} + +const runCreateProject = ( + path: Path.Path, + command: CreateCommand +): Effect.Effect => + Effect.gen(function*(_) { + const ctx = makeCreateContext(path, process.cwd()) + const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir)) + + const resolvedConfig = yield* _(resolveCreateConfig(command, ctx, resolvedOutDir)) + const { globalConfig, projectConfig } = buildProjectConfigs(path, ctx.baseDir, resolvedOutDir, resolvedConfig) + + yield* _(migrateProjectOrchLayout(ctx.baseDir, globalConfig, ctx.resolveRootPath)) + + const createdFiles = yield* _( + prepareProjectFiles(resolvedOutDir, ctx.baseDir, globalConfig, projectConfig, command.force) + ) + yield* _(logCreatedProject(resolvedOutDir, createdFiles)) + + yield* _(runDockerUpIfNeeded(resolvedOutDir, projectConfig, command.runUp, command.waitForClone, command.force)) + if (command.runUp) { + yield* _(logDockerAccessInfo(resolvedOutDir, projectConfig)) + } + + yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`)) + }).pipe(Effect.asVoid) + +export const createProject = (command: CreateCommand): Effect.Effect => + Path.Path.pipe(Effect.flatMap((path) => runCreateProject(path, command))) diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts new file mode 100644 index 00000000..21c38100 --- /dev/null +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -0,0 +1,157 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Duration, Effect, Fiber, Schedule } from "effect" + +import type { CreateCommand } from "../../core/domain.js" +import { + runDockerComposeDownVolumes, + runDockerComposeLogsFollow, + runDockerComposeUp, + runDockerExecExitCode, + runDockerInspectContainerBridgeIp, + runDockerNetworkConnectBridge +} from "../../shell/docker.js" +import type { DockerCommandError } from "../../shell/errors.js" +import { CloneFailedError } from "../../shell/errors.js" +import { findSshPrivateKey, resolveAuthorizedKeysPath } from "../path-helpers.js" +import { buildSshCommand } from "../projects.js" + +const maxPortAttempts = 25 +const clonePollInterval = Duration.seconds(1) +const cloneDonePath = "/run/docker-git/clone.done" +const cloneFailPath = "/run/docker-git/clone.failed" + +const logSshAccess = ( + baseDir: string, + config: CreateCommand["config"] +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath) + const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) + const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) + const sshCommand = buildSshCommand(config, sshKey) + + yield* _(Effect.log(`SSH access: ${sshCommand}`)) + if (!authExists) { + yield* _( + Effect.logWarning( + `Authorized keys file missing: ${resolvedAuthorizedKeys} (SSH may fail without a matching key).` + ) + ) + } + }) + +type CloneState = "pending" | "done" | "failed" + +const checkCloneState = ( + cwd: string, + containerName: string +): Effect.Effect => + Effect.gen(function*(_) { + const failed = yield* _(runDockerExecExitCode(cwd, containerName, ["test", "-f", cloneFailPath])) + if (failed === 0) { + return "failed" + } + + const done = yield* _(runDockerExecExitCode(cwd, containerName, ["test", "-f", cloneDonePath])) + return done === 0 ? "done" : "pending" + }) + +const waitForCloneCompletion = ( + cwd: string, + config: CreateCommand["config"] +): Effect.Effect => + Effect.gen(function*(_) { + const logsFiber = yield* _( + runDockerComposeLogsFollow(cwd).pipe( + Effect.tapError((error) => + Effect.logWarning( + `docker compose logs --follow failed: ${error instanceof Error ? error.message : String(error)}` + ) + ), + Effect.fork + ) + ) + const result = yield* _( + checkCloneState(cwd, config.containerName).pipe( + Effect.repeat( + Schedule.addDelay( + Schedule.recurUntil((state) => state !== "pending"), + () => clonePollInterval + ) + ) + ) + ) + yield* _(Fiber.interrupt(logsFiber)) + if (result === "failed") { + return yield* _( + Effect.fail( + new CloneFailedError({ + repoUrl: config.repoUrl, + repoRef: config.repoRef, + targetDir: config.targetDir + }) + ) + ) + } + }) + +export const runDockerUpIfNeeded = ( + resolvedOutDir: string, + projectConfig: CreateCommand["config"], + runUp: boolean, + waitForClone: boolean, + force: boolean +): Effect.Effect< + void, + CloneFailedError | DockerCommandError | PlatformError, + CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function*(_) { + if (!runUp) { + return + } + if (force) { + yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)...")) + yield* _(runDockerComposeDownVolumes(resolvedOutDir)) + } + yield* _(Effect.log("Running: docker compose up -d --build")) + yield* _(runDockerComposeUp(resolvedOutDir)) + + const ensureBridgeAccess = (containerName: string) => + runDockerInspectContainerBridgeIp(resolvedOutDir, containerName).pipe( + Effect.flatMap((bridgeIp) => + bridgeIp.length > 0 + ? Effect.void + : runDockerNetworkConnectBridge(resolvedOutDir, containerName) + ), + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning( + `Failed to connect ${containerName} to bridge network: ${ + error instanceof Error ? error.message : String(error) + }` + ), + onSuccess: () => Effect.void + }) + ) + + // Make container ports reachable from other (non-compose) containers by IP. + yield* _(ensureBridgeAccess(projectConfig.containerName)) + if (projectConfig.enableMcpPlaywright) { + yield* _(ensureBridgeAccess(`${projectConfig.containerName}-browser`)) + } + + if (waitForClone) { + yield* _(Effect.log("Streaming container logs until clone completes...")) + yield* _(waitForCloneCompletion(resolvedOutDir, projectConfig)) + } + yield* _(Effect.log("Docker environment is up")) + yield* _(logSshAccess(resolvedOutDir, projectConfig)) + }) + +export const maxSshPortAttempts = maxPortAttempts diff --git a/packages/lib/src/usecases/actions/paths.ts b/packages/lib/src/usecases/actions/paths.ts new file mode 100644 index 00000000..909b5a01 --- /dev/null +++ b/packages/lib/src/usecases/actions/paths.ts @@ -0,0 +1,66 @@ +import type * as Path from "@effect/platform/Path" +import type { CreateCommand } from "../../core/domain.js" + +export const resolvePathFromBase = (path: Path.Path, baseDir: string, targetPath: string): string => + path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) + +const toPosixPath = (value: string): string => value.replaceAll("\\", "/") + +export const resolveDockerGitRootRelativePath = ( + path: Path.Path, + projectsRoot: string, + inputPath: string +): string => { + if (path.isAbsolute(inputPath)) { + return inputPath + } + const normalized = inputPath + .replaceAll("\\", "/") + .replace(/^\.\//, "") + if (normalized === ".docker-git") { + return projectsRoot + } + const prefix = ".docker-git/" + if (normalized.startsWith(prefix)) { + return path.join(projectsRoot, normalized.slice(prefix.length)) + } + return inputPath +} + +type ProjectConfigs = { + readonly globalConfig: CreateCommand["config"] + readonly projectConfig: CreateCommand["config"] +} + +export const buildProjectConfigs = ( + path: Path.Path, + baseDir: string, + resolvedOutDir: string, + resolvedConfig: CreateCommand["config"] +): ProjectConfigs => { + // docker-compose resolves relative host paths from the project directory (where docker-compose.yml lives). + // To keep generated projects portable across host OSes, we avoid embedding absolute host paths in templates. + const relativeFromOutDir = (absolutePath: string): string => toPosixPath(path.relative(resolvedOutDir, absolutePath)) + + const globalConfig = { + ...resolvedConfig, + authorizedKeysPath: resolvePathFromBase(path, baseDir, resolvedConfig.authorizedKeysPath), + envGlobalPath: resolvePathFromBase(path, baseDir, resolvedConfig.envGlobalPath), + envProjectPath: resolvePathFromBase(path, baseDir, resolvedConfig.envProjectPath), + codexAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexAuthPath), + codexSharedAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexSharedAuthPath) + } + const projectConfig = { + ...resolvedConfig, + authorizedKeysPath: relativeFromOutDir(globalConfig.authorizedKeysPath), + envGlobalPath: "./.orch/env/global.env", + envProjectPath: path.isAbsolute(resolvedConfig.envProjectPath) + ? relativeFromOutDir(resolvedConfig.envProjectPath) + : toPosixPath(resolvedConfig.envProjectPath), + // Project-local Codex state (sessions/logs/etc) is kept under .orch. + codexAuthPath: "./.orch/auth/codex", + // Shared credentials root is mounted separately; entrypoint links auth.json into CODEX_HOME. + codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath) + } + return { globalConfig, projectConfig } +} diff --git a/packages/lib/src/usecases/actions/ports.ts b/packages/lib/src/usecases/actions/ports.ts new file mode 100644 index 00000000..6467833f --- /dev/null +++ b/packages/lib/src/usecases/actions/ports.ts @@ -0,0 +1,35 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { CreateCommand } from "../../core/domain.js" +import type { PortProbeError } from "../../shell/errors.js" +import { loadReservedPorts, selectAvailablePort } from "../ports-reserve.js" + +const maxPortAttempts = 25 + +export const resolveSshPort = ( + config: CreateCommand["config"], + outDir: string +): Effect.Effect< + CreateCommand["config"], + PortProbeError | PlatformError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function*(_) { + const reserved = yield* _(loadReservedPorts(outDir)) + const reservedPorts = new Set(reserved.map((entry) => entry.port)) + const selected = yield* _(selectAvailablePort(config.sshPort, maxPortAttempts, reservedPorts)) + if (selected !== config.sshPort) { + const reason = reservedPorts.has(config.sshPort) + ? "already reserved by another docker-git project" + : "already in use" + yield* _( + Effect.logWarning( + `SSH port ${config.sshPort} is ${reason}; using ${selected} instead.` + ) + ) + } + return selected === config.sshPort ? config : { ...config, sshPort: selected } + }) diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts new file mode 100644 index 00000000..4b21fb6a --- /dev/null +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -0,0 +1,145 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { CreateCommand } from "../../core/domain.js" +import type { FileExistsError } from "../../shell/errors.js" +import { writeProjectFiles } from "../../shell/files.js" +import { ensureCodexConfigFile, migrateLegacyOrchLayout, syncAuthArtifacts } from "../auth-sync.js" +import { findAuthorizedKeysSource, resolveAuthorizedKeysPath } from "../path-helpers.js" +import { withFsPathContext } from "../runtime.js" +import { resolvePathFromBase } from "./paths.js" + +type ExistingFileState = "exists" | "missing" + +const ensureFileReady = ( + fs: FileSystem.FileSystem, + resolved: string, + onDirectoryMessage: (resolvedPath: string, backupPath: string) => string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(resolved)) + if (!exists) { + return "missing" + } + + const info = yield* _(fs.stat(resolved)) + if (info.type === "Directory") { + const backupPath = `${resolved}.bak-${Date.now()}` + yield* _(fs.rename(resolved, backupPath)) + yield* _(Effect.logWarning(onDirectoryMessage(resolved, backupPath))) + return "missing" + } + + return "exists" + }) + +const ensureAuthorizedKeys = ( + baseDir: string, + authorizedKeysPath: string +): Effect.Effect => + withFsPathContext(({ fs, path }) => + Effect.gen(function*(_) { + const resolved = resolveAuthorizedKeysPath(path, baseDir, authorizedKeysPath) + const state = yield* _( + ensureFileReady( + fs, + resolved, + (resolvedPath, backupPath) => + `Authorized keys was a directory, moved to ${backupPath}. Creating a file at ${resolvedPath}.` + ) + ) + if (state === "exists") { + return + } + + const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd())) + if (source === null) { + yield* _( + Effect.logError( + `Authorized keys not found. Create ${resolved} with your public key to enable SSH.` + ) + ) + return + } + + yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) + yield* _(fs.copyFile(source, resolved)) + yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`)) + }) + ) + +const defaultEnvContents = "# docker-git env\n# KEY=value\n" + +const ensureEnvFile = ( + baseDir: string, + envPath: string +): Effect.Effect => + withFsPathContext(({ fs, path }) => + Effect.gen(function*(_) { + const resolved = resolvePathFromBase(path, baseDir, envPath) + const state = yield* _( + ensureFileReady( + fs, + resolved, + (_resolvedPath, backupPath) => `Env file was a directory, moved to ${backupPath}.` + ) + ) + if (state === "exists") { + return + } + + yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) + yield* _(fs.writeFileString(resolved, defaultEnvContents)) + }) + ) + +export type PrepareProjectFilesError = FileExistsError | PlatformError + +export const prepareProjectFiles = ( + resolvedOutDir: string, + baseDir: string, + globalConfig: CreateCommand["config"], + projectConfig: CreateCommand["config"], + force: boolean +): Effect.Effect, PrepareProjectFilesError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function*(_) { + const createdFiles = yield* _(writeProjectFiles(resolvedOutDir, projectConfig, force)) + yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath)) + yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath)) + yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envProjectPath)) + yield* _(ensureCodexConfigFile(baseDir, globalConfig.codexAuthPath)) + yield* _( + syncAuthArtifacts({ + sourceBase: baseDir, + targetBase: resolvedOutDir, + source: { + envGlobalPath: globalConfig.envGlobalPath, + envProjectPath: globalConfig.envProjectPath, + codexAuthPath: globalConfig.codexAuthPath + }, + target: { + envGlobalPath: projectConfig.envGlobalPath, + envProjectPath: projectConfig.envProjectPath, + codexAuthPath: projectConfig.codexAuthPath + } + }) + ) + // Ensure per-project config stays in sync even when `.orch/auth/codex` already exists. + yield* _(ensureCodexConfigFile(resolvedOutDir, projectConfig.codexAuthPath)) + return createdFiles + }) + +export const migrateProjectOrchLayout = ( + baseDir: string, + globalConfig: CreateCommand["config"], + resolveRootPath: (value: string) => string +): Effect.Effect => + migrateLegacyOrchLayout( + baseDir, + globalConfig.envGlobalPath, + globalConfig.envProjectPath, + globalConfig.codexAuthPath, + resolveRootPath(".docker-git/.orch/auth/gh") + ) diff --git a/packages/lib/src/usecases/docker-git-config-search.ts b/packages/lib/src/usecases/docker-git-config-search.ts new file mode 100644 index 00000000..b3317678 --- /dev/null +++ b/packages/lib/src/usecases/docker-git-config-search.ts @@ -0,0 +1,67 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +type DockerGitConfigSearchState = { + readonly stack: Array + readonly results: Array +} + +const isDockerGitConfig = (entry: string): boolean => entry.endsWith("docker-git.json") + +const shouldSkipDir = (entry: string): boolean => entry === ".git" || entry === ".orch" + +const processDockerGitEntry = ( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, + entry: string, + state: DockerGitConfigSearchState +): Effect.Effect => + Effect.gen(function*(_) { + if (shouldSkipDir(entry)) { + return + } + + const resolved = path.join(dir, entry) + const info = yield* _(fs.stat(resolved)) + if (info.type === "Directory") { + state.stack.push(resolved) + return + } + + if (info.type === "File" && isDockerGitConfig(entry)) { + state.results.push(resolved) + } + }).pipe(Effect.asVoid) + +export const findDockerGitConfigPaths = ( + fs: FileSystem.FileSystem, + path: Path.Path, + rootDir: string +): Effect.Effect, PlatformError> => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(rootDir)) + if (!exists) { + return [] + } + + // Avoid traversing git metadata (projectsRoot can itself be a git repo). + const results: Array = [] + const stack: Array = [rootDir] + const state: DockerGitConfigSearchState = { stack, results } + while (stack.length > 0) { + const dir = stack.pop() + if (dir === undefined) { + break + } + + const entries = yield* _(fs.readDirectory(dir)) + for (const entry of entries) { + yield* _(processDockerGitEntry(fs, path, dir, entry, state)) + } + } + + return results + }) diff --git a/packages/lib/src/usecases/menu-helpers.ts b/packages/lib/src/usecases/menu-helpers.ts index 93763e69..16735da1 100644 --- a/packages/lib/src/usecases/menu-helpers.ts +++ b/packages/lib/src/usecases/menu-helpers.ts @@ -1,46 +1,6 @@ import type { ProjectConfig } from "../core/domain.js" -export { findSshPrivateKey } from "./path-helpers.js" - -const expandHome = (value: string): string => { - const home = process.env["HOME"] ?? process.env["USERPROFILE"] - if (!home || home.length === 0) { - return value - } - if (value === "~") { - return home - } - if (value.startsWith("~/") || value.startsWith("~\\")) { - return `${home}${value.slice(1)}` - } - return value -} - -const trimTrailingSlash = (value: string): string => { - let end = value.length - while (end > 0) { - const char = value[end - 1] - if (char !== "/" && char !== "\\") { - break - } - end -= 1 - } - return value.slice(0, end) -} - -export const defaultProjectsRoot = (cwd: string): string => { - const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() - if (explicit && explicit.length > 0) { - return expandHome(explicit) - } - - const home = process.env["HOME"] ?? process.env["USERPROFILE"] - if (home && home.trim().length > 0) { - return `${trimTrailingSlash(home.trim())}/.docker-git` - } - - return `${cwd}/.docker-git` -} +export { defaultProjectsRoot, findSshPrivateKey, resolveAuthorizedKeysPath } from "./path-helpers.js" export const isRepoUrlInput = (input: string): boolean => { const trimmed = input.trim().toLowerCase() @@ -76,5 +36,3 @@ export const formatConnectionInfo = ( `Env project: ${config.template.envProjectPath} ` + `Codex auth: ${config.template.codexAuthPath} -> ${config.template.codexHome}` - -export { resolveAuthorizedKeysPath } from "./path-helpers.js" diff --git a/packages/lib/src/usecases/path-helpers.ts b/packages/lib/src/usecases/path-helpers.ts index 5681aecf..283ec4bd 100644 --- a/packages/lib/src/usecases/path-helpers.ts +++ b/packages/lib/src/usecases/path-helpers.ts @@ -12,6 +12,55 @@ export const resolveAuthorizedKeysPath = ( ? authorizedKeysPath : path.resolve(baseDir, authorizedKeysPath) +const resolveHomeDir = (): string | null => { + const raw = process.env["HOME"] ?? process.env["USERPROFILE"] + const home = raw?.trim() ?? "" + return home.length > 0 ? home : null +} + +const expandHome = (value: string, home: string | null): string => { + if (home === null) { + return value + } + if (value === "~") { + return home + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return `${home}${value.slice(1)}` + } + return value +} + +const trimTrailingSlash = (value: string): string => { + let end = value.length + while (end > 0) { + const char = value[end - 1] + if (char !== "/" && char !== "\\") { + break + } + end -= 1 + } + return value.slice(0, end) +} + +export const defaultProjectsRoot = (cwd: string): string => { + const home = resolveHomeDir() + const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() + if (explicit && explicit.length > 0) { + return expandHome(explicit, home) + } + if (home !== null) { + return `${trimTrailingSlash(home)}/.docker-git` + } + return `${cwd}/.docker-git` +} + +const normalizeRelativePath = (value: string): string => + value + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .trim() + export const resolvePathFromCwd = ( path: Path.Path, cwd: string, @@ -20,50 +69,8 @@ export const resolvePathFromCwd = ( path.isAbsolute(targetPath) ? targetPath : (() => { - const expandHome = (value: string): string => { - const home = process.env["HOME"] ?? process.env["USERPROFILE"] - if (!home || home.length === 0) { - return value - } - if (value === "~") { - return home - } - if (value.startsWith("~/") || value.startsWith("~\\")) { - return `${home}${value.slice(1)}` - } - return value - } - - const trimTrailingSlash = (value: string): string => { - let end = value.length - while (end > 0) { - const char = value[end - 1] - if (char !== "/" && char !== "\\") { - break - } - end -= 1 - } - return value.slice(0, end) - } - - const defaultProjectsRoot = (): string => { - const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim() - if (explicit && explicit.length > 0) { - return expandHome(explicit) - } - const home = process.env["HOME"] ?? process.env["USERPROFILE"] - if (home && home.trim().length > 0) { - return `${trimTrailingSlash(home.trim())}/.docker-git` - } - return `${cwd}/.docker-git` - } - - const projectsRoot = path.resolve(defaultProjectsRoot()) - const normalized = targetPath - .replaceAll("\\", "/") - .replace(/^\.\//, "") - .trim() - + const projectsRoot = path.resolve(defaultProjectsRoot(cwd)) + const normalized = normalizeRelativePath(targetPath) if (normalized === ".docker-git") { return projectsRoot } @@ -71,7 +78,6 @@ export const resolvePathFromCwd = ( if (normalized.startsWith(prefix)) { return path.join(projectsRoot, normalized.slice(prefix.length)) } - return path.resolve(cwd, targetPath) })() diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 5a36458c..ff422de4 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -8,6 +8,7 @@ import { deriveRepoPathParts } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" import type { ConfigDecodeError, ConfigNotFoundError } from "../shell/errors.js" import { resolveBaseDir } from "../shell/paths.js" +import { findDockerGitConfigPaths } from "./docker-git-config-search.js" import { renderError } from "./errors.js" import { defaultProjectsRoot, formatConnectionInfo } from "./menu-helpers.js" import { findSshPrivateKey, resolveAuthorizedKeysPath, resolvePathFromCwd } from "./path-helpers.js" @@ -65,10 +66,6 @@ type ComposePsRow = { readonly image: string } -const isDockerGitConfig = (entry: string): boolean => entry.endsWith("docker-git.json") - -const shouldSkipDir = (entry: string): boolean => entry === ".git" || entry === ".orch" - type ProjectBase = { readonly fs: FileSystem.FileSystem readonly path: Path.Path @@ -89,38 +86,7 @@ const loadProjectBase = ( const findProjectConfigPaths = ( projectsRoot: string ): Effect.Effect, PlatformError, FileSystem.FileSystem | Path.Path> => - withFsPathContext(({ fs, path }) => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(projectsRoot)) - if (!exists) { - return [] - } - - // Avoid traversing git metadata (state root can be a git repo). - const results: Array = [] - const stack: Array = [projectsRoot] - while (stack.length > 0) { - const dir = stack.pop() - if (dir === undefined) { - break - } - const entries = yield* _(fs.readDirectory(dir)) - for (const entry of entries) { - if (shouldSkipDir(entry)) { - continue - } - const resolved = path.join(dir, entry) - const info = yield* _(fs.stat(resolved)) - if (info.type === "Directory") { - stack.push(resolved) - } else if (info.type === "File" && isDockerGitConfig(entry)) { - results.push(resolved) - } - } - } - return results - }) - ) + withFsPathContext(({ fs, path }) => findDockerGitConfigPaths(fs, path, path.resolve(projectsRoot))) export const loadProjectSummary = ( configPath: string, @@ -211,6 +177,27 @@ export const skipWithWarning = (configPath: string) => (error: ProjectLoadErr Effect.as(null) ) +export const forEachProjectStatus = ( + configPaths: ReadonlyArray, + run: (status: ProjectStatus) => Effect.Effect +): Effect.Effect => + Effect.gen(function*(_) { + for (const configPath of configPaths) { + const status = yield* _( + loadProjectStatus(configPath).pipe( + Effect.matchEffect({ + onFailure: skipWithWarning(configPath), + onSuccess: (value) => Effect.succeed(value) + }) + ) + ) + if (status === null) { + continue + } + yield* _(run(status)) + } + }).pipe(Effect.asVoid) + const normalizeCell = (value: string | undefined): string => value?.trim() ?? "-" const parseComposeLine = (line: string): ComposePsRow => { diff --git a/packages/lib/src/usecases/projects-down.ts b/packages/lib/src/usecases/projects-down.ts index e999be6c..d64eb7eb 100644 --- a/packages/lib/src/usecases/projects-down.ts +++ b/packages/lib/src/usecases/projects-down.ts @@ -7,13 +7,7 @@ import { Effect, pipe } from "effect" import { runDockerComposeDown } from "../shell/docker.js" import type { DockerCommandError } from "../shell/errors.js" import { renderError } from "./errors.js" -import { - loadProjectIndex, - loadProjectStatus, - type ProjectStatus, - renderProjectStatusHeader, - skipWithWarning -} from "./projects-core.js" +import { forEachProjectStatus, loadProjectIndex, renderProjectStatusHeader } from "./projects-core.js" // CHANGE: provide a "stop all" helper for docker-git managed projects // WHY: allow quickly stopping all running docker-git containers from the CLI/TUI @@ -34,22 +28,10 @@ export const downAllDockerGitProjects: Effect.Effect< Effect.flatMap((index) => index === null ? Effect.void - : Effect.gen(function*(_) { - for (const configPath of index.configPaths) { - const status = yield* _( - loadProjectStatus(configPath).pipe( - Effect.matchEffect({ - onFailure: skipWithWarning(configPath), - onSuccess: (value) => Effect.succeed(value) - }) - ) - ) - if (status === null) { - continue - } - - yield* _(Effect.log(renderProjectStatusHeader(status))) - yield* _( + : forEachProjectStatus(index.configPaths, (status) => + pipe( + Effect.log(renderProjectStatusHeader(status)), + Effect.zipRight( runDockerComposeDown(status.projectDir).pipe( Effect.catchTag("DockerCommandError", (error: DockerCommandError) => Effect.logWarning( @@ -57,8 +39,7 @@ export const downAllDockerGitProjects: Effect.Effect< )) ) ) - } - }) + )) ), Effect.asVoid ) diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 5d0b1878..fd5f3c7d 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -17,13 +17,11 @@ import { import { renderError } from "./errors.js" import { buildSshCommand, + forEachProjectStatus, formatComposeRows, - loadProjectStatus, parseComposePsOutput, type ProjectItem, - type ProjectStatus, renderProjectStatusHeader, - skipWithWarning, withProjectIndexAndSsh } from "./projects-core.js" import { runDockerComposeUpWithPortCheck } from "./projects-up.js" @@ -177,23 +175,13 @@ export const listProjectStatus: Effect.Effect< Fs | PathService | CommandExecutor.CommandExecutor > = Effect.asVoid( withProjectIndexAndSsh((index, sshKey) => - Effect.gen(function*(_) { - for (const configPath of index.configPaths) { - const status = yield* _( - loadProjectStatus(configPath).pipe( - Effect.matchEffect({ - onFailure: skipWithWarning(configPath), - onSuccess: (value) => Effect.succeed(value) - }) - ) - ) - if (status === null) { - continue - } - - yield* _(Effect.log(renderProjectStatusHeader(status))) - yield* _(Effect.log(`SSH access: ${buildSshCommand(status.config.template, sshKey)}`)) - yield* _( + forEachProjectStatus(index.configPaths, (status) => + pipe( + Effect.log(renderProjectStatusHeader(status)), + Effect.zipRight( + Effect.log(`SSH access: ${buildSshCommand(status.config.template, sshKey)}`) + ), + Effect.zipRight( runDockerComposePsFormatted(status.projectDir).pipe( Effect.map((raw) => parseComposePsOutput(raw)), Effect.map((rows) => formatComposeRows(rows)), @@ -207,7 +195,6 @@ export const listProjectStatus: Effect.Effect< }) ) ) - } - }) + )) ) ) diff --git a/packages/lib/src/usecases/state-normalize.ts b/packages/lib/src/usecases/state-normalize.ts index f07bc108..b788c74a 100644 --- a/packages/lib/src/usecases/state-normalize.ts +++ b/packages/lib/src/usecases/state-normalize.ts @@ -6,10 +6,7 @@ import { Effect } from "effect" import type { TemplateConfig } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" import { writeProjectFiles } from "../shell/files.js" - -const isDockerGitConfig = (entry: string): boolean => entry.endsWith("docker-git.json") - -const shouldSkipDir = (entry: string): boolean => entry === ".git" || entry === ".orch" +import { findDockerGitConfigPaths } from "./docker-git-config-search.js" const toPosixPath = (value: string): string => value.replaceAll("\\", "/") @@ -60,42 +57,6 @@ const normalizeTemplateConfig = ( } } -const findProjectConfigPaths = ( - fs: FileSystem.FileSystem, - path: Path.Path, - projectsRoot: string -): Effect.Effect, PlatformError> => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(projectsRoot)) - if (!exists) { - return [] - } - - // Avoid traversing git metadata (projectsRoot can itself be a git repo). - const results: Array = [] - const stack: Array = [projectsRoot] - while (stack.length > 0) { - const dir = stack.pop() - if (dir === undefined) { - break - } - const entries = yield* _(fs.readDirectory(dir)) - for (const entry of entries) { - if (shouldSkipDir(entry)) { - continue - } - const resolved = path.join(dir, entry) - const info = yield* _(fs.stat(resolved)) - if (info.type === "Directory") { - stack.push(resolved) - } else if (info.type === "File" && isDockerGitConfig(entry)) { - results.push(resolved) - } - } - } - return results - }) - // CHANGE: normalize legacy docker-git project files inside the git-synced state repo // WHY: state is stored in git and must be portable across machines/OSes (no absolute host paths) // QUOTE(ТЗ): "в них не должно быть зарадкожено полных путей типо /home/dev" / "контейнеры должны одинаково ставится на разные ОС" @@ -114,7 +75,7 @@ export const normalizeLegacyStateProjects = ( const path = yield* _(Path.Path) const root = path.resolve(projectsRoot) - const configPaths = yield* _(findProjectConfigPaths(fs, path, root)) + const configPaths = yield* _(findDockerGitConfigPaths(fs, path, root)) if (configPaths.length === 0) { return } diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index 12df7f45..9f5b0117 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -1,120 +1,28 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" -import { ExitCode } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" -import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "../shell/command-runner.js" +import { runCommandExitCode } from "../shell/command-runner.js" import { CommandFailedError } from "../shell/errors.js" -import { parseEnvEntries } from "./env-file.js" import { defaultProjectsRoot } from "./menu-helpers.js" -import { normalizeLegacyStateProjects } from "./state-normalize.js" +import { autoSyncEnvKey, autoSyncStrictEnvKey, isAutoSyncEnabled, isTruthyEnv } from "./state-repo/env.js" +import { + git, + gitBaseEnv, + gitCapture, + gitExitCode, + hasOriginRemote, + isGitRepo, + successExitCode +} from "./state-repo/git-commands.js" +import { isGithubHttpsRemote, resolveGithubToken, withGithubAskpassEnv } from "./state-repo/github-auth.js" +import { ensureStateGitignore } from "./state-repo/gitignore.js" +import { runStateSyncOps, runStateSyncWithToken } from "./state-repo/sync-ops.js" -const successExitCode = Number(ExitCode(0)) - -const gitBaseEnv: Readonly> = { - // Avoid blocking on interactive credential prompts in CI / TUI contexts. - GIT_TERMINAL_PROMPT: "0" -} - -const githubTokenKey = "GITHUB_TOKEN" - -const resolveStateRoot = ( - path: Path.Path, - cwd: string -): string => path.resolve(defaultProjectsRoot(cwd)) - -const stateGitignoreMarker = "# docker-git state repository" - -const legacySecretIgnorePatterns: ReadonlyArray = [ - "**/.orch/env/", - "**/.orch/auth/" -] - -const volatileCodexIgnorePatterns: ReadonlyArray = [ - "**/.orch/auth/codex/log/", - "**/.orch/auth/codex/tmp/", - "**/.orch/auth/codex/sessions/", - "**/.orch/auth/codex/models_cache.json" -] - -const defaultStateGitignore = [ - stateGitignoreMarker, - "# NOTE: this repo intentionally tracks EVERYTHING under the state dir, including .orch/env and .orch/auth.", - "# Keep the remote private; treat it as sensitive infrastructure state.", - "", - "# Volatile Codex artifacts (do not commit)", - ...volatileCodexIgnorePatterns, - "" -].join("\n") - -const normalizeGitignoreText = (text: string): string => - text - .replaceAll("\r\n", "\n") - .trim() - -// CHANGE: ensure state repo has a safe .gitignore for syncing full state via git -// WHY: track .orch/auth and .orch/env, but ignore volatile Codex cache/log directories -// QUOTE(ТЗ): "да не надо сохранять log/, /tmp, /sessions, models_cache.json" -// REF: user-request-2026-02-10-state-ignore-volatile -// SOURCE: n/a -// FORMAT THEOREM: forall root: ensureGitignore(root) -> exists(.gitignore(root)) and ignoresVolatileCodex(root) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: updates only docker-git-managed .gitignore files -// COMPLEXITY: O(n) where n = |.gitignore| -const ensureStateGitignore = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string -): Effect.Effect => - Effect.gen(function*(_) { - const gitignorePath = path.join(root, ".gitignore") - const exists = yield* _(fs.exists(gitignorePath)) - if (!exists) { - yield* _(fs.writeFileString(gitignorePath, defaultStateGitignore)) - return - } - - const stat = yield* _(fs.stat(gitignorePath)) - if (stat.type !== "File") { - yield* _(Effect.logWarning(`${gitignorePath} exists but is not a file; skipping`)) - return - } - - const prev = yield* _(fs.readFileString(gitignorePath)) - const normalized = normalizeGitignoreText(prev) - if (!normalized.startsWith(stateGitignoreMarker)) { - return - } - - // If the file is docker-git managed but still ignores secrets (legacy default), rewrite it. - const prevLines = new Set(prev.replaceAll("\r", "").split("\n").map((l) => l.trimEnd())) - const hasLegacySecretIgnores = legacySecretIgnorePatterns.some((p) => prevLines.has(p)) - if (hasLegacySecretIgnores) { - yield* _(fs.writeFileString(gitignorePath, defaultStateGitignore)) - return - } - - // Ensure volatile Codex artifacts are ignored; append if missing. - const missingVolatile = volatileCodexIgnorePatterns.filter((p) => !prevLines.has(p)) - if (missingVolatile.length === 0) { - return - } - const next = `${prev.trimEnd()}\n\n# Volatile Codex artifacts (do not commit)\n${missingVolatile.join("\n")}\n` - yield* _(fs.writeFileString(gitignorePath, next)) - }) +type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor -// CHANGE: manage docker-git state dir as a git repository -// WHY: allow sharing docker-git state across machines using a private git repo -// QUOTE(ТЗ): "общая память через гит" / "иметь возможность комитить его на гит" -// REF: user-request-2026-02-07-state-repo -// SOURCE: n/a -// FORMAT THEOREM: forall op: state(op) -> deterministic(root) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: never deletes user data; only runs git commands in the state root -// COMPLEXITY: O(command) +const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) export const statePath: Effect.Effect = Effect.gen(function*(_) { const path = yield* _(Path.Path) @@ -123,332 +31,24 @@ export const statePath: Effect.Effect = Effect.g yield* _(Effect.log(root)) }).pipe(Effect.asVoid) -const git = ( - cwd: string, - args: ReadonlyArray, - env: Readonly> = gitBaseEnv -): Effect.Effect => - runCommandWithExitCodes( - { cwd, command: "git", args, env }, - [successExitCode], - (exitCode) => new CommandFailedError({ command: `git ${args[0] ?? ""}`, exitCode }) - ) - -const gitExitCode = ( - cwd: string, - args: ReadonlyArray, - env: Readonly> = gitBaseEnv -): Effect.Effect => - runCommandExitCode({ cwd, command: "git", args, env }) - -const gitCapture = ( - cwd: string, - args: ReadonlyArray, - env: Readonly> = gitBaseEnv -): Effect.Effect => - runCommandCapture( - { cwd, command: "git", args, env }, - [successExitCode], - (exitCode) => new CommandFailedError({ command: `git ${args[0] ?? ""}`, exitCode }) - ) - -const isTruthyEnv = (value: string): boolean => - value.trim().toLowerCase() === "1" || - value.trim().toLowerCase() === "true" || - value.trim().toLowerCase() === "yes" || - value.trim().toLowerCase() === "on" - -const isFalsyEnv = (value: string): boolean => - value.trim().toLowerCase() === "0" || - value.trim().toLowerCase() === "false" || - value.trim().toLowerCase() === "no" || - value.trim().toLowerCase() === "off" - -const autoSyncEnvKey = "DOCKER_GIT_STATE_AUTO_SYNC" -const autoSyncStrictEnvKey = "DOCKER_GIT_STATE_AUTO_SYNC_STRICT" - -const defaultSyncMessage = "chore(state): sync" - -const isGitRepo = (root: string) => - Effect.map(gitExitCode(root, ["rev-parse", "--is-inside-work-tree"]), (exit) => exit === successExitCode) - -const hasOriginRemote = (root: string) => - Effect.map(gitExitCode(root, ["remote", "get-url", "origin"]), (exit) => exit === successExitCode) - -const commitAllIfNeeded = ( - root: string, - message: string, - env: Readonly> -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(git(root, ["add", "-A"], env)) - const diffExit = yield* _(gitExitCode(root, ["diff", "--cached", "--quiet"], env)) - if (diffExit === successExitCode) { - return - } - yield* _(git(root, ["commit", "-m", message], env)) - }) - -const sanitizeBranchComponent = (value: string): string => - value - .trim() - .replaceAll(" ", "-") - .replaceAll(":", "-") - .replaceAll("..", "-") - .replaceAll("@{", "-") - .replaceAll("\\", "-") - .replaceAll("^", "-") - .replaceAll("~", "-") - -const githubHttpsRemoteRe = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/ -const githubSshRemoteRe = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/ -const githubSshUrlRemoteRe = /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/ - -const tryBuildGithubCompareUrl = ( - originUrl: string, - baseBranch: string, - headBranch: string -): string | null => { - const trimmed = originUrl.trim() - const httpsMatch = githubHttpsRemoteRe.exec(trimmed) - if (httpsMatch) { - const owner = httpsMatch[1] ?? "" - const repo = httpsMatch[2] ?? "" - return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${ - encodeURIComponent(headBranch) - }?expand=1` - } - - const sshMatch = githubSshRemoteRe.exec(trimmed) - if (sshMatch) { - const owner = sshMatch[1] ?? "" - const repo = sshMatch[2] ?? "" - return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${ - encodeURIComponent(headBranch) - }?expand=1` - } - - const sshUrlMatch = githubSshUrlRemoteRe.exec(trimmed) - if (sshUrlMatch) { - const owner = sshUrlMatch[1] ?? "" - const repo = sshUrlMatch[2] ?? "" - return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(baseBranch)}...${ - encodeURIComponent(headBranch) - }?expand=1` - } - - return null -} - -const rebaseOntoOriginIfPossible = ( - root: string, - baseBranch: string, - env: Readonly> -): Effect.Effect<"ok" | "skipped" | "conflict", CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => - Effect.gen(function*(_) { - // Ensure we see the latest remote branch tip before attempting to rebase. - const fetchExit = yield* _(gitExitCode(root, ["fetch", "origin", "--prune"], env)) - if (fetchExit !== successExitCode) { - return yield* _(Effect.fail(new CommandFailedError({ command: "git fetch origin --prune", exitCode: fetchExit }))) - } - - const remoteRef = `refs/remotes/origin/${baseBranch}` - const hasRemoteBranchExit = yield* _(gitExitCode(root, ["show-ref", "--verify", "--quiet", remoteRef], env)) - if (hasRemoteBranchExit !== successExitCode) { - return "skipped" - } - - const rebaseExit = yield* _(gitExitCode(root, ["rebase", `origin/${baseBranch}`], env)) - if (rebaseExit === successExitCode) { - return "ok" - } - - // Best-effort: avoid leaving the repo in a rebase-in-progress state. - yield* _(gitExitCode(root, ["rebase", "--abort"], env)) - return "conflict" - }) - -const pushToNewBranch = ( - root: string, - baseBranch: string, - env: Readonly> -): Effect.Effect => - Effect.gen(function*(_) { - const headShort = yield* _( - gitCapture(root, ["rev-parse", "--short", "HEAD"], env).pipe(Effect.map((value) => value.trim())) - ) - const timestamp = yield* _(Effect.sync(() => new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"))) - const branch = sanitizeBranchComponent(`state-sync/${baseBranch}/${timestamp}-${headShort}`) - - yield* _(git(root, ["push", "origin", `HEAD:refs/heads/${branch}`], env)) - return branch - }) - -const isGithubHttpsRemote = (url: string): boolean => /^https:\/\/github\.com\//.test(url.trim()) - -// CHANGE: resolve GitHub token for git HTTPS auth (env > state global.env) -// WHY: enable non-interactive git push/pull for private state repositories -// QUOTE(ТЗ): "Он должен был сделать пуш на гит что бы держать синхронизацию данных" -// REF: user-request-2026-02-09-state-push-auth -// SOURCE: n/a -// FORMAT THEOREM: forall root: token(root) -> auth_possible(root) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: never logs the token -// COMPLEXITY: O(n) where n = |global.env| -const resolveGithubToken = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string -): Effect.Effect => - Effect.gen(function*(_) { - const fromEnv = process.env["GITHUB_TOKEN"]?.trim() ?? process.env["GH_TOKEN"]?.trim() ?? "" - if (fromEnv.length > 0) { - return fromEnv - } - - const envPath = path.join(root, ".orch", "env", "global.env") - const exists = yield* _(fs.exists(envPath)) - if (!exists) { - return null - } - - const text = yield* _(fs.readFileString(envPath)) - const entries = parseEnvEntries(text) - const direct = entries.find((e) => e.key === githubTokenKey)?.value.trim() ?? "" - if (direct.length > 0) { - return direct - } - - const labeled = entries.find((e) => e.key.startsWith("GITHUB_TOKEN__"))?.value.trim() ?? "" - return labeled.length > 0 ? labeled : null - }) - -const withGithubAskpassEnv = ( - token: string, - use: (env: Readonly>) => Effect.Effect -): Effect.Effect => - Effect.scoped( - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const askpassPath = yield* _(fs.makeTempFileScoped({ prefix: "docker-git-askpass-" })) - const contents = [ - "#!/bin/sh", - "case \"$1\" in", - " *Username*) echo \"x-access-token\" ;;", - " *Password*) echo \"${DOCKER_GIT_GITHUB_TOKEN}\" ;;", - " *) echo \"${DOCKER_GIT_GITHUB_TOKEN}\" ;;", - "esac", - "" - ].join("\n") - yield* _(fs.writeFileString(askpassPath, contents)) - yield* _(fs.chmod(askpassPath, 0o700)) - const env: Readonly> = { - ...gitBaseEnv, - DOCKER_GIT_GITHUB_TOKEN: token, - GIT_ASKPASS: askpassPath, - GIT_ASKPASS_REQUIRE: "force" - } - return yield* _(use(env)) - }) - ) - -type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor - -type GitAuthEnv = Readonly> - -const resolveBaseBranch = (value: string): string => (value === "HEAD" ? "main" : value) - -const getCurrentBranch = ( - root: string, - env: GitAuthEnv -): Effect.Effect => - gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env).pipe(Effect.map((value) => value.trim())) - -const runStateSyncOps = ( - root: string, - originUrl: string, - message: string | null, - env: GitAuthEnv -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(normalizeLegacyStateProjects(root)) - const commitMessage = message && message.trim().length > 0 ? message.trim() : defaultSyncMessage - yield* _(commitAllIfNeeded(root, commitMessage, env)) - - const branch = yield* _(getCurrentBranch(root, env)) - const baseBranch = resolveBaseBranch(branch) - - const rebaseResult = yield* _(rebaseOntoOriginIfPossible(root, baseBranch, env)) - if (rebaseResult === "conflict") { - const prBranch = yield* _(pushToNewBranch(root, baseBranch, env)) - const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) - - yield* _(Effect.logWarning(`State sync needs manual merge: pushed changes to branch '${prBranch}'.`)) - yield* (compareUrl - ? _(Effect.log(`Open PR: ${compareUrl}`)) - : _(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`))) - return - } - - const pushExit = yield* _(gitExitCode(root, ["push", "-u", "origin", "HEAD"], env)) - if (pushExit === successExitCode) { - return - } - - const prBranch = yield* _(pushToNewBranch(root, baseBranch, env)) - const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) - yield* _(Effect.logWarning(`State push failed (exit ${pushExit}); pushed changes to branch '${prBranch}'.`)) - if (compareUrl) { - yield* _(Effect.log(`Open PR: ${compareUrl}`)) - return - } - yield* _(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) - }).pipe(Effect.asVoid) - -const runStateSyncWithToken = ( - token: string, - root: string, - originUrl: string, - message: string | null -): Effect.Effect => - withGithubAskpassEnv(token, (env) => runStateSyncOps(root, originUrl, message, env)) - -// CHANGE: sync state repo with remote (commit + pull --rebase + push) -// WHY: provide a single command to keep git-synced state up to date across machines -// QUOTE(ТЗ): "иметь команд синхронизации с гит версией" -// REF: user-request-2026-02-08-state-sync -// SOURCE: n/a -// FORMAT THEOREM: forall root: sync(root) -> (local == remote) or typed_error -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: never commits ignored files (relies on .gitignore) -// COMPLEXITY: O(git) export const stateSync = ( message: string | null -): Effect.Effect< - void, - CommandFailedError | PlatformError, - StateRepoEnv -> => +): Effect.Effect => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) - const baseEnv = gitBaseEnv - const repoExit = yield* _(gitExitCode(root, ["rev-parse", "--is-inside-work-tree"], baseEnv)) + const repoExit = yield* _(gitExitCode(root, ["rev-parse", "--is-inside-work-tree"], gitBaseEnv)) if (repoExit !== successExitCode) { yield* _(Effect.logWarning(`State dir is not a git repository: ${root}`)) yield* _(Effect.logWarning(`Run: docker-git state init --repo-url `)) return yield* _( - Effect.fail( - new CommandFailedError({ command: "git rev-parse --is-inside-work-tree", exitCode: repoExit }) - ) + Effect.fail(new CommandFailedError({ command: "git rev-parse --is-inside-work-tree", exitCode: repoExit })) ) } - const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], baseEnv)) + const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv)) if (originUrlExit !== successExitCode) { yield* _(Effect.logWarning(`State dir has no origin remote: ${root}`)) yield* _(Effect.logWarning(`Run: docker-git state init --repo-url `)) @@ -457,49 +57,17 @@ export const stateSync = ( ) } const originUrl = yield* _( - gitCapture(root, ["remote", "get-url", "origin"], baseEnv).pipe(Effect.map((value) => value.trim())) + gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim())) ) const token = yield* _(resolveGithubToken(fs, path, root)) const syncEffect = token && token.length > 0 && isGithubHttpsRemote(originUrl) ? runStateSyncWithToken(token, root, originUrl, message) - : runStateSyncOps(root, originUrl, message, baseEnv) + : runStateSyncOps(root, originUrl, message, gitBaseEnv) yield* _(syncEffect) }).pipe(Effect.asVoid) -const isAutoSyncEnabled = ( - envValue: string | undefined, - hasRemote: boolean -): boolean => { - if (envValue === undefined) { - return hasRemote - } - if (envValue.trim().length === 0) { - return hasRemote - } - if (isFalsyEnv(envValue)) { - return false - } - if (isTruthyEnv(envValue)) { - return true - } - // Non-empty values default to enabled. - return true -} - -// CHANGE: automatically sync state after docker-git operations -// WHY: keep state repo always pushed when containers/projects are added -// QUOTE(ТЗ): "любое обновление .docker-git папки комитило и пушило её на гит" -// REF: user-request-2026-02-08-auto-sync -// SOURCE: n/a -// FORMAT THEOREM: forall op: updates(op) -> eventually_synced(op) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: best-effort; never fails the main operation -// COMPLEXITY: O(git) -export const autoSyncState = ( - message: string -): Effect.Effect => +export const autoSyncState = (message: string): Effect.Effect => Effect.gen(function*(_) { const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) @@ -545,67 +113,102 @@ export const autoSyncState = ( Effect.asVoid ) -export const stateInit = ( - input: { - readonly repoUrl: string - readonly repoRef: string - } -): Effect.Effect< - void, - CommandFailedError | PlatformError, - FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor -> => +type StateInitInput = { + readonly repoUrl: string + readonly repoRef: string +} + +const cloneStateRepo = ( + root: string, + input: StateInitInput +): Effect.Effect => Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const cwd = process.cwd() - const root = resolveStateRoot(path, cwd) + const cloneWithBranch = ["clone", "--branch", input.repoRef, input.repoUrl, root] + const cloneBranchExit = yield* _( + runCommandExitCode({ cwd: root, command: "git", args: cloneWithBranch, env: gitBaseEnv }) + ) + if (cloneBranchExit === successExitCode) { + return + } + + // Empty remotes (no branch yet) and remotes without the requested branch can fail here. + // Fall back to cloning the default branch so we can still set up the repo and create the branch locally. + yield* _( + Effect.logWarning( + `git clone --branch ${input.repoRef} failed (exit ${cloneBranchExit}); retrying without --branch` + ) + ) + const cloneDefault = ["clone", input.repoUrl, root] + const cloneDefaultExit = yield* _( + runCommandExitCode({ cwd: root, command: "git", args: cloneDefault, env: gitBaseEnv }) + ) + if (cloneDefaultExit !== successExitCode) { + return yield* _(Effect.fail(new CommandFailedError({ command: "git clone", exitCode: cloneDefaultExit }))) + } + }).pipe(Effect.asVoid) +const initRepoIfNeeded = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + input: StateInitInput +): Effect.Effect => + Effect.gen(function*(_) { yield* _(fs.makeDirectory(root, { recursive: true })) const gitDir = path.join(root, ".git") const hasGit = yield* _(fs.exists(gitDir)) - if (!hasGit) { - const entries = yield* _(fs.readDirectory(root)) - if (entries.length === 0) { - const cloneWithBranch = ["clone", "--branch", input.repoRef, input.repoUrl, root] - const cloneBranchExit = yield* _( - runCommandExitCode({ cwd: root, command: "git", args: cloneWithBranch, env: gitBaseEnv }) - ) - if (cloneBranchExit !== successExitCode) { - // Empty remotes (no branch yet) and remotes without the requested branch can fail here. - // Fall back to cloning the default branch so we can still set up the repo and create the branch locally. - yield* _( - Effect.logWarning( - `git clone --branch ${input.repoRef} failed (exit ${cloneBranchExit}); retrying without --branch` - ) - ) - const cloneDefault = ["clone", input.repoUrl, root] - const cloneDefaultExit = yield* _( - runCommandExitCode({ cwd: root, command: "git", args: cloneDefault, env: gitBaseEnv }) - ) - if (cloneDefaultExit !== successExitCode) { - return yield* _(Effect.fail(new CommandFailedError({ command: "git clone", exitCode: cloneDefaultExit }))) - } - } - yield* _(Effect.log(`State dir cloned: ${root}`)) - } else { - yield* _(git(root, ["init"], gitBaseEnv)) - } + if (hasGit) { + return } - const setUrlExit = yield* _(gitExitCode(root, ["remote", "set-url", "origin", input.repoUrl], gitBaseEnv)) - if (setUrlExit !== successExitCode) { - yield* _(git(root, ["remote", "add", "origin", input.repoUrl], gitBaseEnv)) + const entries = yield* _(fs.readDirectory(root)) + if (entries.length === 0) { + yield* _(cloneStateRepo(root, input)) + yield* _(Effect.log(`State dir cloned: ${root}`)) + return } - // Best-effort: ensure the local branch exists and can be tracked later. - const checkoutExit = yield* _(gitExitCode(root, ["checkout", "-B", input.repoRef], gitBaseEnv)) - if (checkoutExit !== successExitCode) { - yield* _(Effect.logWarning(`git checkout -B ${input.repoRef} failed (exit ${checkoutExit})`)) + yield* _(git(root, ["init"], gitBaseEnv)) + }).pipe(Effect.asVoid) + +const ensureOriginRemote = ( + root: string, + repoUrl: string +): Effect.Effect => + Effect.gen(function*(_) { + const setUrlExit = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], gitBaseEnv)) + if (setUrlExit === successExitCode) { + return } + yield* _(git(root, ["remote", "add", "origin", repoUrl], gitBaseEnv)) + }) +const checkoutBranchBestEffort = ( + root: string, + repoRef: string +): Effect.Effect => + Effect.gen(function*(_) { + const checkoutExit = yield* _(gitExitCode(root, ["checkout", "-B", repoRef], gitBaseEnv)) + if (checkoutExit === successExitCode) { + return + } + yield* _(Effect.logWarning(`git checkout -B ${repoRef} failed (exit ${checkoutExit})`)) + }) + +export const stateInit = ( + input: StateInitInput +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const root = resolveStateRoot(path, process.cwd()) + + yield* _(initRepoIfNeeded(fs, path, root, input)) + yield* _(ensureOriginRemote(root, input.repoUrl)) + yield* _(checkoutBranchBestEffort(root, input.repoRef)) yield* _(ensureStateGitignore(fs, path, root)) + yield* _(Effect.log(`State dir ready: ${root}`)) yield* _(Effect.log(`Remote: ${input.repoUrl}`)) }).pipe(Effect.asVoid) diff --git a/packages/lib/src/usecases/state-repo/env.ts b/packages/lib/src/usecases/state-repo/env.ts new file mode 100644 index 00000000..de50f8b6 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/env.ts @@ -0,0 +1,31 @@ +export const isTruthyEnv = (value: string): boolean => { + const normalized = value.trim().toLowerCase() + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on" +} + +export const isFalsyEnv = (value: string): boolean => { + const normalized = value.trim().toLowerCase() + return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off" +} + +export const autoSyncEnvKey = "DOCKER_GIT_STATE_AUTO_SYNC" +export const autoSyncStrictEnvKey = "DOCKER_GIT_STATE_AUTO_SYNC_STRICT" + +export const defaultSyncMessage = "chore(state): sync" + +export const isAutoSyncEnabled = (envValue: string | undefined, hasRemote: boolean): boolean => { + if (envValue === undefined) { + return hasRemote + } + if (envValue.trim().length === 0) { + return hasRemote + } + if (isFalsyEnv(envValue)) { + return false + } + if (isTruthyEnv(envValue)) { + return true + } + // Non-empty values default to enabled. + return true +} diff --git a/packages/lib/src/usecases/state-repo/git-commands.ts b/packages/lib/src/usecases/state-repo/git-commands.ts new file mode 100644 index 00000000..49bb7f81 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/git-commands.ts @@ -0,0 +1,48 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import { ExitCode } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { Effect } from "effect" +import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "../../shell/command-runner.js" +import { CommandFailedError } from "../../shell/errors.js" + +export const successExitCode = Number(ExitCode(0)) + +export const gitBaseEnv: Readonly> = { + // Avoid blocking on interactive credential prompts in CI / TUI contexts. + GIT_TERMINAL_PROMPT: "0" +} + +export const git = ( + cwd: string, + args: ReadonlyArray, + env: Readonly> = gitBaseEnv +): Effect.Effect => + runCommandWithExitCodes( + { cwd, command: "git", args, env }, + [successExitCode], + (exitCode) => new CommandFailedError({ command: `git ${args[0] ?? ""}`, exitCode }) + ) + +export const gitExitCode = ( + cwd: string, + args: ReadonlyArray, + env: Readonly> = gitBaseEnv +): Effect.Effect => + runCommandExitCode({ cwd, command: "git", args, env }) + +export const gitCapture = ( + cwd: string, + args: ReadonlyArray, + env: Readonly> = gitBaseEnv +): Effect.Effect => + runCommandCapture( + { cwd, command: "git", args, env }, + [successExitCode], + (exitCode) => new CommandFailedError({ command: `git ${args[0] ?? ""}`, exitCode }) + ) + +export const isGitRepo = (root: string) => + Effect.map(gitExitCode(root, ["rev-parse", "--is-inside-work-tree"]), (exit) => exit === successExitCode) + +export const hasOriginRemote = (root: string) => + Effect.map(gitExitCode(root, ["remote", "get-url", "origin"]), (exit) => exit === successExitCode) diff --git a/packages/lib/src/usecases/state-repo/github-auth.ts b/packages/lib/src/usecases/state-repo/github-auth.ts new file mode 100644 index 00000000..d0817bc5 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/github-auth.ts @@ -0,0 +1,143 @@ +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" +import { parseEnvEntries } from "../env-file.js" +import { gitBaseEnv } from "./git-commands.js" + +const githubTokenKey = "GITHUB_TOKEN" + +const githubHttpsRemoteRe = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/ +const githubSshRemoteRe = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/ +const githubSshUrlRemoteRe = /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/ + +type GithubRemoteParts = { + readonly owner: string + readonly repo: string +} + +const tryParseGithubRemoteParts = (originUrl: string): GithubRemoteParts | null => { + const trimmed = originUrl.trim() + const match = githubHttpsRemoteRe.exec(trimmed) ?? + githubSshRemoteRe.exec(trimmed) ?? + githubSshUrlRemoteRe.exec(trimmed) + if (match === null) { + return null + } + const owner = match[1] ?? "" + const repo = match[2] ?? "" + return owner.length > 0 && repo.length > 0 ? { owner, repo } : null +} + +export const tryBuildGithubCompareUrl = ( + originUrl: string, + baseBranch: string, + headBranch: string +): string | null => { + const parts = tryParseGithubRemoteParts(originUrl) + if (parts === null) { + return null + } + return `https://github.com/${parts.owner}/${parts.repo}/compare/${encodeURIComponent(baseBranch)}...${ + encodeURIComponent(headBranch) + }?expand=1` +} + +export const isGithubHttpsRemote = (url: string): boolean => /^https:\/\/github\.com\//.test(url.trim()) + +const resolveTokenFromProcessEnv = (): string | null => { + const github = process.env["GITHUB_TOKEN"] + if (github !== undefined) { + const trimmed = github.trim() + if (trimmed.length > 0) { + return trimmed + } + } + + const gh = process.env["GH_TOKEN"] + if (gh !== undefined) { + const trimmed = gh.trim() + if (trimmed.length > 0) { + return trimmed + } + } + + return null +} + +type EnvEntry = { + readonly key: string + readonly value: string +} + +const findTokenInEnvEntries = (entries: ReadonlyArray): string | null => { + const directEntry = entries.find((e) => e.key === githubTokenKey) + if (directEntry !== undefined) { + const direct = directEntry.value.trim() + if (direct.length > 0) { + return direct + } + } + + const labeledEntry = entries.find((e) => e.key.startsWith("GITHUB_TOKEN__")) + if (labeledEntry !== undefined) { + const labeled = labeledEntry.value.trim() + if (labeled.length > 0) { + return labeled + } + } + + return null +} + +export const resolveGithubToken = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const fromEnv = resolveTokenFromProcessEnv() + if (fromEnv !== null) { + return fromEnv + } + + const envPath = path.join(root, ".orch", "env", "global.env") + const exists = yield* _(fs.exists(envPath)) + if (!exists) { + return null + } + + const text = yield* _(fs.readFileString(envPath)) + return findTokenInEnvEntries(parseEnvEntries(text)) + }) + +export type GitAuthEnv = Readonly> + +export const withGithubAskpassEnv = ( + token: string, + use: (env: GitAuthEnv) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const askpassPath = yield* _(fs.makeTempFileScoped({ prefix: "docker-git-askpass-" })) + const contents = [ + "#!/bin/sh", + "case \"$1\" in", + " *Username*) echo \"x-access-token\" ;;", + " *Password*) echo \"${DOCKER_GIT_GITHUB_TOKEN}\" ;;", + " *) echo \"${DOCKER_GIT_GITHUB_TOKEN}\" ;;", + "esac", + "" + ].join("\n") + yield* _(fs.writeFileString(askpassPath, contents)) + yield* _(fs.chmod(askpassPath, 0o700)) + const env: GitAuthEnv = { + ...gitBaseEnv, + DOCKER_GIT_GITHUB_TOKEN: token, + GIT_ASKPASS: askpassPath, + GIT_ASKPASS_REQUIRE: "force" + } + return yield* _(use(env)) + }) + ) diff --git a/packages/lib/src/usecases/state-repo/gitignore.ts b/packages/lib/src/usecases/state-repo/gitignore.ts new file mode 100644 index 00000000..d3a2e6a1 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/gitignore.ts @@ -0,0 +1,75 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +const stateGitignoreMarker = "# docker-git state repository" + +const legacySecretIgnorePatterns: ReadonlyArray = [ + "**/.orch/env/", + "**/.orch/auth/" +] + +const volatileCodexIgnorePatterns: ReadonlyArray = [ + "**/.orch/auth/codex/log/", + "**/.orch/auth/codex/tmp/", + "**/.orch/auth/codex/sessions/", + "**/.orch/auth/codex/models_cache.json" +] + +const defaultStateGitignore = [ + stateGitignoreMarker, + "# NOTE: this repo intentionally tracks EVERYTHING under the state dir, including .orch/env and .orch/auth.", + "# Keep the remote private; treat it as sensitive infrastructure state.", + "", + "# Volatile Codex artifacts (do not commit)", + ...volatileCodexIgnorePatterns, + "" +].join("\n") + +const normalizeGitignoreText = (text: string): string => + text + .replaceAll("\r\n", "\n") + .trim() + +export const ensureStateGitignore = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const gitignorePath = path.join(root, ".gitignore") + const exists = yield* _(fs.exists(gitignorePath)) + if (!exists) { + yield* _(fs.writeFileString(gitignorePath, defaultStateGitignore)) + return + } + + const stat = yield* _(fs.stat(gitignorePath)) + if (stat.type !== "File") { + yield* _(Effect.logWarning(`${gitignorePath} exists but is not a file; skipping`)) + return + } + + const prev = yield* _(fs.readFileString(gitignorePath)) + const normalized = normalizeGitignoreText(prev) + if (!normalized.startsWith(stateGitignoreMarker)) { + return + } + + // If the file is docker-git managed but still ignores secrets (legacy default), rewrite it. + const prevLines = new Set(prev.replaceAll("\r", "").split("\n").map((l) => l.trimEnd())) + const hasLegacySecretIgnores = legacySecretIgnorePatterns.some((p) => prevLines.has(p)) + if (hasLegacySecretIgnores) { + yield* _(fs.writeFileString(gitignorePath, defaultStateGitignore)) + return + } + + // Ensure volatile Codex artifacts are ignored; append if missing. + const missingVolatile = volatileCodexIgnorePatterns.filter((p) => !prevLines.has(p)) + if (missingVolatile.length === 0) { + return + } + const next = `${prev.trimEnd()}\n\n# Volatile Codex artifacts (do not commit)\n${missingVolatile.join("\n")}\n` + yield* _(fs.writeFileString(gitignorePath, next)) + }) diff --git a/packages/lib/src/usecases/state-repo/sync-ops.ts b/packages/lib/src/usecases/state-repo/sync-ops.ts new file mode 100644 index 00000000..104b5997 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/sync-ops.ts @@ -0,0 +1,139 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" +import { CommandFailedError } from "../../shell/errors.js" +import { normalizeLegacyStateProjects } from "../state-normalize.js" +import { defaultSyncMessage } from "./env.js" +import { git, gitCapture, gitExitCode, successExitCode } from "./git-commands.js" +import type { GitAuthEnv } from "./github-auth.js" +import { tryBuildGithubCompareUrl, withGithubAskpassEnv } from "./github-auth.js" + +type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + +const commitAllIfNeeded = ( + root: string, + message: string, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(git(root, ["add", "-A"], env)) + const diffExit = yield* _(gitExitCode(root, ["diff", "--cached", "--quiet"], env)) + if (diffExit === successExitCode) { + return + } + yield* _(git(root, ["commit", "-m", message], env)) + }) + +const sanitizeBranchComponent = (value: string): string => + value + .trim() + .replaceAll(" ", "-") + .replaceAll(":", "-") + .replaceAll("..", "-") + .replaceAll("@{", "-") + .replaceAll("\\", "-") + .replaceAll("^", "-") + .replaceAll("~", "-") + +const rebaseOntoOriginIfPossible = ( + root: string, + baseBranch: string, + env: GitAuthEnv +): Effect.Effect<"ok" | "skipped" | "conflict", CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => + Effect.gen(function*(_) { + // Ensure we see the latest remote branch tip before attempting to rebase. + const fetchExit = yield* _(gitExitCode(root, ["fetch", "origin", "--prune"], env)) + if (fetchExit !== successExitCode) { + return yield* _(Effect.fail(new CommandFailedError({ command: "git fetch origin --prune", exitCode: fetchExit }))) + } + + const remoteRef = `refs/remotes/origin/${baseBranch}` + const hasRemoteBranchExit = yield* _(gitExitCode(root, ["show-ref", "--verify", "--quiet", remoteRef], env)) + if (hasRemoteBranchExit !== successExitCode) { + return "skipped" + } + + const rebaseExit = yield* _(gitExitCode(root, ["rebase", `origin/${baseBranch}`], env)) + if (rebaseExit === successExitCode) { + return "ok" + } + + // Best-effort: avoid leaving the repo in a rebase-in-progress state. + yield* _(gitExitCode(root, ["rebase", "--abort"], env)) + return "conflict" + }) + +const pushToNewBranch = ( + root: string, + baseBranch: string, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + const headShort = yield* _( + gitCapture(root, ["rev-parse", "--short", "HEAD"], env).pipe(Effect.map((value) => value.trim())) + ) + const timestamp = yield* _(Effect.sync(() => new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"))) + const branch = sanitizeBranchComponent(`state-sync/${baseBranch}/${timestamp}-${headShort}`) + + yield* _(git(root, ["push", "origin", `HEAD:refs/heads/${branch}`], env)) + return branch + }) + +const resolveBaseBranch = (value: string): string => (value === "HEAD" ? "main" : value) + +const getCurrentBranch = ( + root: string, + env: GitAuthEnv +): Effect.Effect => + gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env).pipe(Effect.map((value) => value.trim())) + +export const runStateSyncOps = ( + root: string, + originUrl: string, + message: string | null, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(normalizeLegacyStateProjects(root)) + const commitMessage = message && message.trim().length > 0 ? message.trim() : defaultSyncMessage + yield* _(commitAllIfNeeded(root, commitMessage, env)) + + const branch = yield* _(getCurrentBranch(root, env)) + const baseBranch = resolveBaseBranch(branch) + + const rebaseResult = yield* _(rebaseOntoOriginIfPossible(root, baseBranch, env)) + if (rebaseResult === "conflict") { + const prBranch = yield* _(pushToNewBranch(root, baseBranch, env)) + const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) + + yield* _(Effect.logWarning(`State sync needs manual merge: pushed changes to branch '${prBranch}'.`)) + yield* (compareUrl + ? _(Effect.log(`Open PR: ${compareUrl}`)) + : _(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`))) + return + } + + const pushExit = yield* _(gitExitCode(root, ["push", "-u", "origin", "HEAD"], env)) + if (pushExit === successExitCode) { + return + } + + const prBranch = yield* _(pushToNewBranch(root, baseBranch, env)) + const compareUrl = tryBuildGithubCompareUrl(originUrl, baseBranch, prBranch) + yield* _(Effect.logWarning(`State push failed (exit ${pushExit}); pushed changes to branch '${prBranch}'.`)) + if (compareUrl) { + yield* _(Effect.log(`Open PR: ${compareUrl}`)) + return + } + yield* _(Effect.log(`Open PR from '${prBranch}' into '${baseBranch}' (origin: ${originUrl}).`)) + }).pipe(Effect.asVoid) + +export const runStateSyncWithToken = ( + token: string, + root: string, + originUrl: string, + message: string | null +): Effect.Effect => + withGithubAskpassEnv(token, (env) => runStateSyncOps(root, originUrl, message, env)) From 2d2018aad61fa3a951e28db5378593180b77fc71 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 10 Feb 2026 22:07:34 +0000 Subject: [PATCH 6/6] fix(ci): fail on lib lint errors --- .github/workflows/check.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d4dfbd6f..3280d06c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -51,8 +51,7 @@ jobs: - name: Install global linter dependencies run: npm install -g typescript @biomejs/biome - run: pnpm lint - - name: Lint (lib, informational) - continue-on-error: true + - name: Lint (lib) run: pnpm --filter @effect-template/lib lint test: