From 046a3ddde6a346bc5bfe927ff63bc25907b9def4 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:13:48 +0000 Subject: [PATCH 1/3] refactor(container): extract container definition into a dedicated package (#412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the container definition from the panel and the backend. - New leaf package @prover-coder-ai/docker-git-container owns the pure rendering layer (planFiles → Dockerfile/entrypoint.sh/docker-compose.yml, TemplateConfig, resolveCompose* helpers). Zero deps on shell/usecases/api/app. - packages/lib (backend) now depends on it and re-exports the moved symbols, keeping the @effect-template/lib public API unchanged. - Panel: removed the dead duplicate packages/app/src/lib (165 files), its @lib / @effect-template/lib aliases and unused dependency. The no-lib-imports ESLint rule now forbids the panel from importing the backend OR the container-definition package. No runtime behaviour change: generated container files are byte-identical, guaranteed by the unchanged property-based template test suite (moved to the new package). Dependency graph stays acyclic: container <- lib <- api. Co-Authored-By: Claude Opus 4.8 --- .changeset/separate-container-definition.md | 24 ++ bun.lock | 54 ++- package.json | 1 + packages/api/package.json | 6 +- packages/app/eslint/no-lib-imports.mjs | 12 +- packages/app/package.json | 13 +- .../app/src/docker-git/api-terminal-codec.ts | 3 +- .../src/docker-git/browser-frontend-state.ts | 1 - .../app/src/docker-git/controller-docker.ts | 2 +- .../docker-git/controller-image-revision.ts | 4 +- .../frontend-lib/core/auto-agent-flags.ts | 2 +- packages/app/src/docker-git/host-errors.ts | 9 +- packages/app/src/docker-git/menu-auth.ts | 4 +- .../app/src/docker-git/menu-project-auth.ts | 8 +- .../src/docker-git/menu-select-presenter.ts | 2 +- packages/app/src/lib/core/auth-domain.ts | 191 --------- packages/app/src/lib/core/auto-agent-flags.ts | 28 -- packages/app/src/lib/core/clone.ts | 62 --- .../src/lib/core/command-builders-shared.ts | 199 --------- .../src/lib/core/command-builders-template.ts | 94 ----- packages/app/src/lib/core/command-builders.ts | 276 ------------ packages/app/src/lib/core/command-options.ts | 76 ---- .../app/src/lib/core/docker-git-scripts.ts | 29 -- packages/app/src/lib/core/docker-network.ts | 52 --- packages/app/src/lib/core/gpu.ts | 39 -- packages/app/src/lib/core/menu.ts | 113 ----- packages/app/src/lib/core/parse-errors.ts | 26 -- packages/app/src/lib/core/repo.ts | 396 ------------------ packages/app/src/lib/core/resource-limits.ts | 235 ----------- packages/app/src/lib/core/sessions-domain.ts | 25 -- packages/app/src/lib/core/state-domain.ts | 42 -- packages/app/src/lib/core/strings.ts | 17 - .../app/src/lib/core/templates-entrypoint.ts | 75 ---- .../lib/core/templates-entrypoint/agent.ts | 219 ---------- .../templates-entrypoint/agents-notice.ts | 140 ------- .../src/lib/core/templates-entrypoint/base.ts | 191 --------- .../claude-extra-config.ts | 147 ------- .../lib/core/templates-entrypoint/claude.ts | 279 ------------ .../templates-entrypoint/codex-resume-hint.ts | 100 ----- .../lib/core/templates-entrypoint/codex.ts | 195 --------- .../core/templates-entrypoint/dns-repair.ts | 51 --- .../lib/core/templates-entrypoint/gemini.ts | 346 --------------- .../core/templates-entrypoint/git-hooks.ts | 185 -------- .../git-post-push-wrapper.ts | 169 -------- .../src/lib/core/templates-entrypoint/grok.ts | 352 ---------------- .../templates-entrypoint/nested-docker-git.ts | 244 ----------- .../lib/core/templates-entrypoint/opencode.ts | 211 ---------- .../core/templates-entrypoint/plan-to-git.ts | 223 ---------- .../core/templates-entrypoint/post-push-pr.ts | 133 ------ .../templates-entrypoint/project-rules.ts | 82 ---- .../src/lib/core/templates-entrypoint/rtk.ts | 47 --- .../lib/core/templates-entrypoint/tasks.ts | 310 -------------- packages/app/src/lib/core/templates-prompt.ts | 344 --------------- .../src/lib/core/templates/docker-compose.ts | 304 -------------- .../lib/core/templates/dockerfile-prelude.ts | 117 ------ packages/app/src/lib/core/token-labels.ts | 53 --- packages/app/src/lib/index.ts | 21 - packages/app/src/lib/shell/ansi-strip.ts | 83 ---- packages/app/src/lib/shell/clone.ts | 95 ----- packages/app/src/lib/shell/command-runner.ts | 226 ---------- packages/app/src/lib/shell/config.ts | 165 -------- packages/app/src/lib/shell/docker-auth.ts | 342 --------------- .../app/src/lib/shell/docker-compose-env.ts | 43 -- packages/app/src/lib/shell/docker-compose.ts | 147 ------- .../app/src/lib/shell/docker-daemon-access.ts | 159 ------- .../app/src/lib/shell/docker-inspect-parse.ts | 15 - packages/app/src/lib/shell/docker-network.ts | 86 ---- .../src/lib/shell/docker-published-ports.ts | 82 ---- packages/app/src/lib/shell/docker-runtime.ts | 100 ----- packages/app/src/lib/shell/docker-volume.ts | 51 --- packages/app/src/lib/shell/docker.ts | 5 - packages/app/src/lib/shell/errors.ts | 101 ----- packages/app/src/lib/shell/files.ts | 274 ------------ packages/app/src/lib/shell/paths.ts | 23 - packages/app/src/lib/shell/ports.ts | 72 ---- packages/app/src/lib/shell/workspace-root.ts | 51 --- packages/app/src/lib/usecases/access-log.ts | 72 ---- packages/app/src/lib/usecases/actions.ts | 3 - .../actions/create-project-conflicts.ts | 179 -------- .../actions/create-project-open-ssh.ts | 117 ------ .../lib/usecases/actions/create-project.ts | 230 ---------- .../app/src/lib/usecases/actions/docker-up.ts | 284 ------------- .../app/src/lib/usecases/actions/paths.ts | 80 ---- .../app/src/lib/usecases/actions/ports.ts | 38 -- .../src/lib/usecases/actions/prepare-files.ts | 328 --------------- .../app/src/lib/usecases/agent-auto-select.ts | 182 -------- .../app/src/lib/usecases/apply-overrides.ts | 74 ---- .../lib/usecases/apply-project-discovery.ts | 212 ---------- packages/app/src/lib/usecases/apply.ts | 171 -------- .../app/src/lib/usecases/auth-claude-oauth.ts | 213 ---------- packages/app/src/lib/usecases/auth-claude.ts | 349 --------------- packages/app/src/lib/usecases/auth-codex.ts | 237 ----------- packages/app/src/lib/usecases/auth-copy.ts | 203 --------- .../src/lib/usecases/auth-gemini-helpers.ts | 328 --------------- .../src/lib/usecases/auth-gemini-logout.ts | 39 -- .../app/src/lib/usecases/auth-gemini-oauth.ts | 351 ---------------- .../src/lib/usecases/auth-gemini-status.ts | 32 -- packages/app/src/lib/usecases/auth-gemini.ts | 121 ------ packages/app/src/lib/usecases/auth-git.ts | 196 --------- packages/app/src/lib/usecases/auth-github.ts | 334 --------------- packages/app/src/lib/usecases/auth-gitlab.ts | 293 ------------- .../lib/usecases/auth-grok-credential-text.ts | 77 ---- .../app/src/lib/usecases/auth-grok-helpers.ts | 285 ------------- .../app/src/lib/usecases/auth-grok-logout.ts | 35 -- .../app/src/lib/usecases/auth-grok-oauth.ts | 164 -------- .../app/src/lib/usecases/auth-grok-status.ts | 33 -- packages/app/src/lib/usecases/auth-grok.ts | 98 ----- packages/app/src/lib/usecases/auth-helpers.ts | 81 ---- .../src/lib/usecases/auth-sync-claude-seed.ts | 135 ------ .../app/src/lib/usecases/auth-sync-helpers.ts | 177 -------- packages/app/src/lib/usecases/auth-sync.ts | 239 ----------- packages/app/src/lib/usecases/auth.ts | 7 - .../app/src/lib/usecases/auto-open-ssh.ts | 1 - .../app/src/lib/usecases/compose-env-files.ts | 52 --- packages/app/src/lib/usecases/docker-dns.ts | 56 --- .../lib/usecases/docker-git-config-search.ts | 83 ---- packages/app/src/lib/usecases/docker-image.ts | 98 ----- .../app/src/lib/usecases/docker-network-gc.ts | 178 -------- packages/app/src/lib/usecases/env-file.ts | 296 ------------- packages/app/src/lib/usecases/errors.ts | 228 ---------- .../src/lib/usecases/github-api-helpers.ts | 64 --- .../app/src/lib/usecases/github-auth-image.ts | 55 --- packages/app/src/lib/usecases/github-fork.ts | 131 ------ .../lib/usecases/github-token-preflight.ts | 203 --------- .../lib/usecases/github-token-validation.ts | 91 ---- packages/app/src/lib/usecases/gitlab-api.ts | 39 -- .../app/src/lib/usecases/gitlab-auth-image.ts | 56 --- .../lib/usecases/gitlab-token-preflight.ts | 180 -------- .../lib/usecases/gitlab-token-validation.ts | 79 ---- .../app/src/lib/usecases/mcp-playwright.ts | 92 ---- packages/app/src/lib/usecases/menu-helpers.ts | 52 --- packages/app/src/lib/usecases/path-helpers.ts | 231 ---------- .../app/src/lib/usecases/ports-reserve.ts | 157 ------- .../src/lib/usecases/projects-apply-all.ts | 99 ----- .../app/src/lib/usecases/projects-core.ts | 318 -------------- .../app/src/lib/usecases/projects-delete.ts | 122 ------ .../app/src/lib/usecases/projects-down.ts | 49 --- .../app/src/lib/usecases/projects-list.ts | 167 -------- packages/app/src/lib/usecases/projects-ssh.ts | 280 ------------- packages/app/src/lib/usecases/projects-up.ts | 267 ------------ packages/app/src/lib/usecases/projects.ts | 27 -- .../app/src/lib/usecases/resource-limits.ts | 20 - packages/app/src/lib/usecases/runtime.ts | 31 -- packages/app/src/lib/usecases/scrap-chunks.ts | 120 ------ packages/app/src/lib/usecases/scrap-common.ts | 104 ----- packages/app/src/lib/usecases/scrap-path.ts | 72 ---- .../src/lib/usecases/scrap-session-export.ts | 287 ------------- .../src/lib/usecases/scrap-session-import.ts | 295 ------------- .../lib/usecases/scrap-session-manifest.ts | 73 ---- .../app/src/lib/usecases/scrap-session.ts | 4 - packages/app/src/lib/usecases/scrap-types.ts | 30 -- packages/app/src/lib/usecases/scrap.ts | 27 -- .../src/lib/usecases/shared-volume-seed.ts | 308 -------------- packages/app/src/lib/usecases/ssh-access.ts | 206 --------- .../app/src/lib/usecases/state-normalize.ts | 137 ------ .../app/src/lib/usecases/state-repo-github.ts | 138 ------ packages/app/src/lib/usecases/state-repo.ts | 333 --------------- .../lib/usecases/state-repo/adopt-remote.ts | 68 --- .../lib/usecases/state-repo/auth-effects.ts | 72 ---- .../app/src/lib/usecases/state-repo/env.ts | 48 --- .../lib/usecases/state-repo/git-commands.ts | 57 --- .../usecases/state-repo/github-auth-state.ts | 71 ---- .../lib/usecases/state-repo/github-auth.ts | 168 -------- .../src/lib/usecases/state-repo/gitignore.ts | 112 ----- .../lib/usecases/state-repo/gitlab-auth.ts | 173 -------- .../src/lib/usecases/state-repo/local-ops.ts | 55 --- .../src/lib/usecases/state-repo/pull-push.ts | 104 ----- .../src/lib/usecases/state-repo/sync-ops.ts | 178 -------- .../app/src/lib/usecases/terminal-cursor.ts | 209 --------- .../app/src/lib/usecases/terminal-sessions.ts | 223 ---------- .../app/src/lib/usecases/volatile-files.ts | 53 --- .../app/src/web/app-ready-ssh-link-core.ts | 2 +- .../app/src/web/app-terminal-session-core.ts | 2 +- .../src/web/app-terminal-session-handlers.ts | 4 +- packages/app/test-adapters/core-templates.ts | 2 - .../tests/docker-git/core-templates.test.ts | 171 -------- .../app/tests/eslint/no-lib-imports.test.ts | 10 + packages/app/tsconfig.json | 4 +- packages/app/vite.config.ts | 8 - packages/app/vite.docker-git.config.ts | 12 - packages/app/vite.web.config.ts | 8 - packages/app/vitest.config.ts | 8 - packages/container/biome.json | 34 ++ packages/container/eslint.config.mts | 305 ++++++++++++++ .../eslint.effect-ts-check.config.mjs | 220 ++++++++++ packages/container/linter.config.json | 33 ++ packages/container/package.json | 83 ++++ .../src/lib => container/src}/core/domain.ts | 196 +-------- packages/container/src/core/index.ts | 6 + .../container/src/core/resource-limits.ts | 13 + .../src}/core/shell-literals.ts | 0 .../src}/core/template-defaults.ts | 2 - .../src/core/templates-entrypoint.ts | 0 .../src/core/templates-entrypoint/agent.ts | 50 ++- .../templates-entrypoint/agents-notice.ts | 7 +- .../src/core/templates-entrypoint/base.ts | 11 +- .../claude-extra-config.ts | 0 .../src/core/templates-entrypoint/claude.ts | 7 +- .../templates-entrypoint/codex-resume-hint.ts | 9 +- .../src/core/templates-entrypoint/codex.ts | 13 +- .../core/templates-entrypoint/dns-repair.ts | 0 .../src/core/templates-entrypoint/gemini.ts | 18 +- .../core/templates-entrypoint/git-hooks.ts | 13 +- .../git-post-push-wrapper.ts | 8 +- .../src}/core/templates-entrypoint/git.ts | 32 +- .../src/core/templates-entrypoint/grok.ts | 4 +- .../templates-entrypoint/nested-docker-git.ts | 16 +- .../src/core/templates-entrypoint/opencode.ts | 4 +- .../core/templates-entrypoint/plan-to-git.ts | 10 +- .../core/templates-entrypoint/post-push-pr.ts | 8 +- .../templates-entrypoint/project-rules.ts | 0 .../src/core/templates-entrypoint/rtk.ts | 4 +- .../src/core/templates-entrypoint/tasks.ts | 12 +- .../src/core/templates-prompt.ts | 2 +- .../src}/core/templates-zsh.ts | 0 .../lib => container/src}/core/templates.ts | 40 +- .../src/core/templates/docker-compose.ts | 96 +++-- .../src/core/templates/dockerfile-prelude.ts | 10 +- .../src}/core/templates/dockerfile.ts | 5 +- .../src}/core/templates/glab.ts | 0 .../src}/core/templates/tools.ts | 0 packages/container/src/index.ts | 1 + .../tests/core/git-post-push-wrapper.test.ts | 0 .../tests/core/templates.test.ts | 6 - packages/container/tsconfig.json | 15 + packages/container/vitest.config.ts | 83 ++++ packages/lib/package.json | 4 + packages/lib/src/core/auto-agent-flags.ts | 2 +- packages/lib/src/core/domain.ts | 153 ++----- packages/lib/src/core/resource-limits.ts | 13 +- packages/lib/src/core/shell-literals.ts | 22 - packages/lib/src/core/template-defaults.ts | 94 +---- .../lib/src/core/templates-entrypoint/git.ts | 271 ------------ packages/lib/src/core/templates-zsh.ts | 133 ------ packages/lib/src/core/templates.ts | 99 +---- packages/lib/src/core/templates/dockerfile.ts | 253 ----------- packages/lib/src/core/templates/glab.ts | 22 - packages/lib/src/core/templates/tools.ts | 52 --- packages/lib/src/usecases/errors.ts | 9 +- .../src/usecases/gitlab-token-preflight.ts | 2 +- packages/lib/src/usecases/projects-up.ts | 2 +- pnpm-workspace.yaml | 1 + 242 files changed, 1246 insertions(+), 23720 deletions(-) create mode 100644 .changeset/separate-container-definition.md delete mode 100644 packages/app/src/lib/core/auth-domain.ts delete mode 100644 packages/app/src/lib/core/auto-agent-flags.ts delete mode 100644 packages/app/src/lib/core/clone.ts delete mode 100644 packages/app/src/lib/core/command-builders-shared.ts delete mode 100644 packages/app/src/lib/core/command-builders-template.ts delete mode 100644 packages/app/src/lib/core/command-builders.ts delete mode 100644 packages/app/src/lib/core/command-options.ts delete mode 100644 packages/app/src/lib/core/docker-git-scripts.ts delete mode 100644 packages/app/src/lib/core/docker-network.ts delete mode 100644 packages/app/src/lib/core/gpu.ts delete mode 100644 packages/app/src/lib/core/menu.ts delete mode 100644 packages/app/src/lib/core/parse-errors.ts delete mode 100644 packages/app/src/lib/core/repo.ts delete mode 100644 packages/app/src/lib/core/resource-limits.ts delete mode 100644 packages/app/src/lib/core/sessions-domain.ts delete mode 100644 packages/app/src/lib/core/state-domain.ts delete mode 100644 packages/app/src/lib/core/strings.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/agent.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/agents-notice.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/base.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/claude.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/codex.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/dns-repair.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/gemini.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/git-hooks.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/grok.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/opencode.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/project-rules.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/rtk.ts delete mode 100644 packages/app/src/lib/core/templates-entrypoint/tasks.ts delete mode 100644 packages/app/src/lib/core/templates-prompt.ts delete mode 100644 packages/app/src/lib/core/templates/docker-compose.ts delete mode 100644 packages/app/src/lib/core/templates/dockerfile-prelude.ts delete mode 100644 packages/app/src/lib/core/token-labels.ts delete mode 100644 packages/app/src/lib/index.ts delete mode 100644 packages/app/src/lib/shell/ansi-strip.ts delete mode 100644 packages/app/src/lib/shell/clone.ts delete mode 100644 packages/app/src/lib/shell/command-runner.ts delete mode 100644 packages/app/src/lib/shell/config.ts delete mode 100644 packages/app/src/lib/shell/docker-auth.ts delete mode 100644 packages/app/src/lib/shell/docker-compose-env.ts delete mode 100644 packages/app/src/lib/shell/docker-compose.ts delete mode 100644 packages/app/src/lib/shell/docker-daemon-access.ts delete mode 100644 packages/app/src/lib/shell/docker-inspect-parse.ts delete mode 100644 packages/app/src/lib/shell/docker-network.ts delete mode 100644 packages/app/src/lib/shell/docker-published-ports.ts delete mode 100644 packages/app/src/lib/shell/docker-runtime.ts delete mode 100644 packages/app/src/lib/shell/docker-volume.ts delete mode 100644 packages/app/src/lib/shell/docker.ts delete mode 100644 packages/app/src/lib/shell/errors.ts delete mode 100644 packages/app/src/lib/shell/files.ts delete mode 100644 packages/app/src/lib/shell/paths.ts delete mode 100644 packages/app/src/lib/shell/ports.ts delete mode 100644 packages/app/src/lib/shell/workspace-root.ts delete mode 100644 packages/app/src/lib/usecases/access-log.ts delete mode 100644 packages/app/src/lib/usecases/actions.ts delete mode 100644 packages/app/src/lib/usecases/actions/create-project-conflicts.ts delete mode 100644 packages/app/src/lib/usecases/actions/create-project-open-ssh.ts delete mode 100644 packages/app/src/lib/usecases/actions/create-project.ts delete mode 100644 packages/app/src/lib/usecases/actions/docker-up.ts delete mode 100644 packages/app/src/lib/usecases/actions/paths.ts delete mode 100644 packages/app/src/lib/usecases/actions/ports.ts delete mode 100644 packages/app/src/lib/usecases/actions/prepare-files.ts delete mode 100644 packages/app/src/lib/usecases/agent-auto-select.ts delete mode 100644 packages/app/src/lib/usecases/apply-overrides.ts delete mode 100644 packages/app/src/lib/usecases/apply-project-discovery.ts delete mode 100644 packages/app/src/lib/usecases/apply.ts delete mode 100644 packages/app/src/lib/usecases/auth-claude-oauth.ts delete mode 100644 packages/app/src/lib/usecases/auth-claude.ts delete mode 100644 packages/app/src/lib/usecases/auth-codex.ts delete mode 100644 packages/app/src/lib/usecases/auth-copy.ts delete mode 100644 packages/app/src/lib/usecases/auth-gemini-helpers.ts delete mode 100644 packages/app/src/lib/usecases/auth-gemini-logout.ts delete mode 100644 packages/app/src/lib/usecases/auth-gemini-oauth.ts delete mode 100644 packages/app/src/lib/usecases/auth-gemini-status.ts delete mode 100644 packages/app/src/lib/usecases/auth-gemini.ts delete mode 100644 packages/app/src/lib/usecases/auth-git.ts delete mode 100644 packages/app/src/lib/usecases/auth-github.ts delete mode 100644 packages/app/src/lib/usecases/auth-gitlab.ts delete mode 100644 packages/app/src/lib/usecases/auth-grok-credential-text.ts delete mode 100644 packages/app/src/lib/usecases/auth-grok-helpers.ts delete mode 100644 packages/app/src/lib/usecases/auth-grok-logout.ts delete mode 100644 packages/app/src/lib/usecases/auth-grok-oauth.ts delete mode 100644 packages/app/src/lib/usecases/auth-grok-status.ts delete mode 100644 packages/app/src/lib/usecases/auth-grok.ts delete mode 100644 packages/app/src/lib/usecases/auth-helpers.ts delete mode 100644 packages/app/src/lib/usecases/auth-sync-claude-seed.ts delete mode 100644 packages/app/src/lib/usecases/auth-sync-helpers.ts delete mode 100644 packages/app/src/lib/usecases/auth-sync.ts delete mode 100644 packages/app/src/lib/usecases/auth.ts delete mode 100644 packages/app/src/lib/usecases/auto-open-ssh.ts delete mode 100644 packages/app/src/lib/usecases/compose-env-files.ts delete mode 100644 packages/app/src/lib/usecases/docker-dns.ts delete mode 100644 packages/app/src/lib/usecases/docker-git-config-search.ts delete mode 100644 packages/app/src/lib/usecases/docker-image.ts delete mode 100644 packages/app/src/lib/usecases/docker-network-gc.ts delete mode 100644 packages/app/src/lib/usecases/env-file.ts delete mode 100644 packages/app/src/lib/usecases/errors.ts delete mode 100644 packages/app/src/lib/usecases/github-api-helpers.ts delete mode 100644 packages/app/src/lib/usecases/github-auth-image.ts delete mode 100644 packages/app/src/lib/usecases/github-fork.ts delete mode 100644 packages/app/src/lib/usecases/github-token-preflight.ts delete mode 100644 packages/app/src/lib/usecases/github-token-validation.ts delete mode 100644 packages/app/src/lib/usecases/gitlab-api.ts delete mode 100644 packages/app/src/lib/usecases/gitlab-auth-image.ts delete mode 100644 packages/app/src/lib/usecases/gitlab-token-preflight.ts delete mode 100644 packages/app/src/lib/usecases/gitlab-token-validation.ts delete mode 100644 packages/app/src/lib/usecases/mcp-playwright.ts delete mode 100644 packages/app/src/lib/usecases/menu-helpers.ts delete mode 100644 packages/app/src/lib/usecases/path-helpers.ts delete mode 100644 packages/app/src/lib/usecases/ports-reserve.ts delete mode 100644 packages/app/src/lib/usecases/projects-apply-all.ts delete mode 100644 packages/app/src/lib/usecases/projects-core.ts delete mode 100644 packages/app/src/lib/usecases/projects-delete.ts delete mode 100644 packages/app/src/lib/usecases/projects-down.ts delete mode 100644 packages/app/src/lib/usecases/projects-list.ts delete mode 100644 packages/app/src/lib/usecases/projects-ssh.ts delete mode 100644 packages/app/src/lib/usecases/projects-up.ts delete mode 100644 packages/app/src/lib/usecases/projects.ts delete mode 100644 packages/app/src/lib/usecases/resource-limits.ts delete mode 100644 packages/app/src/lib/usecases/runtime.ts delete mode 100644 packages/app/src/lib/usecases/scrap-chunks.ts delete mode 100644 packages/app/src/lib/usecases/scrap-common.ts delete mode 100644 packages/app/src/lib/usecases/scrap-path.ts delete mode 100644 packages/app/src/lib/usecases/scrap-session-export.ts delete mode 100644 packages/app/src/lib/usecases/scrap-session-import.ts delete mode 100644 packages/app/src/lib/usecases/scrap-session-manifest.ts delete mode 100644 packages/app/src/lib/usecases/scrap-session.ts delete mode 100644 packages/app/src/lib/usecases/scrap-types.ts delete mode 100644 packages/app/src/lib/usecases/scrap.ts delete mode 100644 packages/app/src/lib/usecases/shared-volume-seed.ts delete mode 100644 packages/app/src/lib/usecases/ssh-access.ts delete mode 100644 packages/app/src/lib/usecases/state-normalize.ts delete mode 100644 packages/app/src/lib/usecases/state-repo-github.ts delete mode 100644 packages/app/src/lib/usecases/state-repo.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/adopt-remote.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/auth-effects.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/env.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/git-commands.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/github-auth-state.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/github-auth.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/gitignore.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/gitlab-auth.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/local-ops.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/pull-push.ts delete mode 100644 packages/app/src/lib/usecases/state-repo/sync-ops.ts delete mode 100644 packages/app/src/lib/usecases/terminal-cursor.ts delete mode 100644 packages/app/src/lib/usecases/terminal-sessions.ts delete mode 100644 packages/app/src/lib/usecases/volatile-files.ts delete mode 100644 packages/app/test-adapters/core-templates.ts delete mode 100644 packages/app/tests/docker-git/core-templates.test.ts create mode 100644 packages/container/biome.json create mode 100644 packages/container/eslint.config.mts create mode 100644 packages/container/eslint.effect-ts-check.config.mjs create mode 100644 packages/container/linter.config.json create mode 100644 packages/container/package.json rename packages/{app/src/lib => container/src}/core/domain.ts (51%) create mode 100644 packages/container/src/core/index.ts create mode 100644 packages/container/src/core/resource-limits.ts rename packages/{app/src/lib => container/src}/core/shell-literals.ts (100%) rename packages/{app/src/lib => container/src}/core/template-defaults.ts (97%) rename packages/{lib => container}/src/core/templates-entrypoint.ts (100%) rename packages/{lib => container}/src/core/templates-entrypoint/agent.ts (85%) rename packages/{lib => container}/src/core/templates-entrypoint/agents-notice.ts (97%) rename packages/{lib => container}/src/core/templates-entrypoint/base.ts (95%) rename packages/{lib => container}/src/core/templates-entrypoint/claude-extra-config.ts (100%) rename packages/{lib => container}/src/core/templates-entrypoint/claude.ts (98%) rename packages/{lib => container}/src/core/templates-entrypoint/codex-resume-hint.ts (95%) rename packages/{lib => container}/src/core/templates-entrypoint/codex.ts (96%) rename packages/{lib => container}/src/core/templates-entrypoint/dns-repair.ts (100%) rename packages/{lib => container}/src/core/templates-entrypoint/gemini.ts (96%) rename packages/{lib => container}/src/core/templates-entrypoint/git-hooks.ts (92%) rename packages/{lib => container}/src/core/templates-entrypoint/git-post-push-wrapper.ts (93%) rename packages/{app/src/lib => container/src}/core/templates-entrypoint/git.ts (91%) rename packages/{lib => container}/src/core/templates-entrypoint/grok.ts (99%) rename packages/{lib => container}/src/core/templates-entrypoint/nested-docker-git.ts (95%) rename packages/{lib => container}/src/core/templates-entrypoint/opencode.ts (98%) rename packages/{lib => container}/src/core/templates-entrypoint/plan-to-git.ts (94%) rename packages/{lib => container}/src/core/templates-entrypoint/post-push-pr.ts (95%) rename packages/{lib => container}/src/core/templates-entrypoint/project-rules.ts (100%) rename packages/{lib => container}/src/core/templates-entrypoint/rtk.ts (93%) rename packages/{lib => container}/src/core/templates-entrypoint/tasks.ts (98%) rename packages/{lib => container}/src/core/templates-prompt.ts (99%) rename packages/{app/src/lib => container/src}/core/templates-zsh.ts (100%) rename packages/{app/src/lib => container/src}/core/templates.ts (80%) rename packages/{lib => container}/src/core/templates/docker-compose.ts (83%) rename packages/{lib => container}/src/core/templates/dockerfile-prelude.ts (96%) rename packages/{app/src/lib => container/src}/core/templates/dockerfile.ts (99%) rename packages/{app/src/lib => container/src}/core/templates/glab.ts (100%) rename packages/{app/src/lib => container/src}/core/templates/tools.ts (100%) create mode 100644 packages/container/src/index.ts rename packages/{lib => container}/tests/core/git-post-push-wrapper.test.ts (100%) rename packages/{lib => container}/tests/core/templates.test.ts (99%) create mode 100644 packages/container/tsconfig.json create mode 100644 packages/container/vitest.config.ts delete mode 100644 packages/lib/src/core/shell-literals.ts delete mode 100644 packages/lib/src/core/templates-entrypoint/git.ts delete mode 100644 packages/lib/src/core/templates-zsh.ts delete mode 100644 packages/lib/src/core/templates/dockerfile.ts delete mode 100644 packages/lib/src/core/templates/glab.ts delete mode 100644 packages/lib/src/core/templates/tools.ts diff --git a/.changeset/separate-container-definition.md b/.changeset/separate-container-definition.md new file mode 100644 index 00000000..662a0889 --- /dev/null +++ b/.changeset/separate-container-definition.md @@ -0,0 +1,24 @@ +--- +"@prover-coder-ai/docker-git": patch +"@effect-template/lib": patch +--- + +Separate the container definition from the panel and the backend (issue #412). + +The container definition — the pure layer that renders a project's `Dockerfile`, +`entrypoint.sh` and `docker-compose.yml` from a `TemplateConfig` — has been +extracted from the backend package (`@effect-template/lib`) into a new, +dependency-free leaf package `@prover-coder-ai/docker-git-container`. The backend +now depends on it and re-exports the moved symbols, so its public API is +unchanged. + +The panel (`@prover-coder-ai/docker-git`) no longer carries a duplicate copy of +the container/backend logic: the dead `packages/app/src/lib` tree (165 files) and +its now-unused `@lib` / `@effect-template/lib` aliases and dependency were +removed. The `no-lib-imports` ESLint rule now forbids the panel from importing +either the backend or the container-definition package, keeping the boundary +enforced. + +No runtime behaviour changes: the generated container files are byte-identical +(guaranteed by the unchanged property-based template test suite, which moved to +the new package). diff --git a/bun.lock b/bun.lock index bf1b4666..b8fcbf7b 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "packages/api": { "name": "@effect-template/api", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@effect-template/lib": "workspace:*", "@effect/platform": "^0.96.1", @@ -41,7 +41,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.2.0", + "version": "1.3.4", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -69,7 +69,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.0", - "@effect-template/lib": "workspace:*", "@effect/eslint-plugin": "^0.3.2", "@effect/language-service": "latest", "@effect/vitest": "^0.29.0", @@ -108,9 +107,51 @@ "ws": "^8.21.0", }, }, + "packages/container": { + "name": "@prover-coder-ai/docker-git-container", + "version": "0.0.0", + "dependencies": { + "effect": "^3.21.3", + }, + "devDependencies": { + "@biomejs/biome": "^2.5.0", + "@effect/eslint-plugin": "^0.3.2", + "@effect/language-service": "latest", + "@effect/platform": "^0.96.1", + "@effect/platform-node": "^0.107.0", + "@effect/schema": "^0.75.5", + "@effect/vitest": "^0.29.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", + "@eslint/compat": "2.1.0", + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "10.0.1", + "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", + "@ton-ai-core/vibecode-linter": "^1.0.11", + "@types/node": "^25.9.3", + "@typescript-eslint/eslint-plugin": "^8.61.0", + "@typescript-eslint/parser": "^8.61.0", + "@vitest/coverage-v8": "^4.1.8", + "@vitest/eslint-plugin": "^1.6.20", + "eslint": "^10.5.0", + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-plugin-codegen": "0.34.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-simple-import-sort": "^13.0.0", + "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sort-destructure-keys": "^3.0.0", + "eslint-plugin-unicorn": "^65.0.1", + "fast-check": "^4.8.0", + "globals": "^17.6.0", + "jscpd": "^5.0.9", + "typescript": "^6.0.3", + "typescript-eslint": "^8.61.0", + "vite": "^8.0.16", + "vitest": "^4.1.8", + }, + }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.58", + "version": "1.0.62", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -139,6 +180,7 @@ "@effect/sql": "^0.51.1", "@effect/typeclass": "^0.40.0", "@effect/workflow": "^0.18.2", + "@prover-coder-ai/docker-git-container": "workspace:*", "effect": "^3.21.3", "ts-morph": "^28.0.0", }, @@ -178,7 +220,7 @@ }, "packages/terminal": { "name": "@prover-coder-ai/docker-git-terminal", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@effect/platform": "^0.96.1", "@effect/platform-node": "^0.107.0", @@ -597,6 +639,8 @@ "@prover-coder-ai/docker-git": ["@prover-coder-ai/docker-git@workspace:packages/app"], + "@prover-coder-ai/docker-git-container": ["@prover-coder-ai/docker-git-container@workspace:packages/container"], + "@prover-coder-ai/docker-git-session-sync": ["@prover-coder-ai/docker-git-session-sync@workspace:packages/docker-git-session-sync"], "@prover-coder-ai/docker-git-terminal": ["@prover-coder-ai/docker-git-terminal@workspace:packages/terminal"], diff --git a/package.json b/package.json index e4fbdf45..cc1246c6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "workspaces": [ "packages/api", "packages/app", + "packages/container", "packages/docker-git-session-sync", "packages/lib", "packages/terminal" diff --git a/packages/api/package.json b/packages/api/package.json index 40d420a3..e0505d63 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -7,15 +7,15 @@ "type": "module", "packageManager": "bun@1.3.11", "scripts": { - "prebuild": "bun run --cwd ../terminal build && bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../terminal build && bun run --cwd ../container build && bun run --cwd ../lib build", "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "prestart": "bun run build", "start": "bun dist/src/main.js", - "pretypecheck": "bun run --cwd ../terminal build && bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../terminal build && bun run --cwd ../container build && bun run --cwd ../lib build", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", - "pretest": "bun run --cwd ../terminal build && bun run --cwd ../lib build", + "pretest": "bun run --cwd ../terminal build && bun run --cwd ../container build && bun run --cwd ../lib build", "test": "vitest run" }, "dependencies": { diff --git a/packages/app/eslint/no-lib-imports.mjs b/packages/app/eslint/no-lib-imports.mjs index af0abc36..aa4d5124 100644 --- a/packages/app/eslint/no-lib-imports.mjs +++ b/packages/app/eslint/no-lib-imports.mjs @@ -1,6 +1,10 @@ // @ts-check -const bannedPackageName = "@effect-template/lib" +// CHANGE: forbid the panel from importing BOTH the backend and the container-definition packages (issue #412) +// WHY: packages/app is the panel; container orchestration lives in @effect-template/lib (backend) and +// container definition in @prover-coder-ai/docker-git-container. The panel must reach them only via the API client. +// REF: issue-412 +const bannedPackageNames = ["@effect-template/lib", "@prover-coder-ai/docker-git-container"] const bannedLocalAlias = "@lib" /** @param {string} value */ @@ -38,7 +42,7 @@ const isFrontendSurfaceFile = (filePath) => { /** @param {string} value */ const isDirectLibImport = (value) => - value === bannedPackageName || value.startsWith(`${bannedPackageName}/`) + bannedPackageNames.some((name) => value === name || value.startsWith(`${name}/`)) /** * @param {unknown} value @@ -198,12 +202,12 @@ export const noLibImportsRule = { type: "problem", docs: { description: - "forbid direct imports, re-exports, and require calls from legacy lib surfaces inside package/app frontend surfaces and tests" + "forbid direct imports, re-exports, and require calls from the backend (@effect-template/lib) or container-definition (@prover-coder-ai/docker-git-container) packages inside package/app frontend surfaces and tests" }, schema: [], messages: { noLibImport: - "Direct import or require '{{source}}' from legacy lib surfaces is forbidden in package/app frontend surfaces and tests. Use the API client or a local app adapter instead." + "Direct import or require '{{source}}' from the backend (@effect-template/lib) or container-definition (@prover-coder-ai/docker-git-container) packages is forbidden in package/app frontend surfaces and tests. Use the API client or a local app adapter instead." } }, create: createRuleListener diff --git a/packages/app/package.json b/packages/app/package.json index 29069780..5e3e6209 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,7 +13,7 @@ "doc": "doc" }, "scripts": { - "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build", "build": "bun run build:app && bun run build:docker-git", "build:app": "vite build --ssr src/app/main.ts", "build:web": "vite build --config vite.web.config.ts", @@ -22,13 +22,13 @@ "dev": "vite build --watch --ssr src/app/main.ts", "dev:web": "vite --config vite.web.config.ts", "serve:web": "bun scripts/serve-dist-web.mjs", - "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", + "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build", "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", "lint:tests": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter tests/", "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", - "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", + "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build", "build:docker-git": "vite build --config vite.docker-git.config.ts", - "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", + "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build", "build:docker-git:reuse-install": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", "clone": "bun run build:docker-git && bun dist/src/docker-git/main.js clone", @@ -37,9 +37,9 @@ "list": "bun run build:docker-git && bun dist/src/docker-git/main.js ps", "preview:web": "vite preview --config vite.web.config.ts", "start": "bun run build:docker-git && bun dist/src/docker-git/main.js", - "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", + "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build", "test": "bun run lint:tests && vitest run", - "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build", "typecheck": "tsc --noEmit" }, "repository": { @@ -87,7 +87,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.0", - "@effect-template/lib": "workspace:*", "@effect/eslint-plugin": "^0.3.2", "@effect/language-service": "latest", "@effect/vitest": "^0.29.0", diff --git a/packages/app/src/docker-git/api-terminal-codec.ts b/packages/app/src/docker-git/api-terminal-codec.ts index 488e6972..38b9c197 100644 --- a/packages/app/src/docker-git/api-terminal-codec.ts +++ b/packages/app/src/docker-git/api-terminal-codec.ts @@ -17,8 +17,7 @@ type RawTerminalSession = { const isTerminalSessionStatus = ( value: string -): value is ApiTerminalSession["status"] => - ["ready", "attached", "exited", "failed"].includes(value) +): value is ApiTerminalSession["status"] => ["ready", "attached", "exited", "failed"].includes(value) const readOptionalNumber = (value: JsonValue | undefined): number | undefined => typeof value === "number" ? value : undefined diff --git a/packages/app/src/docker-git/browser-frontend-state.ts b/packages/app/src/docker-git/browser-frontend-state.ts index 382f0bfb..2d332083 100644 --- a/packages/app/src/docker-git/browser-frontend-state.ts +++ b/packages/app/src/docker-git/browser-frontend-state.ts @@ -57,7 +57,6 @@ const browserFrontendRevisionInputs: ReadonlyArray = [ "packages/app/vite.web.config.ts", "packages/app/scripts/serve-dist-web.mjs", "packages/app/src/docker-git", - "packages/app/src/lib", "packages/app/src/shared", "packages/app/src/ui", "packages/app/src/web" diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 4b5fdc9e..5f695b54 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -37,7 +37,7 @@ export const controllerContainerName = process.env["DOCKER_GIT_API_CONTAINER_NAM const inspectNetworksTemplate = String .raw`{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\n" $k $v.IPAddress}}{{end}}` -const inspectEnvTemplate = String.raw`{{range .Config.Env}}{{println .}}{{end}}` +const inspectEnvTemplate = "{{range .Config.Env}}{{println .}}{{end}}" const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ _tag: "ControllerBootstrapError", diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index f95d49a3..d976fa20 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -9,8 +9,8 @@ import { type ControllerRuntime, runDockerCapture, runDockerCaptureWithFailureOu import { parseControllerRevisionLabelOutput } from "./controller-revision.js" import type { ControllerBootstrapError } from "./host-errors.js" -const inspectControllerRevisionLabelTemplate = String - .raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}` +const inspectControllerRevisionLabelTemplate = + "{{ index .Config.Labels \"io.prover-coder-ai.docker-git.controller-rev\" }}" const missingImageInspectionPatterns: ReadonlyArray = [/No such image/iu, /No such object/iu] /** diff --git a/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts b/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts index cec874a5..2e04c651 100644 --- a/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts +++ b/packages/app/src/docker-git/frontend-lib/core/auto-agent-flags.ts @@ -14,7 +14,7 @@ export const resolveAutoAgentFlags = ( if (requested === "auto") { return Either.right({ agentMode: undefined, agentAuto: true }) } - const agentModes: readonly AgentMode[] = ["claude", "codex", "gemini", "grok"] + const agentModes: ReadonlyArray = ["claude", "codex", "gemini", "grok"] const matchedMode = agentModes.find((mode) => mode === requested) if (matchedMode !== undefined) { return Either.right({ agentMode: matchedMode, agentAuto: true }) diff --git a/packages/app/src/docker-git/host-errors.ts b/packages/app/src/docker-git/host-errors.ts index 7badb4fd..7bfdfd0e 100644 --- a/packages/app/src/docker-git/host-errors.ts +++ b/packages/app/src/docker-git/host-errors.ts @@ -56,7 +56,14 @@ export type HostError = export type CliError = ParseError | HostError const isParseError = (error: CliError): error is ParseError => - ["UnknownCommand", "UnknownOption", "MissingOptionValue", "MissingRequiredOption", "InvalidOption", "UnexpectedArgument"].includes(error._tag) + [ + "UnknownCommand", + "UnknownOption", + "MissingOptionValue", + "MissingRequiredOption", + "InvalidOption", + "UnexpectedArgument" + ].includes(error._tag) const renderApiRequestError = (error: ApiRequestError): string => error.displayOnlyMessage === true diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts index a8f89d05..c8cd42fe 100644 --- a/packages/app/src/docker-git/menu-auth.ts +++ b/packages/app/src/docker-git/menu-auth.ts @@ -99,7 +99,9 @@ const submitAuthPrompt = (view: AuthPromptView, context: AuthInputContext) => { const label = defaultLabel(nextValues["label"] ?? "") const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues) runAuthPromptEffect(effect, view, label, { ...context, cwd: context.state.cwd }, { - suspendTui: ["GithubOauth", "CodexOauth", "ClaudeOauth", "ClaudeLogout", "GeminiOauth", "GrokOauth"].includes(view.flow) + suspendTui: ["GithubOauth", "CodexOauth", "ClaudeOauth", "ClaudeLogout", "GeminiOauth", "GrokOauth"].includes( + view.flow + ) }) } ) diff --git a/packages/app/src/docker-git/menu-project-auth.ts b/packages/app/src/docker-git/menu-project-auth.ts index 5bbba23e..cce0bb74 100644 --- a/packages/app/src/docker-git/menu-project-auth.ts +++ b/packages/app/src/docker-git/menu-project-auth.ts @@ -130,7 +130,13 @@ const runProjectAuthAction = ( } if ( - ["ProjectGithubDisconnect", "ProjectGitDisconnect", "ProjectClaudeDisconnect", "ProjectGeminiDisconnect", "ProjectGrokDisconnect"].includes(action) + [ + "ProjectGithubDisconnect", + "ProjectGitDisconnect", + "ProjectClaudeDisconnect", + "ProjectGeminiDisconnect", + "ProjectGrokDisconnect" + ].includes(action) ) { runProjectAuthEffect(view.project, action, {}, "default", context) return diff --git a/packages/app/src/docker-git/menu-select-presenter.ts b/packages/app/src/docker-git/menu-select-presenter.ts index 5cfb9625..657b88bd 100644 --- a/packages/app/src/docker-git/menu-select-presenter.ts +++ b/packages/app/src/docker-git/menu-select-presenter.ts @@ -44,7 +44,7 @@ const formatRepoRef = (repoRef: string): string => { const prPrefix = "refs/pull/" if (trimmed.startsWith(prPrefix)) { const rest = trimmed.slice(prPrefix.length) - const number = rest.split("/")[0] ?? rest + const number = rest.split("/", 1)[0] ?? rest return `PR#${number}` } return trimmed.length > 0 ? trimmed : "main" diff --git a/packages/app/src/lib/core/auth-domain.ts b/packages/app/src/lib/core/auth-domain.ts deleted file mode 100644 index 977afa50..00000000 --- a/packages/app/src/lib/core/auth-domain.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* jscpd:ignore-start */ -export interface AuthGithubLoginCommand { - readonly _tag: "AuthGithubLogin" - readonly label: string | null - readonly token: string | null - readonly scopes: string | null - readonly envGlobalPath: string -} - -export interface AuthGithubStatusCommand { - readonly _tag: "AuthGithubStatus" - readonly envGlobalPath: string -} - -export interface AuthGithubLogoutCommand { - readonly _tag: "AuthGithubLogout" - readonly label: string | null - readonly envGlobalPath: string -} - -export interface AuthGitlabLoginCommand { - readonly _tag: "AuthGitlabLogin" - readonly label: string | null - readonly token: string | null - readonly envGlobalPath: string -} - -export interface AuthGitlabStatusCommand { - readonly _tag: "AuthGitlabStatus" - readonly envGlobalPath: string -} - -export interface AuthGitlabLogoutCommand { - readonly _tag: "AuthGitlabLogout" - readonly label: string | null - readonly envGlobalPath: string -} - -// CHANGE: add generic git host auth commands -// WHY: issue #368 requires connecting git providers other than github/gitlab via token -// QUOTE(ТЗ): "реализовать возможность добавлять git подключения отличных от gitlab, github" -// REF: issue-368 -// SOURCE: https://git-scm.com/docs/gitcredentials -// FORMAT THEOREM: forall cmd ∈ AuthGitCommand: cmd.host normalizes to a token env key suffix -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: credentials are isolated per normalized host key -// COMPLEXITY: O(1) -export interface AuthGitLoginCommand { - readonly _tag: "AuthGitLogin" - readonly host: string - readonly token: string | null - readonly user: string | null - readonly envGlobalPath: string -} - -export interface AuthGitStatusCommand { - readonly _tag: "AuthGitStatus" - readonly envGlobalPath: string -} - -export interface AuthGitLogoutCommand { - readonly _tag: "AuthGitLogout" - readonly host: string - readonly envGlobalPath: string -} - -export interface AuthCodexLoginCommand { - readonly _tag: "AuthCodexLogin" - readonly label: string | null - readonly codexAuthPath: string -} - -export interface AuthCodexImportCommand { - readonly _tag: "AuthCodexImport" - readonly label: string | null - readonly codexAuthPath: string -} - -export interface AuthCodexStatusCommand { - readonly _tag: "AuthCodexStatus" - readonly label: string | null - readonly codexAuthPath: string -} - -export interface AuthCodexLogoutCommand { - readonly _tag: "AuthCodexLogout" - readonly label: string | null - readonly codexAuthPath: string -} - -export interface AuthClaudeLoginCommand { - readonly _tag: "AuthClaudeLogin" - readonly label: string | null - readonly claudeAuthPath: string -} - -export interface AuthClaudeStatusCommand { - readonly _tag: "AuthClaudeStatus" - readonly label: string | null - readonly claudeAuthPath: string -} - -export interface AuthClaudeLogoutCommand { - readonly _tag: "AuthClaudeLogout" - readonly label: string | null - readonly claudeAuthPath: string -} - -// CHANGE: add Gemini CLI auth commands -// WHY: enable Gemini CLI authentication management similar to Claude/Codex -// QUOTE(ТЗ): "Добавь поддержку gemini CLI" -// REF: issue-146 -// SOURCE: https://geminicli.com/docs/get-started/authentication/ -// FORMAT THEOREM: forall cmd ∈ AuthGeminiCommand: cmd.geminiAuthPath is valid path -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: authentication state is isolated by label -// COMPLEXITY: O(1) -export interface AuthGeminiLoginCommand { - readonly _tag: "AuthGeminiLogin" - readonly label: string | null - readonly geminiAuthPath: string - readonly isWeb: boolean -} - -export interface AuthGeminiStatusCommand { - readonly _tag: "AuthGeminiStatus" - readonly label: string | null - readonly geminiAuthPath: string -} - -export interface AuthGeminiLogoutCommand { - readonly _tag: "AuthGeminiLogout" - readonly label: string | null - readonly geminiAuthPath: string -} - -// CHANGE: add Grok CLI auth commands -// WHY: issue #304 requires Grok login/status/logout profiles with isolated auth storage -// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" -// REF: issue-304 -// SOURCE: https://x.ai/news/grok-build-cli -// FORMAT THEOREM: forall cmd ∈ AuthGrokCommand: cmd.grokAuthPath is valid path -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: authentication state is isolated by label -// COMPLEXITY: O(1) -export interface AuthGrokLoginCommand { - readonly _tag: "AuthGrokLogin" - readonly label: string | null - readonly grokAuthPath: string - readonly isWeb: boolean -} - -export interface AuthGrokStatusCommand { - readonly _tag: "AuthGrokStatus" - readonly label: string | null - readonly grokAuthPath: string -} - -export interface AuthGrokLogoutCommand { - readonly _tag: "AuthGrokLogout" - readonly label: string | null - readonly grokAuthPath: string -} - -export type AuthCommand = - | AuthGithubLoginCommand - | AuthGithubStatusCommand - | AuthGithubLogoutCommand - | AuthGitlabLoginCommand - | AuthGitlabStatusCommand - | AuthGitlabLogoutCommand - | AuthGitLoginCommand - | AuthGitStatusCommand - | AuthGitLogoutCommand - | AuthCodexLoginCommand - | AuthCodexImportCommand - | AuthCodexStatusCommand - | AuthCodexLogoutCommand - | AuthClaudeLoginCommand - | AuthClaudeStatusCommand - | AuthClaudeLogoutCommand - | AuthGeminiLoginCommand - | AuthGeminiStatusCommand - | AuthGeminiLogoutCommand - | AuthGrokLoginCommand - | AuthGrokStatusCommand - | AuthGrokLogoutCommand -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/auto-agent-flags.ts b/packages/app/src/lib/core/auto-agent-flags.ts deleted file mode 100644 index cec874a5..00000000 --- a/packages/app/src/lib/core/auto-agent-flags.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -import type { RawOptions } from "./command-options.js" -import type { AgentMode, ParseError } from "./domain.js" - -export const resolveAutoAgentFlags = ( - raw: RawOptions -): Either.Either<{ readonly agentMode: AgentMode | undefined; readonly agentAuto: boolean }, ParseError> => { - const requested = raw.agentAutoMode - if (requested === undefined) { - return Either.right({ agentMode: undefined, agentAuto: false }) - } - if (requested === "auto") { - return Either.right({ agentMode: undefined, agentAuto: true }) - } - const agentModes: readonly AgentMode[] = ["claude", "codex", "gemini", "grok"] - const matchedMode = agentModes.find((mode) => mode === requested) - if (matchedMode !== undefined) { - return Either.right({ agentMode: matchedMode, agentAuto: true }) - } - return Either.left({ - _tag: "InvalidOption", - option: "--auto", - reason: "expected one of: claude, codex, gemini, grok" - }) -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/clone.ts b/packages/app/src/lib/core/clone.ts deleted file mode 100644 index 6a61c5a8..00000000 --- a/packages/app/src/lib/core/clone.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* jscpd:ignore-start */ -export type CloneRequest = - | { readonly _tag: "Clone"; readonly args: ReadonlyArray } - | { readonly _tag: "Open"; readonly args: ReadonlyArray } - | { readonly _tag: "None" } - -const emptyRequest: CloneRequest = { _tag: "None" } - -const toCloneRequest = (args: ReadonlyArray): CloneRequest => ({ - _tag: "Clone", - args -}) - -const toOpenRequest = (args: ReadonlyArray): CloneRequest => ({ - _tag: "Open", - args -}) - -const resolveLifecycleArgs = ( - argv: ReadonlyArray, - command: "clone" | "open" -): ReadonlyArray => { - if (argv.length === 0) { - return [] - } - const [first, ...rest] = argv - return first === command ? rest : argv -} - -// CHANGE: resolve clone/open shortcut requests from argv + package lifecycle metadata -// WHY: support bun run clone/open without requiring "--" -// QUOTE(ТЗ): "Добавить команду open. ... Просто открывает существующий по ссылке" -// REF: user-request-2026-01-27 -// SOURCE: n/a -// FORMAT THEOREM: forall a,e: resolve(a,e) -> deterministic -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: command requested only when argv[0] or npmLifecycleEvent is clone/open -// COMPLEXITY: O(n) -export const resolveCloneRequest = ( - argv: ReadonlyArray, - npmLifecycleEvent: string | undefined -): CloneRequest => { - if (npmLifecycleEvent === "clone") { - return toCloneRequest(resolveLifecycleArgs(argv, "clone")) - } - - if (npmLifecycleEvent === "open") { - return toOpenRequest(resolveLifecycleArgs(argv, "open")) - } - - if (argv.length > 0 && argv[0] === "clone") { - return toCloneRequest(argv.slice(1)) - } - - if (argv.length > 0 && argv[0] === "open") { - return toOpenRequest(argv.slice(1)) - } - - return emptyRequest -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders-shared.ts b/packages/app/src/lib/core/command-builders-shared.ts deleted file mode 100644 index 0a015881..00000000 --- a/packages/app/src/lib/core/command-builders-shared.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -import { - type CreateCommand, - defaultTemplateConfig, - isDockerNetworkMode, - isGpuMode, - isUnixUsername, - type ParseError, - sshUsernamePatternDescription -} from "./domain.js" - -const parsePort = (value: string): Either.Either => { - const parsed = Number(value) - if (!Number.isInteger(parsed)) { - return Either.left({ - _tag: "InvalidOption", - option: "--ssh-port", - reason: `expected integer, got: ${value}` - }) - } - if (parsed < 1 || parsed > 65_535) { - return Either.left({ - _tag: "InvalidOption", - option: "--ssh-port", - reason: "must be between 1 and 65535" - }) - } - return Either.right(parsed) -} - -const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) || (code >= 97 && code <= 122) - -const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\" - -const rootPathLength = (value: string): number => { - if (isPathSeparator(value[0])) { - return 1 - } - if ( - value.length >= 3 && - isAsciiLetterCode(value.codePointAt(0) ?? 0) && - value[1] === ":" && - isPathSeparator(value[2]) - ) { - return 3 - } - return 0 -} - -/** - * Removes redundant trailing path separators while preserving filesystem roots. - * - * @param value - Path text decoded from CLI/config input. - * @returns The input without trailing `/` or `\\` separators unless the input is a root path. - * @pure true - * @effect none; CORE helper only scans the provided string. - * @invariant roots `/`, `\\`, `C:\\`, and `C:/` remain non-empty root paths. - * @precondition value is a string and may be empty or contain mixed separators. - * @postcondition non-root results do not end with `/` or `\\`; root results are preserved. - * @complexity O(n) time / O(1) space where n = |value|. - */ -export const trimTrailingPathSeparators = (value: string): string => { - let end = value.length - const minEnd = rootPathLength(value) - while (end > minEnd && isPathSeparator(value[end - 1])) { - end -= 1 - } - return value.slice(0, end) -} - -/** - * Parses a raw SSH port value into the valid Docker host-port range. - * - * @param value - Raw textual value for `--ssh-port`. - * @returns Either a valid integer port or a typed parse error for `--ssh-port`. - * @pure true - * @effect none; CORE parser only evaluates the provided string. - * @invariant Right(port) implies Number.isInteger(port) and 1 <= port <= 65535. - * @precondition value is untrusted CLI or config text. - * @postcondition the function returns a typed Either and never throws. - * @complexity O(1) time / O(1) space. - */ -export const parseSshPort = (value: string): Either.Either => parsePort(value) - -/** - * Parses and validates the SSH user used by generated Dockerfiles and entrypoints. - * - * @param value - Optional raw value for `--ssh-user`; undefined falls back to the default template user. - * @returns Either a Linux user name matching the docker-git invariant or a typed parse error. - * @pure true - * @effect none; CORE parser only trims and validates the candidate string. - * @invariant Right(user) implies user matches ^[a-z_][a-z0-9_-]{0,31}$. - * @precondition value is untrusted CLI or config text. - * @postcondition empty candidates fail as MissingRequiredOption; unsafe candidates fail as InvalidOption. - * @complexity O(n) time / O(1) space where n = |value|. - */ -export const parseSshUser = ( - value: string | undefined -): Either.Either => { - const candidate = value?.trim() ?? defaultTemplateConfig.sshUser - if (candidate.length === 0) { - return Either.left({ - _tag: "MissingRequiredOption", - option: "--ssh-user" - }) - } - if (!isUnixUsername(candidate)) { - return Either.left({ - _tag: "InvalidOption", - option: "--ssh-user", - reason: `expected Linux user name matching ${sshUsernamePatternDescription}` - }) - } - return Either.right(candidate) -} - -/** - * Parses the Docker network mode selector used by generated compose files. - * - * @param value - Optional raw value for `--network-mode`; undefined falls back to the template default. - * @returns Either a supported network mode or a typed parse error for `--network-mode`. - * @pure true - * @effect none; CORE parser only trims and checks a finite domain. - * @invariant Right(mode) implies mode is either "shared" or "project". - * @precondition value is untrusted CLI or config text. - * @postcondition unsupported modes fail as InvalidOption. - * @complexity O(n) time / O(1) space where n = |value|. - */ -export const parseDockerNetworkMode = ( - value: string | undefined -): Either.Either => { - const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode - if (isDockerNetworkMode(candidate)) { - return Either.right(candidate) - } - return Either.left({ - _tag: "InvalidOption", - option: "--network-mode", - reason: "expected one of: shared, project" - }) -} - -/** - * Parses the GPU mode selector used by generated compose files. - * - * @param value - Optional raw value for `--gpu`; undefined falls back to the template default. - * @returns Either a supported GPU mode or a typed parse error for `--gpu`. - * @pure true - * @effect none; CORE parser only trims and checks a finite domain. - * @invariant Right(mode) implies mode is either "none" or "all". - * @precondition value is untrusted CLI or config text. - * @postcondition unsupported modes fail as InvalidOption. - * @complexity O(n) time / O(1) space where n = |value|. - */ -export const parseGpuMode = ( - value: string | undefined -): Either.Either => { - const candidate = value?.trim() ?? defaultTemplateConfig.gpu - if (isGpuMode(candidate)) { - return Either.right(candidate) - } - return Either.left({ - _tag: "InvalidOption", - option: "--gpu", - reason: "expected one of: none, all" - }) -} - -/** - * Parses a required non-empty string option with an optional fallback. - * - * @param option - CLI option name reported in typed parse errors. - * @param value - Optional raw value supplied by the user. - * @param fallback - Optional default used when value is undefined. - * @returns Either the trimmed non-empty candidate or a typed missing-option error. - * @pure true - * @effect none; CORE parser only trims and checks string length. - * @invariant Right(candidate) implies candidate.length > 0. - * @precondition option names the boundary field being decoded. - * @postcondition missing or empty candidates fail as MissingRequiredOption. - * @complexity O(n) time / O(1) space where n = |value|. - */ -export const nonEmpty = ( - option: string, - value: string | undefined, - fallback?: string -): Either.Either => { - const candidate = value?.trim() ?? fallback - if (candidate === undefined || candidate.length === 0) { - return Either.left({ - _tag: "MissingRequiredOption", - option - }) - } - return Either.right(candidate) -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders-template.ts b/packages/app/src/lib/core/command-builders-template.ts deleted file mode 100644 index f54a214d..00000000 --- a/packages/app/src/lib/core/command-builders-template.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* jscpd:ignore-start */ -import type { NameConfig, PathConfig, RepoBasics } from "./command-builders.js" -import { type AgentMode, type CreateCommand, defaultTemplateConfig } from "./domain.js" - -export type BuildTemplateConfigInput = { - readonly repo: RepoBasics - readonly names: NameConfig - readonly paths: PathConfig - readonly cpuLimit: string | undefined - readonly ramLimit: string | undefined - readonly playwrightCpuLimit: string | undefined - readonly playwrightRamLimit: string | undefined - readonly gpu: CreateCommand["config"]["gpu"] - readonly dockerNetworkMode: CreateCommand["config"]["dockerNetworkMode"] - readonly dockerSharedNetworkName: string - readonly gitTokenLabel: string | undefined - readonly skipGithubAuth: boolean - readonly codexAuthLabel: string | undefined - readonly claudeAuthLabel: string | undefined - readonly geminiAuthLabel: string | undefined - readonly grokAuthLabel: string | undefined - readonly enableMcpPlaywright: boolean - readonly agentMode: AgentMode | undefined - readonly agentAuto: boolean - readonly clonedOnHostname?: string | undefined -} - -const buildTemplateConfigBase = ( - input: Pick -): Pick< - CreateCommand["config"], - | "containerName" - | "serviceName" - | "sshUser" - | "sshPort" - | "repoUrl" - | "repoRef" - | "targetDir" - | "volumeName" - | "dockerGitPath" - | "authorizedKeysPath" - | "envGlobalPath" - | "envProjectPath" - | "codexAuthPath" - | "codexSharedAuthPath" - | "codexHome" - | "geminiAuthPath" - | "geminiHome" - | "grokAuthPath" - | "grokHome" -> => ({ - containerName: input.names.containerName, - serviceName: input.names.serviceName, - sshUser: input.repo.sshUser, - sshPort: input.repo.sshPort, - repoUrl: input.repo.repoUrl, - repoRef: input.repo.repoRef, - targetDir: input.repo.targetDir, - volumeName: input.names.volumeName, - dockerGitPath: input.paths.dockerGitPath, - authorizedKeysPath: input.paths.authorizedKeysPath, - envGlobalPath: input.paths.envGlobalPath, - envProjectPath: input.paths.envProjectPath, - codexAuthPath: input.paths.codexAuthPath, - codexSharedAuthPath: input.paths.codexSharedAuthPath, - codexHome: input.paths.codexHome, - geminiAuthPath: input.paths.geminiAuthPath, - geminiHome: input.paths.geminiHome, - grokAuthPath: input.paths.grokAuthPath, - grokHome: input.paths.grokHome -}) - -export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateCommand["config"] => ({ - ...buildTemplateConfigBase(input), - gitTokenLabel: input.gitTokenLabel, - skipGithubAuth: input.skipGithubAuth, - codexAuthLabel: input.codexAuthLabel, - claudeAuthLabel: input.claudeAuthLabel, - geminiAuthLabel: input.geminiAuthLabel, - grokAuthLabel: input.grokAuthLabel, - cpuLimit: input.cpuLimit, - ramLimit: input.ramLimit, - playwrightCpuLimit: input.playwrightCpuLimit, - playwrightRamLimit: input.playwrightRamLimit, - gpu: input.gpu, - dockerNetworkMode: input.dockerNetworkMode, - dockerSharedNetworkName: input.dockerSharedNetworkName, - enableMcpPlaywright: input.enableMcpPlaywright, - bunVersion: defaultTemplateConfig.bunVersion, - agentMode: input.agentMode, - agentAuto: input.agentAuto, - clonedOnHostname: input.clonedOnHostname -}) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-builders.ts b/packages/app/src/lib/core/command-builders.ts deleted file mode 100644 index c27c77c1..00000000 --- a/packages/app/src/lib/core/command-builders.ts +++ /dev/null @@ -1,276 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -import { expandContainerHome } from "../usecases/scrap-path.js" -import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { - nonEmpty, - parseDockerNetworkMode, - parseGpuMode, - parseSshPort, - parseSshUser, - trimTrailingPathSeparators -} from "./command-builders-shared.js" -import { buildTemplateConfig } from "./command-builders-template.js" -import { type RawOptions } from "./command-options.js" -import { - type CreateCommand, - defaultTemplateConfig, - deriveRepoPathParts, - deriveRepoSlug, - type ParseError, - resolveRepoInput -} from "./domain.js" -import { resolveResourceLimitsIntent } from "./resource-limits.js" -import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js" - -export { nonEmpty } from "./command-builders-shared.js" - -const normalizeSecretsRoot = trimTrailingPathSeparators - -export type RepoBasics = { - readonly repoUrl: string - readonly repoSlug: string - readonly projectSlug: string - readonly repoPath: string - readonly repoRef: string - readonly targetDir: string - readonly sshUser: string - readonly sshPort: number -} - -const resolveRepoBasics = (raw: RawOptions): Either.Either => - Either.gen(function*(_) { - const rawRepoUrl = raw.repoUrl?.trim() ?? "" - const resolvedRepo = resolveRepoInput(rawRepoUrl) - const repoUrl = resolvedRepo.repoUrl - const repoSlug = deriveRepoSlug(repoUrl) - const repoPathParts = deriveRepoPathParts(repoUrl).pathParts - const workspaceSuffix = resolvedRepo.workspaceSuffix - const projectSlug = workspaceSuffix ? `${repoSlug}-${workspaceSuffix}` : repoSlug - const repoPath = workspaceSuffix ? [...repoPathParts, workspaceSuffix].join("/") : repoPathParts.join("/") - const repoRef = yield* _( - nonEmpty("--repo-ref", raw.repoRef ?? resolvedRepo.repoRef, defaultTemplateConfig.repoRef) - ) - const sshUser = yield* _(parseSshUser(raw.sshUser)) - const rawTargetDir = yield* _( - nonEmpty("--target-dir", raw.targetDir, defaultTemplateConfig.targetDir) - ) - const targetDir = expandContainerHome(sshUser, rawTargetDir) - const sshPort = yield* _(parseSshPort(raw.sshPort ?? String(defaultTemplateConfig.sshPort))) - - return { repoUrl, repoSlug, projectSlug, repoPath, repoRef, targetDir, sshUser, sshPort } - }) - -export type NameConfig = { - readonly containerName: string - readonly serviceName: string - readonly volumeName: string -} - -const resolveNames = ( - raw: RawOptions, - projectSlug: string -): Either.Either => - Either.gen(function*(_) { - const derivedContainerName = `dg-${projectSlug}` - const derivedServiceName = `dg-${projectSlug}` - const derivedVolumeName = `dg-${projectSlug}-home` - const containerName = yield* _( - nonEmpty("--container-name", raw.containerName, derivedContainerName) - ) - const serviceName = yield* _(nonEmpty("--service-name", raw.serviceName, derivedServiceName)) - const volumeName = yield* _(nonEmpty("--volume-name", raw.volumeName, derivedVolumeName)) - - return { containerName, serviceName, volumeName } - }) - -export type PathConfig = { - readonly dockerGitPath: string - readonly authorizedKeysPath: string - readonly envGlobalPath: string - readonly envProjectPath: string - readonly codexAuthPath: string - readonly codexSharedAuthPath: string - readonly codexHome: string - readonly geminiAuthPath: string - readonly geminiHome: string - readonly grokAuthPath: string - readonly grokHome: string - readonly outDir: string -} - -type DefaultPathConfig = { - readonly dockerGitPath: string - readonly authorizedKeysPath: string - readonly envGlobalPath: string - readonly envProjectPath: string - readonly codexAuthPath: string - readonly geminiAuthPath: string - readonly grokAuthPath: string -} - -const resolveNormalizedSecretsRoot = (value: string | undefined): string | undefined => { - const trimmed = value?.trim() ?? "" - return trimmed.length === 0 ? undefined : normalizeSecretsRoot(trimmed) -} - -const joinSecretsRootPath = (root: string, child: string): string => - root.endsWith("/") || root.endsWith("\\") ? `${root}${child}` : `${root}/${child}` - -const buildDefaultPathConfig = ( - normalizedSecretsRoot: string | undefined -): DefaultPathConfig => - normalizedSecretsRoot === undefined - ? { - dockerGitPath: defaultTemplateConfig.dockerGitPath, - authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath, - envGlobalPath: defaultTemplateConfig.envGlobalPath, - envProjectPath: defaultTemplateConfig.envProjectPath, - codexAuthPath: defaultTemplateConfig.codexAuthPath, - geminiAuthPath: defaultTemplateConfig.geminiAuthPath, - grokAuthPath: defaultTemplateConfig.grokAuthPath - } - : { - // NOTE: Keep docker-git root mount stable (projects root) so caches like - // `.cache/git-mirrors` remain outside the secrets dir. - dockerGitPath: defaultTemplateConfig.dockerGitPath, - authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath, - envGlobalPath: joinSecretsRootPath(normalizedSecretsRoot, "global.env"), - envProjectPath: defaultTemplateConfig.envProjectPath, - codexAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "codex"), - geminiAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "gemini"), - grokAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "grok") - } - -const resolvePaths = ( - raw: RawOptions, - repoPath: string -): Either.Either => - Either.gen(function*(_) { - const normalizedSecretsRoot = resolveNormalizedSecretsRoot(raw.secretsRoot) - const defaults = buildDefaultPathConfig(normalizedSecretsRoot) - const dockerGitPath = defaults.dockerGitPath - const authorizedKeysPath = yield* _( - nonEmpty("--authorized-keys", raw.authorizedKeysPath, defaults.authorizedKeysPath) - ) - const envGlobalPath = yield* _(nonEmpty("--env-global", raw.envGlobalPath, defaults.envGlobalPath)) - const envProjectPath = yield* _( - nonEmpty("--env-project", raw.envProjectPath, defaults.envProjectPath) - ) - const codexAuthPath = yield* _( - nonEmpty("--codex-auth", raw.codexAuthPath, defaults.codexAuthPath) - ) - const codexSharedAuthPath = codexAuthPath - const codexHome = yield* _(nonEmpty("--codex-home", raw.codexHome, defaultTemplateConfig.codexHome)) - const geminiAuthPath = defaults.geminiAuthPath - const geminiHome = defaultTemplateConfig.geminiHome - const grokAuthPath = defaults.grokAuthPath - const grokHome = defaultTemplateConfig.grokHome - const outDir = yield* _(nonEmpty("--out-dir", raw.outDir, `.docker-git/${repoPath}`)) - - return { - dockerGitPath, - authorizedKeysPath, - envGlobalPath, - envProjectPath, - codexAuthPath, - codexSharedAuthPath, - codexHome, - geminiAuthPath, - geminiHome, - grokAuthPath, - grokHome, - outDir - } - }) - -type CreateBehavior = { - readonly runUp: boolean - readonly openSsh: boolean - readonly skipGithubAuth: boolean - readonly force: boolean - readonly forceEnv: boolean - readonly enableMcpPlaywright: boolean -} - -const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ - runUp: raw.up ?? true, - openSsh: raw.openSsh ?? false, - skipGithubAuth: raw.skipGithubAuth ?? false, - force: raw.force ?? false, - forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false -}) - -type TokenLabelConfig = { - readonly gitTokenLabel: string | undefined - readonly codexAuthLabel: string | undefined - readonly claudeAuthLabel: string | undefined - readonly geminiAuthLabel: string | undefined - readonly grokAuthLabel: string | undefined -} - -const resolveTokenLabels = (raw: RawOptions): TokenLabelConfig => ({ - gitTokenLabel: normalizeGitTokenLabel(raw.gitTokenLabel), - codexAuthLabel: normalizeAuthLabel(raw.codexTokenLabel), - claudeAuthLabel: normalizeAuthLabel(raw.claudeTokenLabel), - geminiAuthLabel: normalizeAuthLabel(raw.geminiTokenLabel), - grokAuthLabel: normalizeAuthLabel(raw.grokTokenLabel) -}) - -// CHANGE: build a typed create command from raw options (CLI or API) -// WHY: share deterministic command construction across CLI and server -// QUOTE(ТЗ): "В lib ты оставляешь бизнес логику, а все CLI морду хранишь в app" -// REF: user-request-2026-02-02-cli-split -// SOURCE: n/a -// FORMAT THEOREM: forall raw: build(raw) -> deterministic(command) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: uses defaults for unset fields -// COMPLEXITY: O(1) -export const buildCreateCommand = ( - raw: RawOptions -): Either.Either => - Either.gen(function*(_) { - const repo = yield* _(resolveRepoBasics(raw)) - const names = yield* _(resolveNames(raw, repo.projectSlug)) - const paths = yield* _(resolvePaths(raw, repo.repoPath)) - const behavior = resolveCreateBehavior(raw) - const tokenLabels = resolveTokenLabels(raw) - const limits = yield* _(resolveResourceLimitsIntent(raw)) - const gpu = yield* _(parseGpuMode(raw.gpu)) - const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode)) - const dockerSharedNetworkName = yield* _( - nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName) - ) - const { agentAuto, agentMode } = yield* _(resolveAutoAgentFlags(raw)) - - return { - _tag: "Create", - outDir: paths.outDir, - runUp: behavior.runUp, - openSsh: behavior.openSsh, - force: behavior.force, - forceEnv: behavior.forceEnv, - waitForClone: false, - config: buildTemplateConfig({ - repo, - names, - paths, - cpuLimit: limits.cpuLimit, - ramLimit: limits.ramLimit, - playwrightCpuLimit: limits.playwrightCpuLimit, - playwrightRamLimit: limits.playwrightRamLimit, - gpu, - dockerNetworkMode, - dockerSharedNetworkName, - ...tokenLabels, - skipGithubAuth: behavior.skipGithubAuth, - enableMcpPlaywright: behavior.enableMcpPlaywright, - agentMode, - agentAuto - }) - } - }) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/command-options.ts b/packages/app/src/lib/core/command-options.ts deleted file mode 100644 index d0d1ec3a..00000000 --- a/packages/app/src/lib/core/command-options.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* jscpd:ignore-start */ -import { type ParseError } from "./domain.js" - -// CHANGE: define reusable command option shape for create/clone/auth builders -// WHY: decouple pure command construction from CLI parsing locations -// QUOTE(ТЗ): "В lib ты оставляешь бизнес логику, а все CLI морду хранишь в app" -// REF: user-request-2026-02-02-cli-split -// SOURCE: n/a -// FORMAT THEOREM: forall o: RawOptions -> deterministic(o) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: all fields are optional and represent raw user intent -// COMPLEXITY: O(1) -export interface RawOptions { - readonly repoUrl?: string - readonly repoRef?: string - readonly targetDir?: string - readonly sshPort?: string - readonly sshUser?: string - readonly containerName?: string - readonly serviceName?: string - readonly volumeName?: string - readonly secretsRoot?: string - readonly authorizedKeysPath?: string - readonly envGlobalPath?: string - readonly envProjectPath?: string - readonly codexAuthPath?: string - readonly codexHome?: string - readonly cpuLimit?: string - readonly ramLimit?: string - readonly playwrightCpuLimit?: string - readonly playwrightRamLimit?: string - readonly gpu?: string - readonly dockerNetworkMode?: string - readonly dockerSharedNetworkName?: string - readonly enableMcpPlaywright?: boolean - readonly archivePath?: string - readonly scrapMode?: string - readonly wipe?: boolean - readonly label?: string - readonly gitTokenLabel?: string - readonly codexTokenLabel?: string - readonly claudeTokenLabel?: string - readonly geminiTokenLabel?: string - readonly grokTokenLabel?: string - readonly token?: string - readonly scopes?: string - readonly host?: string - readonly user?: string - readonly message?: string - readonly authWeb?: boolean - readonly authOauth?: boolean - readonly outDir?: string - readonly projectDir?: string - readonly lines?: string - readonly includeDefault?: boolean - readonly up?: boolean - readonly openSsh?: boolean - readonly skipGithubAuth?: boolean - readonly force?: boolean - readonly forceEnv?: boolean - readonly agentAutoMode?: string -} - -// CHANGE: helper type alias for builder signatures that produce parse errors -// WHY: keep error typing consistent without CLI parsing -// QUOTE(ТЗ): "Ошибки типизированы" -// REF: user-request-2026-02-02-cli-split -// SOURCE: n/a -// FORMAT THEOREM: forall e: ParseError -> typed(e) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: ParseError tags are preserved -// COMPLEXITY: O(1) -export type CommandBuildError = ParseError -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/docker-git-scripts.ts b/packages/app/src/lib/core/docker-git-scripts.ts deleted file mode 100644 index 9e37f7ce..00000000 --- a/packages/app/src/lib/core/docker-git-scripts.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* jscpd:ignore-start */ -// CHANGE: define the set of docker-git scripts to embed in generated containers -// WHY: scripts (pre-commit guards, knowledge splitter) must be available -// inside containers for git hooks and docker-git module usage -// REF: issue-176 -// SOURCE: n/a -// FORMAT THEOREM: ∀ name ∈ dockerGitScriptNames: name ∈ scripts/ ∧ referenced_by_hooks(name) -// PURITY: CORE (pure constant definition) -// INVARIANT: list is exhaustive for all scripts referenced by generated git hooks -// COMPLEXITY: O(1) - -/** - * Names of docker-git scripts that must be available inside generated containers. - * - * These scripts are referenced by git hooks (pre-push, pre-commit). They are copied into - * each project's build context under - * `scripts/` and embedded into the Docker image at `/opt/docker-git/scripts/`. - * - * @pure true - * @invariant ∀ name ∈ result: ∃ file(scripts/{name}) in docker-git workspace - */ -export const dockerGitScriptNames: ReadonlyArray = [ - "pre-commit-secret-guard.sh", - "pre-push-knowledge-guard.js", - "split-knowledge-large-files.js", - "repair-knowledge-history.js", - "setup-pre-commit-hook.js" -] -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/docker-network.ts b/packages/app/src/lib/core/docker-network.ts deleted file mode 100644 index 701192e0..00000000 --- a/packages/app/src/lib/core/docker-network.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* jscpd:ignore-start */ -import { deriveRepoPathParts } from "./domain.js" - -export type DockerNetworkConfig = { - readonly subnet: string - readonly ipAddress: string -} - -const hashRepoSeed = (value: string): number => { - let hash = 0x81_1C_9D_C5 - for (const char of value) { - hash ^= char.codePointAt(0) ?? 0 - hash = Math.imul(hash, 0x01_00_01_93) - } - return hash >>> 0 -} - -// CHANGE: derive a stable docker DNS hostname from repo URL -// WHY: allow consistent per-project DNS aliases -// QUOTE(ТЗ): "docker.{dns}:port" -// REF: user-request-2026-01-30-dns -// SOURCE: n/a -// FORMAT THEOREM: forall url: dns(url) is deterministic -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: hostname always begins with docker. -// COMPLEXITY: O(n) where n = |url| -export const deriveDockerDnsName = (repoUrl: string): string => { - const parts = deriveRepoPathParts(repoUrl).pathParts - return ["docker", ...parts].join(".") -} - -// CHANGE: derive a stable docker subnet + IP for per-project isolation -// WHY: avoid port conflicts by giving each container a unique IP -// QUOTE(ТЗ): "У каждого контейнера свой IP т.е свой домен" -// REF: user-request-2026-01-30-dns -// SOURCE: n/a -// FORMAT THEOREM: forall url: net(url) is deterministic -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: subnet in 172.20.0.0/16..172.31.0.0/16, IP host in [10,209] -// COMPLEXITY: O(n) where n = |url| -export const deriveDockerNetworkConfig = (repoUrl: string): DockerNetworkConfig => { - const hash = hashRepoSeed(repoUrl) - const subnetA = 20 + (hash % 12) - const subnetB = (hash >>> 8) & 0xFF - const hostOctet = 10 + ((hash >>> 16) % 200) - const subnet = `172.${subnetA}.${subnetB}.0/24` - const ipAddress = `172.${subnetA}.${subnetB}.${hostOctet}` - return { subnet, ipAddress } -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/gpu.ts b/packages/app/src/lib/core/gpu.ts deleted file mode 100644 index 4dfbb580..00000000 --- a/packages/app/src/lib/core/gpu.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* jscpd:ignore-start */ -import type { GpuMode } from "./domain.js" - -const nvidiaFailureMarkers = [ - "nvidia-container-cli", - "libnvidia-ml.so.1", - "could not select device driver" -] - -// CHANGE: classify Docker/NVIDIA runtime failures from compose output. -// WHY: GPU device requests fail before the container entrypoint can run, so recovery must happen outside Docker. -// QUOTE(ТЗ): "nvidia-container-cli: initialization error: load library failed: libnvidia-ml.so.1" -// REF: issue-291 -// SOURCE: n/a -// FORMAT THEOREM: forall s: contains_nvidia_runtime_marker(s) -> nvidia_runtime_failure(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: detection is monotonic over output text; adding unrelated text cannot flip true to false -// COMPLEXITY: O(n * m) where n = |details| and m = marker count -export const isNvidiaRuntimeFailure = (details: string | undefined): boolean => { - const normalized = details?.toLowerCase() ?? "" - return nvidiaFailureMarkers.some((marker) => normalized.includes(marker)) -} - -// CHANGE: derive the safe GPU fallback mode after a Docker runtime failure. -// WHY: non-GPU containers must remain startable on hosts without a working NVIDIA userspace stack. -// QUOTE(ТЗ): "load library failed: libnvidia-ml.so.1" -// REF: issue-291 -// SOURCE: n/a -// FORMAT THEOREM: forall g,e: fallback(g,e) = none iff g = all and nvidia_runtime_failure(e) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: gpu=none is idempotent and never escalates to gpu=all -// COMPLEXITY: O(n * m) where n = |details| and m = marker count -export const gpuModeAfterDockerFailure = ( - gpu: GpuMode, - details: string | undefined -): GpuMode => gpu === "all" && isNvidiaRuntimeFailure(details) ? "none" : gpu -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/menu.ts b/packages/app/src/lib/core/menu.ts deleted file mode 100644 index e6e4f686..00000000 --- a/packages/app/src/lib/core/menu.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -export type MenuAction = - | { readonly _tag: "Create" } - | { readonly _tag: "Select" } - | { readonly _tag: "Auth" } - | { readonly _tag: "ProjectAuth" } - | { readonly _tag: "Info" } - | { readonly _tag: "Up" } - | { readonly _tag: "Status" } - | { readonly _tag: "Logs" } - | { readonly _tag: "Down" } - | { readonly _tag: "DownAll" } - | { readonly _tag: "Delete" } - | { readonly _tag: "Quit" } - -export type ParseError = - | { readonly _tag: "UnknownCommand"; readonly command: string } - | { readonly _tag: "UnknownOption"; readonly option: string } - | { readonly _tag: "MissingOptionValue"; readonly option: string } - | { readonly _tag: "MissingRequiredOption"; readonly option: string } - | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } - | { readonly _tag: "UnexpectedArgument"; readonly value: string } - -const normalizeMenuInput = (input: string): string => input.trim().toLowerCase() - -const menuAliasMap = new Map([ - ["1", { _tag: "Create" }], - ["create", { _tag: "Create" }], - ["c", { _tag: "Create" }], - ["2", { _tag: "Select" }], - ["select", { _tag: "Select" }], - ["s", { _tag: "Select" }], - ["3", { _tag: "Auth" }], - ["auth", { _tag: "Auth" }], - ["a", { _tag: "Auth" }], - ["4", { _tag: "ProjectAuth" }], - ["project-auth", { _tag: "ProjectAuth" }], - ["projectauth", { _tag: "ProjectAuth" }], - ["pa", { _tag: "ProjectAuth" }], - ["5", { _tag: "Info" }], - ["info", { _tag: "Info" }], - ["i", { _tag: "Info" }], - ["up", { _tag: "Up" }], - ["u", { _tag: "Up" }], - ["start", { _tag: "Up" }], - ["6", { _tag: "Status" }], - ["status", { _tag: "Status" }], - ["ps", { _tag: "Status" }], - ["7", { _tag: "Logs" }], - ["logs", { _tag: "Logs" }], - ["log", { _tag: "Logs" }], - ["l", { _tag: "Logs" }], - ["8", { _tag: "Down" }], - ["down", { _tag: "Down" }], - ["stop", { _tag: "Down" }], - ["d", { _tag: "Down" }], - ["9", { _tag: "DownAll" }], - ["down-all", { _tag: "DownAll" }], - ["downall", { _tag: "DownAll" }], - ["stop-all", { _tag: "DownAll" }], - ["stopall", { _tag: "DownAll" }], - ["kill-all", { _tag: "DownAll" }], - ["killall", { _tag: "DownAll" }], - ["da", { _tag: "DownAll" }], - ["10", { _tag: "Delete" }], - ["delete", { _tag: "Delete" }], - ["del", { _tag: "Delete" }], - ["remove", { _tag: "Delete" }], - ["rm", { _tag: "Delete" }], - ["0", { _tag: "Quit" }], - ["11", { _tag: "Quit" }], - ["quit", { _tag: "Quit" }], - ["q", { _tag: "Quit" }], - ["exit", { _tag: "Quit" }] -]) - -const resolveMenuAction = (normalized: string): MenuAction | undefined => menuAliasMap.get(normalized) - -// CHANGE: decode interactive menu input into a typed action -// WHY: keep menu parsing pure and reusable across shells -// QUOTE(ТЗ): "Хочу что бы открылось менюшка" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall s: parseMenu(s) = a -> deterministic(a) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: unknown input maps to InvalidOption -// COMPLEXITY: O(1) -export const parseMenuSelection = (input: string): Either.Either => { - const normalized = normalizeMenuInput(input) - - if (normalized.length === 0) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: "empty selection" - }) - } - - const action = resolveMenuAction(normalized) - if (action === undefined) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: `unknown selection: ${input}` - }) - } - - return Either.right(action) -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/parse-errors.ts b/packages/app/src/lib/core/parse-errors.ts deleted file mode 100644 index c519ee34..00000000 --- a/packages/app/src/lib/core/parse-errors.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* jscpd:ignore-start */ -import { Match } from "effect" - -import type { ParseError } from "./domain.js" - -// CHANGE: normalize parse errors into deterministic messages -// WHY: reuse parse error formatting across CLI and server flows -// QUOTE(ТЗ): "ошибки должны быть описывающими" -// REF: user-request-2026-02-02-cli-split -// SOURCE: n/a -// FORMAT THEOREM: forall e: format(e) = s -> deterministic(s) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: each ParseError maps to exactly one message -// COMPLEXITY: O(1) -export const formatParseError = (error: ParseError): string => - Match.value(error).pipe( - Match.when({ _tag: "UnknownCommand" }, ({ command }) => `Unknown command: ${command}`), - Match.when({ _tag: "UnknownOption" }, ({ option }) => `Unknown option: ${option}`), - Match.when({ _tag: "MissingOptionValue" }, ({ option }) => `Missing value for option: ${option}`), - Match.when({ _tag: "MissingRequiredOption" }, ({ option }) => `Missing required option: ${option}`), - Match.when({ _tag: "InvalidOption" }, ({ option, reason }) => `Invalid option ${option}: ${reason}`), - Match.when({ _tag: "UnexpectedArgument" }, ({ value }) => `Unexpected argument: ${value}`), - Match.exhaustive - ) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/repo.ts b/packages/app/src/lib/core/repo.ts deleted file mode 100644 index 24de094c..00000000 --- a/packages/app/src/lib/core/repo.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { trimLeftChar, trimRightChar } from "./strings.js" - -const slugify = (value: string): string => { - const normalized = value - .trim() - .toLowerCase() - .replaceAll(/[^a-z0-9_-]+/g, "-") - .replaceAll(/-+/g, "-") - const withoutLeading = trimLeftChar(normalized, "-") - const cleaned = trimRightChar(withoutLeading, "-") - - return cleaned.length > 0 ? cleaned : "app" -} - -// CHANGE: derive a stable repo slug from a repo URL -// WHY: generate deterministic container/service names per repository -// QUOTE(ТЗ): "по факту он должен создавтаь постоянно новый контейнер для нового репозитория" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall url: slug(url) = s -> deterministic(s) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: slug is lowercase and non-empty -// COMPLEXITY: O(n) where n = |url| -export const deriveRepoSlug = (repoUrl: string): string => { - const trimmed = trimRightChar(repoUrl.trim(), "/") - if (trimmed.length === 0) { - return "app" - } - - const lastSlash = trimmed.lastIndexOf("/") - const lastColon = trimmed.lastIndexOf(":") - const pivot = Math.max(lastSlash, lastColon) - const segment = pivot >= 0 ? trimmed.slice(pivot + 1) : trimmed - const withoutGit = segment.endsWith(".git") ? segment.slice(0, -4) : segment - - return slugify(withoutGit) -} - -type RepoPathParts = { - readonly ownerParts: ReadonlyArray - readonly repo: string - readonly pathParts: ReadonlyArray -} - -const stripGitSuffix = (segment: string): string => segment.endsWith(".git") ? segment.slice(0, -4) : segment - -const normalizePathParts = (pathPart: string): ReadonlyArray => { - const cleaned = trimLeftChar(pathPart, "/") - if (cleaned.length === 0) { - return [] - } - const rawParts = cleaned.split("/").filter(Boolean) - return rawParts.map((part, index) => index === rawParts.length - 1 ? stripGitSuffix(part) : part) -} - -const extractFromScheme = (trimmed: string): ReadonlyArray | null => { - const schemeIndex = trimmed.indexOf("://") - if (schemeIndex === -1) { - return null - } - const afterScheme = trimmed.slice(schemeIndex + 3) - const firstSlash = afterScheme.indexOf("/") - if (firstSlash === -1) { - return [] - } - return normalizePathParts(afterScheme.slice(firstSlash + 1)) -} - -const extractFromColon = (trimmed: string): ReadonlyArray | null => { - const colonIndex = trimmed.indexOf(":") - if (colonIndex === -1) { - return null - } - return normalizePathParts(trimmed.slice(colonIndex + 1)) -} - -const extractFromSlash = (trimmed: string): ReadonlyArray | null => { - const slashIndex = trimmed.indexOf("/") - if (slashIndex === -1) { - return null - } - return normalizePathParts(trimmed.slice(slashIndex + 1)) -} - -const extractRepoPathParts = (repoUrl: string): ReadonlyArray => { - const trimmed = trimRightChar(repoUrl.trim(), "/") - if (trimmed.length === 0) { - return [] - } - - const fromScheme = extractFromScheme(trimmed) - if (fromScheme !== null) { - return fromScheme - } - - const fromColon = extractFromColon(trimmed) - if (fromColon !== null) { - return fromColon - } - - const fromSlash = extractFromSlash(trimmed) - if (fromSlash !== null) { - return fromSlash - } - - return [stripGitSuffix(trimmed)] -} - -const normalizeRepoSegment = (segment: string, fallback: string): string => { - const normalized = slugify(segment) - return normalized.length > 0 ? normalized : fallback -} - -// CHANGE: derive stable owner/repo path parts from a repo URL -// WHY: avoid collisions when orgs have identical repo names -// QUOTE(ТЗ): "пути учитывают организацию в которой это лежит" -// REF: user-request-2026-01-27 -// SOURCE: n/a -// FORMAT THEOREM: forall url: parts(url) -> deterministic(parts) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: path parts are slugified and non-empty -// COMPLEXITY: O(n) where n = |url| -export const deriveRepoPathParts = (repoUrl: string): RepoPathParts => { - const repoSlug = deriveRepoSlug(repoUrl) - const rawParts = extractRepoPathParts(repoUrl) - if (rawParts.length === 0) { - return { ownerParts: [], repo: repoSlug, pathParts: [repoSlug] } - } - - const rawRepo = rawParts.at(-1) ?? repoSlug - const repo = normalizeRepoSegment(rawRepo, repoSlug) - const ownerParts = rawParts - .slice(0, -1) - .map((part) => normalizeRepoSegment(part, "org")) - .filter((part) => part.length > 0) - const pathParts = ownerParts.length > 0 ? [...ownerParts, repo] : [repo] - - return { ownerParts, repo, pathParts } -} - -export type GithubRepo = { - readonly owner: string - readonly repo: string -} - -export type GitlabRepo = { readonly namespace: string; readonly projectPath: string; readonly repo: string } - -const stripQueryHash = (value: string): string => { - const queryIndex = value.indexOf("?") - const hashIndex = value.indexOf("#") - const indices = [queryIndex, hashIndex].filter((index) => index >= 0) - if (indices.length === 0) { - return value - } - const cutIndex = Math.min(...indices) - return value.slice(0, cutIndex) -} - -const splitRemotePath = (input: string, host: string): ReadonlyArray | null => { - const trimmed = input.trim() - const httpsPrefix = `https://${host}/` - const sshPrefix = `ssh://git@${host}/` - const gitPrefix = `git@${host}:` - let rest: string | null = null - if (trimmed.startsWith(httpsPrefix)) { - rest = trimmed.slice(httpsPrefix.length) - } else if (trimmed.startsWith(sshPrefix)) { - rest = trimmed.slice(sshPrefix.length) - } else if (trimmed.startsWith(gitPrefix)) { - rest = trimmed.slice(gitPrefix.length) - } - if (rest === null) { - return null - } - const cleaned = trimRightChar(stripQueryHash(rest), "/") - if (cleaned.length === 0) { - return [] - } - return cleaned.split("/").filter((part) => part.length > 0) -} - -const splitGithubPath = (input: string): ReadonlyArray | null => splitRemotePath(input, "github.com") - -const splitGitlabPath = (input: string): ReadonlyArray | null => splitRemotePath(input, "gitlab.com") - -const readGitlabProjectPathParts = (parts: ReadonlyArray | null): ReadonlyArray => { - if (!parts) return [] - const separatorIndex = parts.indexOf("-") - return separatorIndex >= 2 ? parts.slice(0, separatorIndex) : parts -} - -// CHANGE: parse GitHub owner/repo from common URL formats -// WHY: enable auto-fork logic without relying on slugified paths -// QUOTE(ТЗ): "Сразу на issues и он бы делал форк репы если это надо" -// REF: user-request-2026-02-05-issues-fork -// SOURCE: n/a -// FORMAT THEOREM: ∀u: github(u) → repo(u) = {owner, repo} -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: returns null for non-GitHub inputs -// COMPLEXITY: O(n) where n = |input| -export const parseGithubRepoUrl = (input: string): GithubRepo | null => { - const parts = splitGithubPath(input) - if (!parts || parts.length < 2) { - return null - } - - const owner = parts[0]?.trim() - const repoRaw = parts[1]?.trim() - if (!owner || !repoRaw) { - return null - } - - const repo = stripGitSuffix(repoRaw) - return { owner, repo } -} - -export const parseGitlabRepoUrl = (input: string): GitlabRepo | null => { - const projectPathParts = readGitlabProjectPathParts(splitGitlabPath(input)) - const repoRaw = readRepoPathPart(projectPathParts.at(-1)) - const namespace = projectPathParts.slice(0, -1).join("/") - if (!repoRaw || namespace.length === 0) return null - - const repo = stripGitSuffix(repoRaw) - return { namespace, projectPath: `${namespace}/${repo}`, repo } -} - -export type ResolvedRepoInput = { - readonly repoUrl: string - readonly repoRef?: string - readonly workspaceSuffix?: string -} - -type GithubRefParts = { - readonly owner: string - readonly repoRaw: string - readonly marker: string - readonly ref: string -} - -type GitlabRefParts = { - readonly projectPathParts: ReadonlyArray - readonly marker: string - readonly ref: string -} - -const readRepoPathPart = (value: string | undefined): string | null => { - const trimmed = value?.trim() ?? "" - return trimmed.length > 0 ? trimmed : null -} - -const parseGithubRefParts = (input: string): GithubRefParts | null => { - const parts = splitGithubPath(input) - if (!parts || parts.length < 4) { - return null - } - const owner = readRepoPathPart(parts[0]) - const repoRaw = readRepoPathPart(parts[1]) - const markerRaw = readRepoPathPart(parts[2]) - const ref = readRepoPathPart(parts[3]) - if (!owner || !repoRaw || !markerRaw || !ref) { - return null - } - return { owner, repoRaw, marker: markerRaw.toLowerCase(), ref } -} - -const parseGitlabRefParts = (input: string): GitlabRefParts | null => { - const parts = splitGitlabPath(input) - if (!parts) return null - const separatorIndex = parts.indexOf("-") - const markerRaw = readRepoPathPart(parts[separatorIndex + 1]) - const ref = readRepoPathPart(parts[separatorIndex + 2]) - if (separatorIndex < 2 || !markerRaw || !ref) return null - - return { projectPathParts: parts.slice(0, separatorIndex), marker: markerRaw.toLowerCase(), ref } -} - -const normalizeGitlabProjectUrl = (projectPathParts: ReadonlyArray): string | null => { - const repoRaw = readRepoPathPart(projectPathParts.at(-1)) - if (!repoRaw) return null - - const namespaceParts = projectPathParts.slice(0, -1) - const repo = stripGitSuffix(repoRaw) - return `https://gitlab.com/${[...namespaceParts, repo].join("/")}.git` -} - -const parseGithubPrUrl = (input: string): ResolvedRepoInput | null => { - const parsed = parseGithubRefParts(input) - if (!parsed || parsed.marker !== "pull") { - return null - } - - const repo = stripGitSuffix(parsed.repoRaw) - const workspaceSuffix = `pr-${slugify(parsed.ref)}` - return { - repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, - repoRef: `refs/pull/${parsed.ref}/head`, - workspaceSuffix - } -} - -// CHANGE: normalize GitHub tree/blob URLs into repo + ref -// WHY: allow docker-git clone to accept branch URLs like /tree/ -// QUOTE(ТЗ): "вызови --force на https://github.com/agiens/crm/tree/vova-fork" -// REF: user-request-2026-02-10-github-tree-url -// SOURCE: n/a -// FORMAT THEOREM: ∀u: tree(u) → repo(u)=git(u) ∧ ref(u)=branch(u) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: ignores additional path segments after the ref -// COMPLEXITY: O(n) where n = |url| -const parseGithubTreeUrl = (input: string): ResolvedRepoInput | null => { - const parsed = parseGithubRefParts(input) - if (!parsed || (parsed.marker !== "tree" && parsed.marker !== "blob")) { - return null - } - - const repo = stripGitSuffix(parsed.repoRaw) - return { repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, repoRef: parsed.ref } -} - -const resolveGitlabRefUrl = ( - input: string, - markers: ReadonlyArray, - resolveRef: (ref: string) => string, - resolveWorkspaceSuffix?: (ref: string) => string -): ResolvedRepoInput | null => { - const parsed = parseGitlabRefParts(input) - if (!parsed || !markers.includes(parsed.marker)) return null - const repoUrl = normalizeGitlabProjectUrl(parsed.projectPathParts) - if (!repoUrl) return null - const workspaceSuffix = resolveWorkspaceSuffix?.(parsed.ref) - return workspaceSuffix - ? { repoUrl, repoRef: resolveRef(parsed.ref), workspaceSuffix } - : { repoUrl, repoRef: resolveRef(parsed.ref) } -} - -const parseGitlabTreeUrl = (input: string): ResolvedRepoInput | null => - resolveGitlabRefUrl(input, ["tree", "blob"], (ref) => ref) - -// CHANGE: normalize GitHub issue URLs into repo URLs -// WHY: allow docker-git clone to accept issue links directly -// QUOTE(ТЗ): "Сразу на issues" -// REF: user-request-2026-02-05-issues -// SOURCE: n/a -// FORMAT THEOREM: ∀u: issue(u) → repo(u) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: issue URL yields repoUrl + deterministic issue branch -// COMPLEXITY: O(n) where n = |url| -const parseGithubIssueUrl = (input: string): ResolvedRepoInput | null => { - const parsed = parseGithubRefParts(input) - if (!parsed || parsed.marker !== "issues") { - return null - } - - const repo = stripGitSuffix(parsed.repoRaw) - const workspaceSuffix = `issue-${slugify(parsed.ref)}` - return { - repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, - repoRef: workspaceSuffix, - workspaceSuffix - } -} - -const parseGitlabIssueUrl = (input: string): ResolvedRepoInput | null => - resolveGitlabRefUrl(input, ["issues"], (ref) => `issue-${slugify(ref)}`, (ref) => `issue-${slugify(ref)}`) - -const parseGitlabMergeRequestUrl = (input: string): ResolvedRepoInput | null => - resolveGitlabRefUrl( - input, - ["merge_requests"], - (ref) => `refs/merge-requests/${ref}/head`, - (ref) => `mr-${slugify(ref)}` - ) - -// CHANGE: normalize repo input and PR/issue URLs into repo + ref -// WHY: allow cloning GitHub PR links, issue links, and GitLab scoped links directly -// QUOTE(ТЗ): "клонировть по cсылке на PR" | "Сразу на issues" | "Add support for GitLab repo URLs" -// REF: user-request-2026-01-28-pr | user-request-2026-02-05-issues | issue-252 -// SOURCE: n/a -// FORMAT THEOREM: forall url: resolve(url) -> deterministic(url, ref) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: PR/MR URL yields repoUrl + provider-specific head ref -// COMPLEXITY: O(n) where n = |url| -export const resolveRepoInput = (repoUrl: string): ResolvedRepoInput => - parseGithubPrUrl(repoUrl) - ?? parseGithubTreeUrl(repoUrl) - ?? parseGithubIssueUrl(repoUrl) - ?? parseGitlabMergeRequestUrl(repoUrl) - ?? parseGitlabTreeUrl(repoUrl) - ?? parseGitlabIssueUrl(repoUrl) - ?? { repoUrl: repoUrl.trim() } diff --git a/packages/app/src/lib/core/resource-limits.ts b/packages/app/src/lib/core/resource-limits.ts deleted file mode 100644 index a1bf53df..00000000 --- a/packages/app/src/lib/core/resource-limits.ts +++ /dev/null @@ -1,235 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -import { type RawOptions } from "./command-options.js" -import { - defaultCpuLimit, - defaultPlaywrightCpuLimit, - defaultPlaywrightRamLimit, - defaultRamLimit, - type ParseError, - type TemplateConfig -} from "./domain.js" - -const mebibyte = 1024 ** 2 -const minimumResolvedCpuLimit = 0.25 -const minimumResolvedRamLimitMib = 512 -const minimumResolvedSwapLimitMib = 1 -const precisionScale = 100 - -type HostResources = { - readonly cpuCount: number - readonly totalMemoryBytes: number -} - -export type ResolvedComposeResourceLimits = { - readonly cpuLimit: number - readonly ramLimit: string - readonly swapLimit: string -} - -const cpuAbsolutePattern = /^\d+(?:\.\d+)?$/u -const ramLimitPattern = /^(\d+(?:\.\d+)?)(b|k|kb|m|mb|g|gb|t|tb)$/iu -const ramAbsolutePattern = /^\d+(?:\.\d+)?(?:b|k|kb|m|mb|g|gb|t|tb)$/iu -const percentPattern = /^\d+(?:\.\d+)?%$/u - -const ramUnitMibFactors: Readonly> = { - b: 1 / mebibyte, - k: 1 / 1024, - kb: 1 / 1024, - m: 1, - mb: 1, - g: 1024, - gb: 1024, - t: 1024 * 1024, - tb: 1024 * 1024 -} - -const normalizePrecision = (value: number): number => Math.round(value * precisionScale) / precisionScale - -const missingLimit = (): string | undefined => undefined - -const parsePercent = (candidate: string): number | null => { - if (!percentPattern.test(candidate)) { - return null - } - const parsed = Number(candidate.slice(0, -1)) - if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) { - return null - } - return normalizePrecision(parsed) -} - -const percentReason = (kind: "cpu" | "ram"): string => - kind === "cpu" - ? "expected CPU like 30% or 1.5" - : "expected RAM like 30%, 512m or 4g" - -const normalizePercent = (candidate: string, kind: "cpu" | "ram"): Either.Either => { - const parsed = parsePercent(candidate) - if (parsed === null) { - return Either.left({ - _tag: "InvalidOption", - option: kind === "cpu" ? "--cpu" : "--ram", - reason: percentReason(kind) - }) - } - return Either.right(`${parsed}%`) -} - -export const normalizeCpuLimit = ( - value: string | undefined, - option: string -): Either.Either => { - const candidate = value?.trim().toLowerCase() ?? "" - if (candidate.length === 0) { - return Either.right(missingLimit()) - } - if (candidate.endsWith("%")) { - return normalizePercent(candidate, "cpu") - } - if (!cpuAbsolutePattern.test(candidate)) { - return Either.left({ - _tag: "InvalidOption", - option, - reason: "expected CPU like 30% or 1.5" - }) - } - const parsed = Number(candidate) - if (!Number.isFinite(parsed) || parsed <= 0) { - return Either.left({ - _tag: "InvalidOption", - option, - reason: "must be greater than 0" - }) - } - return Either.right(String(normalizePrecision(parsed))) -} - -export const normalizeRamLimit = ( - value: string | undefined, - option: string -): Either.Either => { - const candidate = value?.trim().toLowerCase() ?? "" - if (candidate.length === 0) { - return Either.right(missingLimit()) - } - if (candidate.endsWith("%")) { - return normalizePercent(candidate, "ram") - } - if (!ramAbsolutePattern.test(candidate)) { - return Either.left({ - _tag: "InvalidOption", - option, - reason: "expected RAM like 30%, 512m or 4g" - }) - } - return Either.right(candidate) -} - -export const withDefaultResourceLimitIntent = ( - template: TemplateConfig -): TemplateConfig => ({ - ...template, - cpuLimit: template.cpuLimit ?? defaultCpuLimit, - ramLimit: template.ramLimit ?? defaultRamLimit, - playwrightCpuLimit: template.playwrightCpuLimit ?? defaultPlaywrightCpuLimit, - playwrightRamLimit: template.playwrightRamLimit ?? defaultPlaywrightRamLimit -}) - -const resolvePercentCpuLimit = (percent: number, cpuCount: number): number => - Math.max( - minimumResolvedCpuLimit, - normalizePrecision((Math.max(1, cpuCount) * percent) / 100) - ) - -const resolvePercentRamLimit = (percent: number, totalMemoryBytes: number): string => { - const totalMib = Math.max(minimumResolvedRamLimitMib, Math.floor(totalMemoryBytes / mebibyte)) - const targetMib = Math.max(minimumResolvedRamLimitMib, Math.floor((totalMib * percent) / 100)) - return `${targetMib}m` -} - -const parseRamLimitMib = (value: string): number | null => { - const match = ramLimitPattern.exec(value) - if (match === null) { - return null - } - - const amount = Number(match[1] ?? "0") - const unit = (match[2] ?? "m").toLowerCase() - const factor = ramUnitMibFactors[unit] - return !Number.isFinite(amount) || amount <= 0 || factor === undefined - ? null - : amount * factor -} - -// CHANGE: allow project containers to use WSL swap without removing hard RAM limits -// WHY: Docker Compose `memswap_limit` is RAM+swap total; setting it equal to RAM disables extra swap -// SOURCE: n/a -// FORMAT THEOREM: forall r: valid_ram(r) -> swap_limit(r) >= 2 * ram_limit(r) -// PURITY: CORE -// INVARIANT: generated containers keep a finite memory+swap ceiling -// COMPLEXITY: O(1)/O(1) -const resolveSwapLimit = (ramLimit: string): string => { - const ramMib = parseRamLimitMib(ramLimit) - return ramMib === null - ? ramLimit - : `${Math.max(minimumResolvedSwapLimitMib, Math.ceil(ramMib * 2))}m` -} - -export const resolveComposeResourceLimits = ( - template: Pick, - hostResources: HostResources -): ResolvedComposeResourceLimits => { - const cpuLimitIntent = template.cpuLimit ?? defaultCpuLimit - const ramLimitIntent = template.ramLimit ?? defaultRamLimit - const cpuPercent = parsePercent(cpuLimitIntent) - const ramPercent = parsePercent(ramLimitIntent) - - const ramLimit = ramPercent === null - ? ramLimitIntent - : resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes) - - return { - cpuLimit: cpuPercent === null - ? Number(cpuLimitIntent) - : resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount), - ramLimit, - swapLimit: resolveSwapLimit(ramLimit) - } -} - -export const resolvePlaywrightComposeResourceLimits = ( - template: Pick, - hostResources: HostResources -): ResolvedComposeResourceLimits => - resolveComposeResourceLimits( - { - cpuLimit: template.playwrightCpuLimit ?? template.cpuLimit ?? defaultPlaywrightCpuLimit, - ramLimit: template.playwrightRamLimit ?? template.ramLimit ?? defaultPlaywrightRamLimit - }, - hostResources - ) - -export type ResolvedResourceLimitsIntent = { - readonly cpuLimit: string | undefined - readonly ramLimit: string | undefined - readonly playwrightCpuLimit: string | undefined - readonly playwrightRamLimit: string | undefined -} - -export const resolveResourceLimitsIntent = ( - raw: RawOptions -): Either.Either => - Either.gen(function*(_) { - const cpuLimit = yield* _(normalizeCpuLimit(raw.cpuLimit ?? defaultCpuLimit, "--cpu")) - const ramLimit = yield* _(normalizeRamLimit(raw.ramLimit ?? defaultRamLimit, "--ram")) - const playwrightCpuLimit = yield* _( - normalizeCpuLimit(raw.playwrightCpuLimit ?? cpuLimit, "--playwright-cpu") - ) - const playwrightRamLimit = yield* _( - normalizeRamLimit(raw.playwrightRamLimit ?? ramLimit, "--playwright-ram") - ) - return { cpuLimit, ramLimit, playwrightCpuLimit, playwrightRamLimit } - }) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/sessions-domain.ts b/packages/app/src/lib/core/sessions-domain.ts deleted file mode 100644 index e8b00e1d..00000000 --- a/packages/app/src/lib/core/sessions-domain.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* jscpd:ignore-start */ -export interface SessionsListCommand { - readonly _tag: "SessionsList" - readonly projectDir: string - readonly includeDefault: boolean -} - -export interface SessionsKillCommand { - readonly _tag: "SessionsKill" - readonly projectDir: string - readonly pid: number -} - -export interface SessionsLogsCommand { - readonly _tag: "SessionsLogs" - readonly projectDir: string - readonly pid: number - readonly lines: number -} - -export type SessionsCommand = - | SessionsListCommand - | SessionsKillCommand - | SessionsLogsCommand -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/state-domain.ts b/packages/app/src/lib/core/state-domain.ts deleted file mode 100644 index 0cbd356d..00000000 --- a/packages/app/src/lib/core/state-domain.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* jscpd:ignore-start */ -export interface StatePathCommand { - readonly _tag: "StatePath" -} - -export interface StateInitCommand { - readonly _tag: "StateInit" - readonly repoUrl: string - readonly repoRef: string -} - -export interface StatePullCommand { - readonly _tag: "StatePull" -} - -export interface StatePushCommand { - readonly _tag: "StatePush" -} - -export interface StateStatusCommand { - readonly _tag: "StateStatus" -} - -export interface StateCommitCommand { - readonly _tag: "StateCommit" - readonly message: string -} - -export interface StateSyncCommand { - readonly _tag: "StateSync" - readonly message: string | null -} - -export type StateCommand = - | StatePathCommand - | StateInitCommand - | StatePullCommand - | StatePushCommand - | StateStatusCommand - | StateCommitCommand - | StateSyncCommand -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/strings.ts b/packages/app/src/lib/core/strings.ts deleted file mode 100644 index e51a5dc1..00000000 --- a/packages/app/src/lib/core/strings.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* jscpd:ignore-start */ -export const trimLeftChar = (value: string, char: string): string => { - let start = 0 - while (start < value.length && value[start] === char) { - start += 1 - } - return value.slice(start) -} - -export const trimRightChar = (value: string, char: string): string => { - let end = value.length - while (end > 0 && value[end - 1] === char) { - end -= 1 - } - return value.slice(0, end) -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint.ts b/packages/app/src/lib/core/templates-entrypoint.ts deleted file mode 100644 index d4135d00..00000000 --- a/packages/app/src/lib/core/templates-entrypoint.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "./domain.js" -import { renderEntrypointAgentsNotice } from "./templates-entrypoint/agents-notice.js" -import { - renderEntrypointAuthorizedKeys, - renderEntrypointBaseline, - renderEntrypointDisableMotd, - renderEntrypointDockerSocket, - renderEntrypointHeader, - renderEntrypointInputRc, - renderEntrypointPackageCache, - renderEntrypointSshd, - renderEntrypointZshShell, - renderEntrypointZshUserRc -} from "./templates-entrypoint/base.js" -import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js" -import { - renderEntrypointCodexHome, - renderEntrypointCodexResumeHint, - renderEntrypointCodexSharedAuth, - renderEntrypointMcpPlaywright, - renderEntrypointProjectCodexSkillsSync -} from "./templates-entrypoint/codex.js" -import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" -import { renderEntrypointGeminiConfig } from "./templates-entrypoint/gemini.js" -import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" -import { renderEntrypointGrokConfig } from "./templates-entrypoint/grok.js" -import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" -import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js" -import { renderEntrypointProjectAgentRules } from "./templates-entrypoint/project-rules.js" -import { renderEntrypointRtkConfig } from "./templates-entrypoint/rtk.js" -import { renderEntrypointBackgroundTasks, renderEntrypointRustBrowserConnection } from "./templates-entrypoint/tasks.js" -import { - renderEntrypointBashCompletion, - renderEntrypointBashHistory, - renderEntrypointPrompt, - renderEntrypointZshConfig -} from "./templates-prompt.js" - -export const renderEntrypoint = (config: TemplateConfig): string => - [ - renderEntrypointHeader(config), - renderEntrypointDnsRepair(), - renderEntrypointPackageCache(config), - renderEntrypointDockerGitBootstrap(config), - renderEntrypointAuthorizedKeys(config), - renderEntrypointCodexHome(config), - renderEntrypointCodexSharedAuth(config), - renderEntrypointOpenCodeConfig(config), - renderEntrypointZshShell(config), - renderEntrypointZshUserRc(config), - renderEntrypointPrompt(), - renderEntrypointBashCompletion(), - renderEntrypointBashHistory(), - renderEntrypointInputRc(config), - renderEntrypointZshConfig(), - renderEntrypointCodexResumeHint(config), - renderEntrypointProjectCodexSkillsSync(config), - renderEntrypointProjectAgentRules(), - renderEntrypointAgentsNotice(config), - renderEntrypointDockerSocket(config), - renderEntrypointRustBrowserConnection(), - renderEntrypointMcpPlaywright(config), - renderEntrypointGitConfig(config), - renderEntrypointClaudeConfig(config), - renderEntrypointGeminiConfig(config), - renderEntrypointGrokConfig(config), - renderEntrypointRtkConfig(config), - renderEntrypointGitHooks(), - renderEntrypointBackgroundTasks(config), - renderEntrypointBaseline(), - renderEntrypointDisableMotd(), - renderEntrypointSshd() - ].join("\n\n") -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/agent.ts b/packages/app/src/lib/core/templates-entrypoint/agent.ts deleted file mode 100644 index 0db5871b..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/agent.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* jscpd:ignore-start */ -import { Match } from "effect" -import type { TemplateConfig } from "../domain.js" - -type AgentMode = "claude" | "codex" | "gemini" | "grok" - -const indentBlock = (block: string, size = 2): string => { - const prefix = " ".repeat(size) - - return block - .split("\n") - .map((line) => `${prefix}${line}`) - .join("\n") -} - -const renderAgentPrompt = (): string => - String.raw`AGENT_PROMPT="" -ISSUE_NUM="" -if [[ "$REPO_REF" =~ ^issue-([0-9]+)$ ]]; then - ISSUE_NUM="${"${"}BASH_REMATCH[1]}" -fi - -if [[ "$AGENT_AUTO" == "1" ]]; then - if [[ -n "$ISSUE_NUM" ]]; then - AGENT_PROMPT="Read GitHub issue #$ISSUE_NUM for this repository (use gh issue view $ISSUE_NUM). Implement the requested changes, commit them, create a PR that closes #$ISSUE_NUM, and push it." - else - AGENT_PROMPT="Analyze this repository, implement any pending tasks, commit changes, create a PR, and push it." - fi -fi` - -const renderAgentSetup = (): string => - [ - String.raw`AGENT_DONE_PATH="/run/docker-git/agent.done" -AGENT_FAIL_PATH="/run/docker-git/agent.failed" -AGENT_PROMPT_FILE="/run/docker-git/agent-prompt.txt" -rm -f "$AGENT_DONE_PATH" "$AGENT_FAIL_PATH" "$AGENT_PROMPT_FILE"`, - String.raw`# Collect tokens for agent environment (su - dev does not always inherit profile.d) -AGENT_ENV_FILE="/run/docker-git/agent-env.sh" -{ - [[ -f /etc/profile.d/docker-host.sh ]] && cat /etc/profile.d/docker-host.sh - [[ -f /etc/profile.d/gh-token.sh ]] && cat /etc/profile.d/gh-token.sh - [[ -f /etc/profile.d/claude-config.sh ]] && cat /etc/profile.d/claude-config.sh - [[ -f /etc/profile.d/gemini-config.sh ]] && cat /etc/profile.d/gemini-config.sh - [[ -f /etc/profile.d/grok-config.sh ]] && cat /etc/profile.d/grok-config.sh -} > "$AGENT_ENV_FILE" 2>/dev/null || true -chmod 644 "$AGENT_ENV_FILE"`, - renderAgentPrompt(), - String.raw`AGENT_OK=0 -if [[ -n "$AGENT_PROMPT" ]]; then - printf "%s" "$AGENT_PROMPT" > "$AGENT_PROMPT_FILE" - chmod 644 "$AGENT_PROMPT_FILE" -fi` - ].join("\n\n") - -const renderAgentPromptCommand = (mode: AgentMode): string => - Match.value(mode).pipe( - Match.when( - "claude", - () => - String - .raw`MCP_PLAYWRIGHT_ISOLATED=1 claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"` - ), - Match.when("codex", () => String.raw`MCP_PLAYWRIGHT_ISOLATED=1 codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), - Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), - Match.when("grok", () => - String.raw`MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`), - Match.exhaustive - ) - -const renderAgentAutoLaunchCommand = ( - config: TemplateConfig, - mode: AgentMode -): string => - String - .raw`su - ${config.sshUser} -s /bin/bash -c "bash -lc '. /etc/profile 2>/dev/null || true; . \"$AGENT_ENV_FILE\" 2>/dev/null || true; cd \"$TARGET_DIR\" && ${ - renderAgentPromptCommand(mode) - }'"` - -const renderAgentModeBlock = ( - config: TemplateConfig, - mode: AgentMode -): string => { - const startMessage = `[agent] starting ${mode}...` - const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)` - - return String.raw`"${mode}") - echo "${startMessage}" - if [[ -n "$AGENT_PROMPT" ]]; then - if ${renderAgentAutoLaunchCommand(config, mode)}; then - AGENT_OK=1 - fi - else - echo "${interactiveMessage}" - AGENT_OK=1 - fi - ;;` -} - -const renderAgentModeCase = (config: TemplateConfig): string => - [ - String.raw`case "$AGENT_MODE" in`, - indentBlock(renderAgentModeBlock(config, "claude")), - indentBlock(renderAgentModeBlock(config, "codex")), - indentBlock(renderAgentModeBlock(config, "gemini")), - indentBlock(renderAgentModeBlock(config, "grok")), - indentBlock( - String.raw`*) - echo "[agent] unknown agent mode: $AGENT_MODE" - ;;` - ), - "esac" - ].join("\n") - -const renderAgentIssueComment = (config: TemplateConfig): string => - String.raw`echo "[agent] posting review comment to issue #$ISSUE_NUM..." - -PR_BODY="" -PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh pr list --head '$REPO_REF' --json body --jq '.[0].body'" 2>/dev/null) || true - -if [[ -z "$PR_BODY" ]]; then - PR_BODY=$(su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && git log --format='%B' -1" 2>/dev/null) || true -fi - -if [[ -n "$PR_BODY" ]]; then - COMMENT_FILE="/run/docker-git/agent-comment.txt" - printf "%s" "$PR_BODY" > "$COMMENT_FILE" - chmod 644 "$COMMENT_FILE" - su - ${config.sshUser} -c ". /run/docker-git/agent-env.sh 2>/dev/null; cd '$TARGET_DIR' && gh issue comment '$ISSUE_NUM' --body-file '$COMMENT_FILE'" || echo "[agent] failed to comment on issue #$ISSUE_NUM" -else - echo "[agent] no PR body or commit message found, skipping comment" -fi` - -const renderProjectMoveScript = (): string => - String.raw`#!/bin/bash -. /run/docker-git/agent-env.sh 2>/dev/null || true -cd "$1" || exit 1 -ISSUE_NUM="$2" - -ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUM" --json id --jq '.id' 2>/dev/null) || true -if [[ -z "$ISSUE_NODE_ID" ]]; then - echo "[agent] could not get issue node ID, skipping move" - exit 0 -fi - -GQL_QUERY='query($nodeId: ID!) { node(id: $nodeId) { ... on Issue { projectItems(first: 1) { nodes { id project { id field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } } } } } } }' -ALL_IDS=$(gh api graphql -F nodeId="$ISSUE_NODE_ID" -f query="$GQL_QUERY" \ - --jq '(.data.node.projectItems.nodes // [])[0] // empty | [.id, .project.id, .project.field.id, ([.project.field.options[] | select(.name | test("review"; "i"))][0].id)] | @tsv' 2>/dev/null) || true - -if [[ -z "$ALL_IDS" ]]; then - echo "[agent] issue #$ISSUE_NUM is not in a project board, skipping move" - exit 0 -fi - -ITEM_ID=$(printf "%s" "$ALL_IDS" | cut -f1) -PROJECT_ID=$(printf "%s" "$ALL_IDS" | cut -f2) -STATUS_FIELD_ID=$(printf "%s" "$ALL_IDS" | cut -f3) -REVIEW_OPTION_ID=$(printf "%s" "$ALL_IDS" | cut -f4) -if [[ -z "$STATUS_FIELD_ID" || -z "$REVIEW_OPTION_ID" || "$STATUS_FIELD_ID" == "null" || "$REVIEW_OPTION_ID" == "null" ]]; then - echo "[agent] review status not found in project board, skipping move" - exit 0 -fi - -MUTATION='mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) { projectV2Item { id } } }' -MOVE_RESULT=$(gh api graphql \ - -F projectId="$PROJECT_ID" \ - -F itemId="$ITEM_ID" \ - -F fieldId="$STATUS_FIELD_ID" \ - -F optionId="$REVIEW_OPTION_ID" \ - -f query="$MUTATION" 2>&1) || true - -if [[ "$MOVE_RESULT" == *"projectV2Item"* ]]; then - echo "[agent] issue #$ISSUE_NUM moved to review" -else - echo "[agent] failed to move issue #$ISSUE_NUM in project board" -fi` - -const renderAgentIssueMove = (config: TemplateConfig): string => - [ - String.raw`echo "[agent] moving issue #$ISSUE_NUM to review..." -MOVE_SCRIPT="/run/docker-git/project-move.sh"`, - String.raw`cat > "$MOVE_SCRIPT" << 'EOFMOVE' -${renderProjectMoveScript()} -EOFMOVE`, - String.raw`chmod +x "$MOVE_SCRIPT" -su - ${config.sshUser} -c "$MOVE_SCRIPT '$TARGET_DIR' '$ISSUE_NUM'" || true` - ].join("\n") - -const renderAgentIssueReview = (config: TemplateConfig): string => - [ - String.raw`if [[ "$AGENT_OK" -eq 1 && "$AGENT_AUTO" == "1" && -n "$ISSUE_NUM" ]]; then`, - indentBlock(renderAgentIssueComment(config)), - "", - renderAgentIssueMove(config), - "fi" - ].join("\n") - -const renderAgentFinalize = (): string => - String.raw`if [[ "$AGENT_OK" -eq 1 ]]; then - echo "[agent] done" - touch "$AGENT_DONE_PATH" -else - echo "[agent] failed" - touch "$AGENT_FAIL_PATH" -fi` - -export const renderAgentLaunch = (config: TemplateConfig): string => - [ - String.raw`# 3) Auto-launch agent if AGENT_MODE is set -if [[ "$CLONE_OK" -eq 1 && -n "$AGENT_MODE" ]]; then`, - indentBlock(renderAgentSetup()), - "", - indentBlock(renderAgentModeCase(config)), - "", - renderAgentIssueReview(config), - "", - indentBlock(renderAgentFinalize()), - "fi" - ].join("\n") -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts b/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts deleted file mode 100644 index faae1d53..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/agents-notice.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context -AGENTS_PATH="__CODEX_HOME__/AGENTS.md" -LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md" -docker_git_decode_unicode_escapes() { - local value="$1" - if printf "%s" "$value" | grep -q '\\u[0-9a-fA-F]'; then - printf "%b" "$value" - else - printf "%s" "$value" - fi -} -PROJECT_LINE="Рабочая папка проекта (git clone): __TARGET_DIR__" -WORKSPACES_LINE="Доступные workspace пути: __TARGET_DIR__" -WORKSPACE_INFO_LINE="Контекст workspace: repository" -FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__" -INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." -SUBAGENTS_LINE="Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю." -if [[ "$REPO_REF" == issue-* ]]; then - ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" - ISSUE_URL="" - if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi - fi - if [[ -n "$ISSUE_URL" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" - else - WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" - fi -elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" - PR_URL="" - if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then - PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO" ]]; then - PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" - fi - fi - if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)" - elif [[ -n "$PR_ID" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" - else - WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" - fi -fi -MANAGED_START="" -MANAGED_END="" -CODEX_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{CODEX_SYSTEM_PROMPT_OVERRIDE_FILE:-}" -CODEX_SYSTEM_PROMPT_OVERRIDE="${"$"}{CODEX_SYSTEM_PROMPT_OVERRIDE:-}" -if [[ -n "$CODEX_SYSTEM_PROMPT_OVERRIDE_FILE" && -r "$CODEX_SYSTEM_PROMPT_OVERRIDE_FILE" ]]; then - MANAGED_LINES="$(cat "$CODEX_SYSTEM_PROMPT_OVERRIDE_FILE")" -elif [[ -n "$CODEX_SYSTEM_PROMPT_OVERRIDE" ]]; then - MANAGED_LINES="$CODEX_SYSTEM_PROMPT_OVERRIDE" -else - MANAGED_LINES="$(cat < "$AGENTS_PATH" -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, bun, codex, opencode, oh-my-opencode, sshpass, git, node и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -$MANAGED_BLOCK -Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. -EOF - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$AGENTS_PATH" ]]; then - MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" - else - sed \ - -e '/^Рабочая папка проекта (git clone):/d' \ - -e '/^Доступные workspace пути:/d' \ - -e '/^Контекст workspace:/d' \ - -e '/^Фокус задачи:/d' \ - -e '/^Issue AGENTS.md:/d' \ - -e '/^Доступ к интернету:/d' \ - -e '/^Для решения задач обязательно используй subagents[.]/d' \ - "$AGENTS_PATH" > "$TMP_AGENTS_PATH" - if [[ -s "$TMP_AGENTS_PATH" ]]; then - printf "\n" >> "$TMP_AGENTS_PATH" - fi - printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" - fi - mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$AGENTS_PATH" ]] && grep -qF "$MANAGED_START" "$AGENTS_PATH" && grep -q '\\u[0-9a-fA-F]' "$AGENTS_PATH"; then - TMP_AGENTS_PATH="$(mktemp)" - docker_git_decode_unicode_escapes "$(cat "$AGENTS_PATH")" > "$TMP_AGENTS_PATH" - printf "\n" >> "$TMP_AGENTS_PATH" - mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then - LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" - CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" - if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then - rm -f "$LEGACY_AGENTS_PATH" - fi -fi` - -export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => - entrypointAgentsNoticeTemplate.replaceAll("__CODEX_HOME__", config.codexHome).replaceAll( - "__SSH_USER__", - config.sshUser - ) - .replaceAll("__TARGET_DIR__", config.targetDir) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/base.ts b/packages/app/src/lib/core/templates-entrypoint/base.ts deleted file mode 100644 index 2200712a..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/base.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { TemplateConfig } from "../domain.js" -import { shellSingleQuote } from "../shell-literals.js" -import { renderInputRc } from "../templates-prompt.js" - -const renderTargetDirDefault = (config: TemplateConfig): string => - `TARGET_DIR="\${TARGET_DIR:-}" -if [[ -z "$TARGET_DIR" ]]; then - TARGET_DIR=${shellSingleQuote(config.targetDir)} -fi` - -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:-}" -${renderTargetDirDefault(config)} -if [[ "$TARGET_DIR" == "~" ]]; then - TARGET_DIR="$HOME" -elif [[ "$TARGET_DIR" == "~/"* ]]; then - TARGET_DIR="$HOME\${TARGET_DIR:1}" -fi -CLAUDE_AUTH_LABEL="\${CLAUDE_AUTH_LABEL:-}" -CODEX_AUTH_LABEL="\${CODEX_AUTH_LABEL:-}" -GEMINI_AUTH_LABEL="\${GEMINI_AUTH_LABEL:-}" -GROK_AUTH_LABEL="\${GROK_AUTH_LABEL:-}" -GIT_AUTH_USER="\${GIT_AUTH_USER:-\${GITHUB_USER:-}}" -GITLAB_TOKEN="\${GITLAB_TOKEN:-}" -GIT_AUTH_TOKEN="\${GIT_AUTH_TOKEN:-\${GITHUB_TOKEN:-\${GH_TOKEN:-}}}" -GH_TOKEN="\${GH_TOKEN:-\${GITHUB_TOKEN:-}}" -GITHUB_TOKEN="\${GITHUB_TOKEN:-\${GH_TOKEN:-}}" -if [[ -z "$GITHUB_TOKEN" && -z "$GITLAB_TOKEN" ]]; then - GH_TOKEN="\${GH_TOKEN:-\${GIT_AUTH_TOKEN:-}}" - GITHUB_TOKEN="\${GITHUB_TOKEN:-\${GH_TOKEN:-}}" -fi -GIT_USER_NAME="\${GIT_USER_NAME:-}" -GIT_USER_EMAIL="\${GIT_USER_EMAIL:-}" -CODEX_AUTO_UPDATE="\${CODEX_AUTO_UPDATE:-1}" -AGENT_MODE="\${AGENT_MODE:-}" -AGENT_AUTO="\${AGENT_AUTO:-}" -MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" -MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-0}" - -SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" - -docker_git_upsert_ssh_env() { - local key="$1" - local value="$2" - - if [[ -d "$SSH_ENV_PATH" ]]; then - mv "$SSH_ENV_PATH" "$SSH_ENV_PATH.bak-$(date +%s)" || true - fi - - mkdir -p "$(dirname "$SSH_ENV_PATH")" - touch "$SSH_ENV_PATH" - - awk -v k="$key" -F= '$1 != k { print }' "$SSH_ENV_PATH" > "$SSH_ENV_PATH.tmp" - mv "$SSH_ENV_PATH.tmp" "$SSH_ENV_PATH" - - printf "%s\n" "$key=$value" >> "$SSH_ENV_PATH" - chmod 600 "$SSH_ENV_PATH" || true - chown 1000:1000 "$SSH_ENV_PATH" || true -}` - -export const renderEntrypointPackageCache = (config: TemplateConfig): string => - `# Keep package manager caches inside the project home volume -PACKAGE_CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/packages" -PACKAGE_BUN_CACHE="\${BUN_INSTALL_CACHE_DIR:-$PACKAGE_CACHE_ROOT/bun/install/cache}" -PACKAGE_NPM_CACHE="\${npm_config_cache:-\${NPM_CONFIG_CACHE:-$PACKAGE_CACHE_ROOT/npm}}" -PACKAGE_YARN_CACHE="\${YARN_CACHE_FOLDER:-$PACKAGE_CACHE_ROOT/yarn}" - -mkdir -p "$PACKAGE_BUN_CACHE" "$PACKAGE_NPM_CACHE" "$PACKAGE_YARN_CACHE" -chown -R 1000:1000 "$PACKAGE_CACHE_ROOT" || true - -cat < /etc/profile.d/docker-git-package-cache.sh -export BUN_INSTALL_CACHE_DIR="$PACKAGE_BUN_CACHE" -export NPM_CONFIG_CACHE="$PACKAGE_NPM_CACHE" -export npm_config_cache="$PACKAGE_NPM_CACHE" -export YARN_CACHE_FOLDER="$PACKAGE_YARN_CACHE" -EOF -chmod 0644 /etc/profile.d/docker-git-package-cache.sh - -docker_git_upsert_ssh_env "BUN_INSTALL_CACHE_DIR" "$PACKAGE_BUN_CACHE" -docker_git_upsert_ssh_env "NPM_CONFIG_CACHE" "$PACKAGE_NPM_CACHE" -docker_git_upsert_ssh_env "npm_config_cache" "$PACKAGE_NPM_CACHE" -docker_git_upsert_ssh_env "YARN_CACHE_FOLDER" "$PACKAGE_YARN_CACHE"` - -export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string => - `# 1) Mirror authorized_keys from the project home volume into ~/.ssh -DOCKER_GIT_AUTH_KEYS="/home/${config.sshUser}/.docker-git/authorized_keys" -mkdir -p /home/${config.sshUser}/.ssh -chmod 700 /home/${config.sshUser}/.ssh - -if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then - cp "$DOCKER_GIT_AUTH_KEYS" /home/${config.sshUser}/.ssh/authorized_keys - chmod 600 /home/${config.sshUser}/.ssh/authorized_keys -fi - -chown -R 1000:1000 /home/${config.sshUser}/.ssh` - -export const renderEntrypointDockerSocket = (config: TemplateConfig): string => - `# Ensure Docker CLI targets only the explicitly configured daemon. -if [[ -n "\${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" && -z "\${DOCKER_HOST:-}" ]]; then - DOCKER_HOST="$DOCKER_GIT_PROJECT_DOCKER_HOST" - export DOCKER_HOST -fi - -if [[ -n "\${DOCKER_HOST:-}" ]]; then - printf "export DOCKER_HOST=%q\\n" "$DOCKER_HOST" > /etc/profile.d/docker-host.sh - docker_git_upsert_ssh_env "DOCKER_HOST" "$DOCKER_HOST" -elif [[ -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" - if getent group "$DOCKER_GROUP" >/dev/null 2>&1; then - groupmod -o -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true - else - groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true - fi - fi - usermod -aG "$DOCKER_GROUP" ${config.sshUser} || true - printf "export DOCKER_HOST=unix:///var/run/docker.sock\\n" > /etc/profile.d/docker-host.sh - docker_git_upsert_ssh_env "DOCKER_HOST" "unix:///var/run/docker.sock" -fi` - -export const renderEntrypointZshShell = (config: TemplateConfig): string => - String.raw`# Prefer zsh for ${config.sshUser} when available -if command -v zsh >/dev/null 2>&1; then - usermod -s /usr/bin/zsh ${config.sshUser} || true -fi` - -export const renderEntrypointZshUserRc = (config: TemplateConfig): string => - String.raw`# Ensure ${config.sshUser} has a zshrc and disable newuser wizard -ZSHENV_PATH="/etc/zsh/zshenv" -if [[ -f "$ZSHENV_PATH" ]]; then - if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" - fi -else - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" -fi -USER_ZSHRC="/home/${config.sshUser}/.zshrc" -if [[ ! -f "$USER_ZSHRC" ]]; then - cat <<'EOF' > "$USER_ZSHRC" -# docker-git default zshrc -if [ -f /etc/zsh/zshrc ]; then - source /etc/zsh/zshrc -fi -EOF - chown 1000:1000 "$USER_ZSHRC" || true -fi` - -export const renderEntrypointInputRc = (config: TemplateConfig): string => - String.raw`# Ensure readline history search bindings for ${config.sshUser} -INPUTRC_PATH="/home/${config.sshUser}/.inputrc" -if [[ ! -f "$INPUTRC_PATH" ]]; then - cat <<'EOF' > "$INPUTRC_PATH" -${renderInputRc()} -EOF - chown 1000:1000 "$INPUTRC_PATH" || true -fi` - -export const renderEntrypointBaseline = (): string => - `# 4.5) Snapshot baseline processes for terminal session filtering -mkdir -p /run/docker-git -BASELINE_PATH="/run/docker-git/terminal-baseline.pids" -if [[ ! -f "$BASELINE_PATH" ]]; then - ps -eo pid= > "$BASELINE_PATH" || true -fi` - -export const renderEntrypointDisableMotd = (): string => - String.raw`# 4.75) Disable Ubuntu MOTD noise for SSH sessions -PAM_SSHD="/etc/pam.d/sshd" -if [[ -f "$PAM_SSHD" ]]; then - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true -fi - -# Also disable sshd's own banners (e.g. "Last login") -mkdir -p /etc/ssh/sshd_config.d || true -DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" -cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" -PrintMotd no -PrintLastLog no -EOF -chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true` - -export const renderEntrypointSshd = (): string => - `# 5) Run sshd in foreground (log to stderr for CI/debuggability)\nexec /usr/sbin/sshd -D -e` diff --git a/packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts b/packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts deleted file mode 100644 index 8fd5e942..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/claude-extra-config.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -const entrypointClaudeGlobalPromptTemplate = String - .raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) -CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md" -docker_git_decode_unicode_escapes() { - local value="$1" - if printf "%s" "$value" | grep -q '\\u[0-9a-fA-F]'; then - printf "%b" "$value" - else - printf "%s" "$value" - fi -} -CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}" -CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" -REPO_REF_VALUE="${"$"}{REPO_REF:-__REPO_REF_DEFAULT__}" -REPO_URL_VALUE="${"$"}{REPO_URL:-__REPO_URL_DEFAULT__}" - -if [[ "$REPO_REF_VALUE" == issue-* ]]; then - ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" - ISSUE_URL_VALUE="" - if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then - ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO_VALUE" ]]; then - ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" - fi - fi - if [[ -n "$ISSUE_URL_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" - else - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE" - fi -elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then - PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" - PR_URL_VALUE="" - if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then - PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO_VALUE" ]]; then - PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" - fi - fi - if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" - elif [[ -n "$PR_ID_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE" - else - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)" - fi -fi - -CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{CLAUDE_SYSTEM_PROMPT_OVERRIDE_FILE:-}" -CLAUDE_SYSTEM_PROMPT_OVERRIDE="${"$"}{CLAUDE_SYSTEM_PROMPT_OVERRIDE:-}" -CLAUDE_DEFAULT_PROMPT_BODY="$(cat </dev/null || true - if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then - cat < "$CLAUDE_GLOBAL_PROMPT_FILE" - -$CLAUDE_PROMPT_BODY - -EOF - chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true - chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true - fi -fi - -export CLAUDE_AUTO_SYSTEM_PROMPT` - -const escapeForDoubleQuotes = (value: string): string => { - const backslash = String.fromCodePoint(92) - const quote = String.fromCodePoint(34) - const escapedBackslash = `${backslash}${backslash}` - const escapedQuote = `${backslash}${quote}` - return value - .replaceAll(backslash, escapedBackslash) - .replaceAll(quote, escapedQuote) -} - -export const renderClaudeGlobalPromptSetup = (config: TemplateConfig): string => - entrypointClaudeGlobalPromptTemplate - .replaceAll("__TARGET_DIR__", config.targetDir) - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) - .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) - -export const renderClaudeWrapperSetup = (): string => - String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude" -if command -v claude >/dev/null 2>&1; then - CURRENT_CLAUDE_BIN="$(command -v claude)" - CLAUDE_REAL_DIR="$(dirname "$CURRENT_CLAUDE_BIN")" - CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" - - # If a wrapper already exists but points to a missing real binary, recover from /usr/bin. - if [[ "$CURRENT_CLAUDE_BIN" == "$CLAUDE_WRAPPER_BIN" && ! -e "$CLAUDE_REAL_BIN" && -x "/usr/bin/claude" ]]; then - CURRENT_CLAUDE_BIN="/usr/bin/claude" - CLAUDE_REAL_DIR="/usr/bin" - CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" - fi - - # Keep the "real" binary in the same directory as the original command to preserve relative symlinks. - if [[ "$CURRENT_CLAUDE_BIN" != "$CLAUDE_REAL_BIN" && ! -e "$CLAUDE_REAL_BIN" ]]; then - mv "$CURRENT_CLAUDE_BIN" "$CLAUDE_REAL_BIN" - fi - if [[ -e "$CLAUDE_REAL_BIN" ]]; then - cat <<'EOF' > "$CLAUDE_WRAPPER_BIN" -#!/usr/bin/env bash -set -euo pipefail - -CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__" -CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}" -CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" - -if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then - CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" - export CLAUDE_CODE_OAUTH_TOKEN -else - unset CLAUDE_CODE_OAUTH_TOKEN || true -fi - -exec "$CLAUDE_REAL_BIN" "$@" -EOF - sed -i "s#__CLAUDE_REAL_BIN__#$CLAUDE_REAL_BIN#g" "$CLAUDE_WRAPPER_BIN" || true - chmod 0755 "$CLAUDE_WRAPPER_BIN" || true - fi -fi` -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/claude.ts b/packages/app/src/lib/core/templates-entrypoint/claude.ts deleted file mode 100644 index eafff7cb..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/claude.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" -import { renderClaudeGlobalPromptSetup, renderClaudeWrapperSetup } from "./claude-extra-config.js" - -const claudeAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/claude` - -const claudeAuthConfigTemplate = String - .raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude) -CLAUDE_LABEL_RAW="$CLAUDE_AUTH_LABEL" -if [[ -z "$CLAUDE_LABEL_RAW" ]]; then - CLAUDE_LABEL_RAW="default" -fi - -CLAUDE_LABEL_NORM="$(printf "%s" "$CLAUDE_LABEL_RAW" \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" -if [[ -z "$CLAUDE_LABEL_NORM" ]]; then - CLAUDE_LABEL_NORM="default" -fi - -CLAUDE_AUTH_ROOT="__CLAUDE_AUTH_ROOT__" -CLAUDE_CONFIG_DIR="$CLAUDE_AUTH_ROOT/$CLAUDE_LABEL_NORM" - -# Backward compatibility: if default auth is stored directly under claude root, reuse it. -if [[ "$CLAUDE_LABEL_NORM" == "default" ]]; then - CLAUDE_ROOT_TOKEN_FILE="$CLAUDE_AUTH_ROOT/.oauth-token" - CLAUDE_ROOT_CONFIG_FILE="$CLAUDE_AUTH_ROOT/.config.json" - if [[ -f "$CLAUDE_ROOT_TOKEN_FILE" ]] || [[ -f "$CLAUDE_ROOT_CONFIG_FILE" ]]; then - CLAUDE_CONFIG_DIR="$CLAUDE_AUTH_ROOT" - fi -fi - -export CLAUDE_CONFIG_DIR - -mkdir -p "$CLAUDE_CONFIG_DIR" || true -CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__" -CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__" -mkdir -p "$CLAUDE_HOME_DIR" || true -CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" -CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json" -CLAUDE_NESTED_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.claude/.credentials.json" - -docker_git_prepare_claude_auth_mode() { - if [[ -s "$CLAUDE_TOKEN_FILE" ]]; then - rm -f "$CLAUDE_CREDENTIALS_FILE" "$CLAUDE_NESTED_CREDENTIALS_FILE" "$CLAUDE_HOME_DIR/.credentials.json" || true - fi -} - -docker_git_prepare_claude_auth_mode - -docker_git_link_claude_file() { - local source_path="$1" - local link_path="$2" - - # Preserve user-created regular files and seed config dir once. - if [[ -e "$link_path" && ! -L "$link_path" ]]; then - if [[ -f "$link_path" && ! -e "$source_path" ]]; then - cp "$link_path" "$source_path" || true - chmod 0600 "$source_path" || true - fi - return 0 - fi - - ln -sfn "$source_path" "$link_path" || true -} - -docker_git_link_claude_home_file() { - local relative_path="$1" - local source_path="$CLAUDE_CONFIG_DIR/$relative_path" - local link_path="$CLAUDE_HOME_DIR/$relative_path" - docker_git_link_claude_file "$source_path" "$link_path" -} - -docker_git_link_claude_home_file ".oauth-token" -docker_git_link_claude_home_file ".config.json" -docker_git_link_claude_home_file ".claude.json" -if [[ ! -s "$CLAUDE_TOKEN_FILE" ]]; then - docker_git_link_claude_home_file ".credentials.json" -fi -docker_git_link_claude_file "$CLAUDE_CONFIG_DIR/.claude.json" "$CLAUDE_HOME_JSON" - -docker_git_refresh_claude_oauth_token() { - local token="" - if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then - token="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" - fi - if [[ -n "$token" ]]; then - export CLAUDE_CODE_OAUTH_TOKEN="$token" - else - unset CLAUDE_CODE_OAUTH_TOKEN || true - fi -} - -docker_git_refresh_claude_oauth_token` - -const renderClaudeAuthConfig = (config: TemplateConfig): string => - claudeAuthConfigTemplate - .replaceAll("__CLAUDE_AUTH_ROOT__", claudeAuthRootContainerPath(config.sshUser)) - .replaceAll("__CLAUDE_HOME_DIR__", `/home/${config.sshUser}/.claude`) - .replaceAll("__CLAUDE_HOME_JSON__", `/home/${config.sshUser}/.claude.json`) - -const renderClaudeCliInstall = (): string => - String.raw`# Claude Code: ensure CLI command exists (non-blocking startup self-heal) -docker_git_ensure_claude_cli() { - if command -v claude >/dev/null 2>&1; then - return 0 - fi - - if ! command -v npm >/dev/null 2>&1; then - return 0 - fi - - NPM_ROOT="$(npm root -g 2>/dev/null || true)" - CLAUDE_CLI_JS="$NPM_ROOT/@anthropic-ai/claude-code/cli.js" - if [[ -z "$NPM_ROOT" || ! -f "$CLAUDE_CLI_JS" ]]; then - echo "docker-git: claude cli.js not found under npm global root; skip shim restore" >&2 - return 0 - fi - - # Rebuild a minimal shim when npm package exists but binary link is missing. - cat <<'EOF' > /usr/local/bin/claude -#!/usr/bin/env bash -set -euo pipefail - -if ! command -v npm >/dev/null 2>&1; then - echo "claude: npm is required but missing" >&2 - exit 127 -fi - -NPM_ROOT="$(npm root -g 2>/dev/null || true)" -CLAUDE_CLI_JS="$NPM_ROOT/@anthropic-ai/claude-code/cli.js" -if [[ -z "$NPM_ROOT" || ! -f "$CLAUDE_CLI_JS" ]]; then - echo "claude: cli.js not found under npm global root" >&2 - exit 127 -fi - -exec node "$CLAUDE_CLI_JS" "$@" -EOF - chmod 0755 /usr/local/bin/claude || true - ln -sf /usr/local/bin/claude /usr/bin/claude || true -} - -docker_git_ensure_claude_cli` - -const renderClaudePermissionSettingsConfig = (): string => - String.raw`# Claude Code: keep permission settings in sync with docker-git defaults -CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" -docker_git_sync_claude_permissions() { - CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_PERMISSION_SETTINGS_FILE" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") - -const settingsPath = process.env.CLAUDE_PERMISSION_SETTINGS_FILE -if (typeof settingsPath !== "string" || settingsPath.length === 0) { - process.exit(0) -} - -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) - -let settings = {} -try { - const raw = fs.readFileSync(settingsPath, "utf8") - const parsed = JSON.parse(raw) - settings = isRecord(parsed) ? parsed : {} -} catch { - settings = {} -} - -const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {} -const nextPermissions = { - ...currentPermissions, - defaultMode: "bypassPermissions" -} -const nextSettings = { - ...settings, - permissions: nextPermissions -} - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { - process.exit(0) -} - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_claude_permissions -chmod 0600 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true -chown 1000:1000 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true` - -const renderClaudeMcpPlaywrightConfig = (): string => - String.raw`# Claude Code: keep Playwright MCP config in sync with container settings -CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" -docker_git_sync_claude_playwright_mcp() { - local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" - local browser_network="container:$browser_project" - CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") - -const settingsPath = process.env.CLAUDE_SETTINGS_FILE -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -const enablePlaywright = process.env.MCP_PLAYWRIGHT_ENABLE === "1" -const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" -const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) - -let settings = {} -try { - const raw = fs.readFileSync(settingsPath, "utf8") - const parsed = JSON.parse(raw) - settings = isRecord(parsed) ? parsed : {} -} catch { settings = {} } - -const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} -const nextServers = { ...currentServers } -if (enablePlaywright) { - nextServers.playwright = { - type: "stdio", - command: "browser-connection", - args: browserArgs, - env: {} - } -} else { - delete nextServers.playwright -} - -const nextSettings = { ...settings } -if (Object.keys(nextServers).length > 0) { - nextSettings.mcpServers = nextServers -} else { - delete nextSettings.mcpServers -} - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { - process.exit(0) -} - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_claude_playwright_mcp -chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` - -const renderClaudeProfileSetup = (): string => - String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh" -printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE" -printf "export CLAUDE_CONFIG_DIR=%q\n" "$CLAUDE_CONFIG_DIR" >> "$CLAUDE_PROFILE" -printf "export CLAUDE_AUTO_SYSTEM_PROMPT=%q\n" "$CLAUDE_AUTO_SYSTEM_PROMPT" >> "$CLAUDE_PROFILE" -cat <<'EOF' >> "$CLAUDE_PROFILE" -CLAUDE_TOKEN_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.oauth-token" -if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then - export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" -else - unset CLAUDE_CODE_OAUTH_TOKEN || true -fi -EOF -chmod 0644 "$CLAUDE_PROFILE" || true - -docker_git_upsert_ssh_env "CLAUDE_AUTH_LABEL" "$CLAUDE_AUTH_LABEL" -docker_git_upsert_ssh_env "CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR" -docker_git_upsert_ssh_env "CLAUDE_CODE_OAUTH_TOKEN" "${"$"}{CLAUDE_CODE_OAUTH_TOKEN:-}" -docker_git_upsert_ssh_env "CLAUDE_AUTO_SYSTEM_PROMPT" "$CLAUDE_AUTO_SYSTEM_PROMPT"` - -export const renderEntrypointClaudeConfig = (config: TemplateConfig): string => - [ - renderClaudeAuthConfig(config), - renderClaudeCliInstall(), - renderClaudePermissionSettingsConfig(), - renderClaudeMcpPlaywrightConfig(), - renderClaudeGlobalPromptSetup(config), - renderClaudeWrapperSetup(), - renderClaudeProfileSetup() - ].join("\n\n") -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts b/packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts deleted file mode 100644 index 98e57642..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/codex-resume-hint.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -const escapeForDoubleQuotes = (value: string): string => { - const backslash = String.fromCodePoint(92) - return value - .replaceAll(backslash, `${backslash}${backslash}`) - .replaceAll(String.fromCodePoint(34), `${backslash}${String.fromCodePoint(34)}`) -} - -const entrypointCodexResumeHintTemplate = `# Ensure codex resume hint is shown for interactive shells -CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" -if [[ ! -s "$CODEX_HINT_PATH" ]]; then - cat <<'EOF' > "$CODEX_HINT_PATH" -docker_git_workspace_context_line() { - REPO_REF_VALUE="\${REPO_REF:-__REPO_REF_DEFAULT__}" - REPO_URL_VALUE="\${REPO_URL:-__REPO_URL_DEFAULT__}" - - if [[ "$REPO_REF_VALUE" == issue-* ]]; then - ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" - ISSUE_URL_VALUE="" - if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then - ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO_VALUE" ]]; then - ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" - fi - fi - if [[ -n "$ISSUE_URL_VALUE" ]]; then - printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" - else - printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE" - fi - return - fi - - if [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then - PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\\1#p')" - PR_URL_VALUE="" - if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then - PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO_VALUE" ]]; then - PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" - fi - fi - if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then - printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" - elif [[ -n "$PR_ID_VALUE" ]]; then - printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE" - elif [[ -n "$REPO_REF_VALUE" ]]; then - printf "%s\n" "Контекст workspace: pull request ($REPO_REF_VALUE)" - fi - return - fi - - if [[ -n "$REPO_URL_VALUE" ]]; then - printf "%s\n" "Контекст workspace: $REPO_URL_VALUE" - fi -} - -docker_git_print_codex_resume_hint() { - if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then - DOCKER_GIT_CONTEXT_LINE="$(docker_git_workspace_context_line)" - if [[ -n "$DOCKER_GIT_CONTEXT_LINE" ]]; then - echo "$DOCKER_GIT_CONTEXT_LINE" - fi - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi -} - -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - docker_git_print_codex_resume_hint - ;; - esac -fi -if [ -n "$ZSH_VERSION" ]; then - if [[ "$-" == *i* ]]; then - docker_git_print_codex_resume_hint - fi -fi -EOF - chmod 0644 "$CODEX_HINT_PATH" -fi -if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc -fi -if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then - printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc -fi` - -// PURITY: CORE -// INVARIANT: rendered output contains shell-escaped repo ref and url placeholders -// COMPLEXITY: O(1) -export const renderEntrypointCodexResumeHint = (config: TemplateConfig): string => - entrypointCodexResumeHintTemplate - .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) - .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/codex.ts b/packages/app/src/lib/core/templates-entrypoint/codex.ts deleted file mode 100644 index 3f83c3ee..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/codex.ts +++ /dev/null @@ -1,195 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -export { renderEntrypointCodexResumeHint } from "./codex-resume-hint.js" - -export const renderEntrypointCodexHome = (config: TemplateConfig): string => - `# Ensure Codex home exists if mounted -mkdir -p ${config.codexHome} && chown -R 1000:1000 ${config.codexHome} - -DOCKER_GIT_CODEX_BOOTSTRAP="/home/${config.sshUser}/.docker-git/.orch/auth/codex/config.toml" -if [[ -f "$DOCKER_GIT_CODEX_BOOTSTRAP" && ! -f "${config.codexHome}/config.toml" ]]; then cp "$DOCKER_GIT_CODEX_BOOTSTRAP" "${config.codexHome}/config.toml"; chown 1000:1000 "${config.codexHome}/config.toml" || true; fi - -# Ensure home ownership matches the dev UID/GID (volumes may be stale) -HOME_OWNER="$(stat -c "%u:%g" /home/${config.sshUser} 2>/dev/null || echo "")" -if [[ "$HOME_OWNER" != "1000:1000" ]]; then - chown -R 1000:1000 /home/${config.sshUser} || true -fi` - -export const renderEntrypointCodexSharedAuth = (config: TemplateConfig): string => - `# Share Codex auth.json across projects (avoids refresh_token_reused) -CODEX_SHARE_AUTH="\${CODEX_SHARE_AUTH:-1}" -if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then - CODEX_LABEL_RAW="$CODEX_AUTH_LABEL" - if [[ -z "$CODEX_LABEL_RAW" ]]; then CODEX_LABEL_RAW="default"; fi - CODEX_LABEL_NORM="$(printf "%s" "$CODEX_LABEL_RAW" \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" - if [[ -z "$CODEX_LABEL_NORM" ]]; then CODEX_LABEL_NORM="default"; fi - CODEX_AUTH_LABEL="$CODEX_LABEL_NORM" - DOCKER_GIT_CODEX_AUTH_ROOT="/home/${config.sshUser}/.docker-git/.orch/auth/codex"; CODEX_SHARED_HOME="${config.codexHome}-shared" - mkdir -p "$CODEX_SHARED_HOME" && chown -R 1000:1000 "$CODEX_SHARED_HOME" || true - AUTH_FILE="${config.codexHome}/auth.json"; SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/auth.json" - if [[ "$CODEX_LABEL_NORM" != "default" ]]; then - SHARED_AUTH_FILE="$CODEX_SHARED_HOME/$CODEX_LABEL_NORM/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/$CODEX_LABEL_NORM/auth.json"; mkdir -p "$(dirname "$SHARED_AUTH_FILE")" - fi - if [[ -f "$SHARED_AUTH_SEED" ]]; then - cp "$SHARED_AUTH_SEED" "$SHARED_AUTH_FILE" - chmod 600 "$SHARED_AUTH_FILE" || true - chown 1000:1000 "$SHARED_AUTH_FILE" || true - else - rm -f "$SHARED_AUTH_FILE" || true - fi - # Guard against a bad bind mount creating a directory at auth.json. - if [[ -d "$AUTH_FILE" ]]; then - mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true - fi - if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then - rm -f "$AUTH_FILE" || true - fi - ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" - docker_git_upsert_ssh_env "CODEX_AUTH_LABEL" "$CODEX_AUTH_LABEL" -fi` - -const entrypointMcpPlaywrightTemplate = String.raw`# Optional: configure Browser MCP for Codex (Rust browser-connection) -CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" -DOCKER_GIT_BROWSER_PROJECT="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" -if [[ -z "$DOCKER_GIT_BROWSER_PROJECT" ]]; then - DOCKER_GIT_BROWSER_PROJECT="$(hostname)" -fi -DOCKER_GIT_BROWSER_NETWORK="container:$DOCKER_GIT_BROWSER_PROJECT" - -# Keep config.toml consistent with the container build. -# If browser MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn browser-connection. -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: Browser MCP/ { next } - /^# 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.5" -model_reasoning_effort = "xhigh" -plan_mode_reasoning_effort = "xhigh" -personality = "pragmatic" - -approval_policy = "never" -sandbox_mode = "danger-full-access" -web_search = "live" - -[features] -shell_snapshot = true -multi_agent = true -apps = true -shell_tool = true - -[profiles.longcontx] -model = "gpt-5.5" -model_context_window = 1050000 -model_auto_compact_token_limit = 945000 -model_reasoning_effort = "xhigh" -plan_mode_reasoning_effort = "xhigh" -EOF - chown 1000:1000 "$CODEX_CONFIG_FILE" || true - fi - - # Replace the docker-git Browser MCP 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: Browser MCP/ { next } - /^# 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: Browser MCP (rust-browser-connection) -[mcp_servers.playwright] -command = "browser-connection" -args = ["--project", "$DOCKER_GIT_BROWSER_PROJECT", "--network", "$DOCKER_GIT_BROWSER_NETWORK"] -EOF -fi` - -export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => - entrypointMcpPlaywrightTemplate - .replaceAll("__CODEX_HOME__", config.codexHome) - .replaceAll("__SERVICE_NAME__", config.serviceName) - -const entrypointProjectCodexSkillsSyncTemplate = String - .raw`# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. -docker_git_sync_project_codex_skills() { - local codex_home="${"$"}{CODEX_HOME:-__CODEX_HOME__}" - local project_dir="${"$"}{TARGET_DIR:-}" - local project_skills_root="$codex_home/skills/.docker-git-project" - local linked=0 - local spec="" - local mount_name="" - local relative_path="" - - if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then - return 0 - fi - - mkdir -p "$codex_home/skills" - rm -rf "$project_skills_root" - mkdir -p "$project_skills_root" - - # Priority goes from generic/shared skill trees -> Codex-specific trees. - for spec in \ - "10-root-skills::.skills" \ - "20-agents-skills::.agents/skills" \ - "30-agents-dot-skills::.agents/.skills" \ - "80-codex-skills::.codex/skills" \ - "90-codex-dot-skills::.codex/.skills"; do - mount_name="${"$"}{spec%%::*}" - relative_path="${"$"}{spec#*::}" - - if [[ -d "$project_dir/$relative_path" ]]; then - ln -sfn "$project_dir/$relative_path" "$project_skills_root/$mount_name" - chown -h 1000:1000 "$project_skills_root/$mount_name" 2>/dev/null || true - linked=1 - fi - done - - # Extra entries via CODEX_EXTRA_SKILLS_PATHS (comma- or newline-separated "prio-name::relative/path"). - local extra_specs="${"$"}{CODEX_EXTRA_SKILLS_PATHS:-}" - if [[ -n "$extra_specs" ]]; then - extra_specs="${"$"}{extra_specs//,/$'\n'}" - while IFS= read -r spec; do - [[ -z "$spec" ]] && continue - mount_name="${"$"}{spec%%::*}" - relative_path="${"$"}{spec#*::}" - if [[ -d "$project_dir/$relative_path" ]]; then - ln -sfn "$project_dir/$relative_path" "$project_skills_root/$mount_name" - chown -h 1000:1000 "$project_skills_root/$mount_name" 2>/dev/null || true - linked=1 - fi - done <<< "$extra_specs" - fi - - chown 1000:1000 "$codex_home/skills" "$project_skills_root" 2>/dev/null || true - - if [[ "$linked" -eq 1 ]]; then - echo "[codex-skills] linked project skill trees into $project_skills_root" - fi -}` - -export const renderEntrypointProjectCodexSkillsSync = (config: TemplateConfig): string => - entrypointProjectCodexSkillsSyncTemplate.replaceAll("__CODEX_HOME__", config.codexHome) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/dns-repair.ts b/packages/app/src/lib/core/templates-entrypoint/dns-repair.ts deleted file mode 100644 index b34527b9..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/dns-repair.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* jscpd:ignore-start */ -// CHANGE: add automatic DNS repair at container startup -// WHY: Docker internal DNS (127.0.0.11) intermittently loses external nameservers, -// causing domain resolution to fail inside containers -// QUOTE(ТЗ): "При запуске контейнера он всегда исправляет интернет соединение потому что оно время от времени ложится" -// REF: issue-168 -// SOURCE: n/a -// FORMAT THEOREM: ∀container: startup(container) → dns_healthy(container) ∨ dns_repaired(container) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: after execution, at least one nameserver in /etc/resolv.conf resolves external domains -// COMPLEXITY: O(1) per probe attempt, O(max_attempts) worst case -export const renderEntrypointDnsRepair = (): string => - String.raw`# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken -docker_git_repair_dns() { - local test_domain="github.com" - local resolv="/etc/resolv.conf" - local fallback_dns="8.8.8.8 8.8.4.4 1.1.1.1" - - if getent hosts "$test_domain" >/dev/null 2>&1; then - return 0 - fi - - echo "[dns-repair] DNS resolution failed for $test_domain; attempting repair..." - - # Preserve Docker internal resolver but append external fallbacks - local has_external=0 - for ns in $fallback_dns; do - if grep -q "nameserver $ns" "$resolv" 2>/dev/null; then - has_external=1 - fi - done - - if [[ "$has_external" -eq 0 ]]; then - for ns in $fallback_dns; do - printf "nameserver %s\n" "$ns" >> "$resolv" - done - echo "[dns-repair] appended fallback nameservers to $resolv" - fi - - # Verify fix - if getent hosts "$test_domain" >/dev/null 2>&1; then - echo "[dns-repair] DNS resolution restored" - return 0 - fi - - echo "[dns-repair] WARNING: DNS resolution still failing after repair attempt" - return 1 -} -docker_git_repair_dns || true` -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/gemini.ts b/packages/app/src/lib/core/templates-entrypoint/gemini.ts deleted file mode 100644 index 3a8f308a..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/gemini.ts +++ /dev/null @@ -1,346 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -// CHANGE: add Gemini CLI entrypoint configuration -// WHY: enable Gemini CLI in Docker with automated auth, trust settings and MCP -// REF: issue-146 -// SOURCE: https://github.com/google-gemini/gemini-cli -// FORMAT THEOREM: renderEntrypointGeminiConfig(config) -> valid_bash_script -// PURITY: CORE -// INVARIANT: configurations are isolated by GEMINI_AUTH_LABEL -// COMPLEXITY: O(1) - -const geminiAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/gemini` - -const geminiAuthConfigTemplate = String - .raw`# Gemini CLI: keep ~/.gemini as a real home directory while sharing auth files from ~/.docker-git/.orch/auth/gemini -GEMINI_LABEL_RAW="$GEMINI_AUTH_LABEL" -if [[ -z "$GEMINI_LABEL_RAW" ]]; then - GEMINI_LABEL_RAW="default" -fi - -GEMINI_LABEL_NORM="$(printf "%s" "$GEMINI_LABEL_RAW" \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" -if [[ -z "$GEMINI_LABEL_NORM" ]]; then - GEMINI_LABEL_NORM="default" -fi - -GEMINI_AUTH_ROOT="__GEMINI_AUTH_ROOT__" -export GEMINI_CONFIG_DIR="$GEMINI_AUTH_ROOT/$GEMINI_LABEL_NORM" - -mkdir -p "$GEMINI_CONFIG_DIR" || true -GEMINI_HOME_DIR="__GEMINI_HOME_DIR__" -mkdir -p "$GEMINI_HOME_DIR" || true -GEMINI_SHARED_HOME_DIR="$GEMINI_CONFIG_DIR/.gemini" -mkdir -p "$GEMINI_SHARED_HOME_DIR" || true - -docker_git_link_gemini_file() { - local source_path="$1" - local link_path="$2" - - if [[ -e "$link_path" && ! -L "$link_path" ]]; then - if [[ -f "$link_path" && ! -e "$source_path" ]]; then - cp "$link_path" "$source_path" || true - chmod 0600 "$source_path" || true - fi - return 0 - fi - - ln -sfn "$source_path" "$link_path" || true -} - -docker_git_prepare_gemini_home_dir() { - if [[ -L "$GEMINI_HOME_DIR" ]]; then - local previous_target - previous_target="$(readlink -f "$GEMINI_HOME_DIR" || true)" - rm -f "$GEMINI_HOME_DIR" || true - mkdir -p "$GEMINI_HOME_DIR" || true - if [[ -n "$previous_target" && -d "$previous_target" ]]; then - cp -a "$previous_target"/. "$GEMINI_HOME_DIR"/ 2>/dev/null || true - fi - return 0 - fi - - mkdir -p "$GEMINI_HOME_DIR" || true -} - -docker_git_prepare_gemini_home_dir - -# Link .api-key and .env from central auth storage to container home -docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.api-key" "$GEMINI_HOME_DIR/.api-key" -docker_git_link_gemini_file "$GEMINI_CONFIG_DIR/.env" "$GEMINI_HOME_DIR/.env" -docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth_creds.json" "$GEMINI_HOME_DIR/oauth_creds.json" -docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/oauth-tokens.json" "$GEMINI_HOME_DIR/oauth-tokens.json" -docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/credentials.json" "$GEMINI_HOME_DIR/credentials.json" -docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/application_default_credentials.json" "$GEMINI_HOME_DIR/application_default_credentials.json" -docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/google_accounts.json" "$GEMINI_HOME_DIR/google_accounts.json" -docker_git_link_gemini_file "$GEMINI_SHARED_HOME_DIR/projects.json" "$GEMINI_HOME_DIR/projects.json" - -# Ensure gemini YOLO wrapper exists -GEMINI_REAL_BIN="$(command -v gemini || echo "/usr/local/bin/gemini")" -GEMINI_WRAPPER_BIN="/usr/local/bin/gemini-wrapper" -if [[ -f "$GEMINI_REAL_BIN" && "$GEMINI_REAL_BIN" != "$GEMINI_WRAPPER_BIN" ]]; then - if [[ ! -f "$GEMINI_WRAPPER_BIN" ]]; then - cat <<'EOF' > "$GEMINI_WRAPPER_BIN" -#!/usr/bin/env bash -GEMINI_ORIGINAL_BIN="__GEMINI_REAL_BIN__" -exec "$GEMINI_ORIGINAL_BIN" --yolo "$@" -EOF - sed -i "s#__GEMINI_REAL_BIN__#$GEMINI_REAL_BIN#g" "$GEMINI_WRAPPER_BIN" || true - chmod 0755 "$GEMINI_WRAPPER_BIN" || true - # Create an alias or symlink if needed, but here we just ensure it exists - fi -fi - -docker_git_refresh_gemini_env() { - # If .api-key exists, export it as GEMINI_API_KEY - if [[ -f "$GEMINI_HOME_DIR/.api-key" ]]; then - export GEMINI_API_KEY="$(cat "$GEMINI_HOME_DIR/.api-key" | tr -d '\r\n')" - elif [[ -f "$GEMINI_HOME_DIR/.env" ]]; then - # Parse GEMINI_API_KEY from .env - API_KEY="$(grep "^GEMINI_API_KEY=" "$GEMINI_HOME_DIR/.env" | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//")" - if [[ -n "$API_KEY" ]]; then - export GEMINI_API_KEY="$API_KEY" - fi - fi -} - -docker_git_refresh_gemini_env` - -const renderGeminiAuthConfig = (config: TemplateConfig): string => - geminiAuthConfigTemplate - .replaceAll("__GEMINI_AUTH_ROOT__", geminiAuthRootContainerPath(config.sshUser)) - .replaceAll("__GEMINI_HOME_DIR__", config.geminiHome) - -const geminiSettingsJsonTemplate = `{ - "model": { - "name": "gemini-3.1-pro-preview", - "compressionThreshold": 0.9, - "disableLoopDetection": true - }, - "modelConfigs": { - "customAliases": { - "yolo-ultra": { - "modelConfig": { - "model": "gemini-3.1-pro-preview", - "generateContentConfig": { - "tools": [ - { - "googleSearch": {} - }, - { - "urlContext": {} - } - ] - } - } - } - } - }, - "general": { - "defaultApprovalMode": "auto_edit" - }, - "tools": { - "allowed": [ - "run_shell_command", - "write_file", - "googleSearch", - "urlContext" - ] - }, - "sandbox": { - "enabled": false - }, - "security": { - "folderTrust": { - "enabled": false - }, - "auth": { - "selectedType": "oauth-personal" - }, - "disableYoloMode": false - } -}` - -const renderGeminiPermissionSettingsConfig = (config: TemplateConfig): string => - String.raw`# Gemini CLI: keep trust settings in sync with docker-git defaults -GEMINI_SETTINGS_DIR="${config.geminiHome}" -GEMINI_TRUST_SETTINGS_FILE="$GEMINI_SETTINGS_DIR/trustedFolders.json" -GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_SETTINGS_DIR/settings.json" - -# Wait for symlink to be established by the auth config step -mkdir -p "$GEMINI_SETTINGS_DIR" || true - -# Disable folder trust prompt and enable auto-approval in settings.json -cat <<'EOF' > "$GEMINI_CONFIG_SETTINGS_FILE" -${geminiSettingsJsonTemplate} -EOF - -# Pre-trust important directories in trustedFolders.json -# Use flat mapping as required by recent Gemini CLI versions -cat <<'EOF' > "$GEMINI_TRUST_SETTINGS_FILE" -{ - "/": "TRUST_FOLDER", - "${config.geminiHome}": "TRUST_FOLDER", - "${config.targetDir}": "TRUST_FOLDER" -} -EOF - -chown -R 1000:1000 "$GEMINI_SETTINGS_DIR" || true -chmod 0600 "$GEMINI_TRUST_SETTINGS_FILE" "$GEMINI_CONFIG_SETTINGS_FILE" 2>/dev/null || true` - -const renderGeminiSudoConfig = (config: TemplateConfig): string => - String.raw`# Gemini CLI: allow passwordless sudo for agent tasks -if [[ -d /etc/sudoers.d ]]; then - echo "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/gemini-agent - chmod 0440 /etc/sudoers.d/gemini-agent -fi` - -const renderGeminiMcpPlaywrightConfig = (): string => - String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings -docker_git_sync_gemini_playwright_mcp() { - local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" - local browser_network="container:$browser_project" - GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" -const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } -} else { - delete nextServers.playwright -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_gemini_playwright_mcp` - -const renderGeminiProfileSetup = (config: TemplateConfig): string => - String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" -printf "export GEMINI_AUTH_LABEL=%q\n" "$GEMINI_AUTH_LABEL" > "$GEMINI_PROFILE" -printf "export GEMINI_HOME=%q\n" "${config.geminiHome}" >> "$GEMINI_PROFILE" -printf "export GEMINI_CLI_DISABLE_UPDATE_CHECK=true\n" >> "$GEMINI_PROFILE" -printf "export GEMINI_CLI_NONINTERACTIVE=true\n" >> "$GEMINI_PROFILE" -printf "export GEMINI_CLI_APPROVAL_MODE=yolo\n" >> "$GEMINI_PROFILE" -printf "alias gemini='/usr/local/bin/gemini-wrapper'\n" >> "$GEMINI_PROFILE" -cat <<'EOF' >> "$GEMINI_PROFILE" -if [[ -f "$GEMINI_HOME/.api-key" ]]; then - export GEMINI_API_KEY="$(cat "$GEMINI_HOME/.api-key" | tr -d '\r\n')" -fi -EOF -chmod 0644 "$GEMINI_PROFILE" || true - -docker_git_upsert_ssh_env "GEMINI_AUTH_LABEL" "$GEMINI_AUTH_LABEL" -docker_git_upsert_ssh_env "GEMINI_API_KEY" "\${GEMINI_API_KEY:-}" -docker_git_upsert_ssh_env "GEMINI_CLI_DISABLE_UPDATE_CHECK" "true" -docker_git_upsert_ssh_env "GEMINI_CLI_NONINTERACTIVE" "true" -docker_git_upsert_ssh_env "GEMINI_CLI_APPROVAL_MODE" "yolo"` - -const entrypointGeminiNoticeTemplate = String.raw`# Ensure global GEMINI.md exists for container context -GEMINI_MD_PATH="__GEMINI_HOME__/GEMINI.md" -docker_git_decode_unicode_escapes() { - local value="$1" - if printf "%s" "$value" | grep -q '\\u[0-9a-fA-F]'; then - printf "%b" "$value" - else - printf "%s" "$value" - fi -} -GEMINI_WORKSPACE_CONTEXT="Контекст workspace: repository" -if [[ "$REPO_REF" == issue-* ]]; then - ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" - ISSUE_URL="" - if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi - fi - if [[ -n "$ISSUE_URL" ]]; then - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" - else - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID" - fi -elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" - PR_URL="" - if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then - PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO" ]]; then - PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" - fi - fi - if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID ($PR_URL)" - elif [[ -n "$PR_ID" ]]; then - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID" - else - GEMINI_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF)" - fi -fi - -GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{GEMINI_SYSTEM_PROMPT_OVERRIDE_FILE:-}" -GEMINI_SYSTEM_PROMPT_OVERRIDE="${"$"}{GEMINI_SYSTEM_PROMPT_OVERRIDE:-}" -GEMINI_DEFAULT_PROMPT_BODY="$(cat < "$GEMINI_MD_PATH" - -$GEMINI_PROMPT_BODY - -EOF -chown 1000:1000 "$GEMINI_MD_PATH" || true` - -const renderEntrypointGeminiNotice = (config: TemplateConfig): string => - entrypointGeminiNoticeTemplate - .replaceAll("__GEMINI_HOME__", config.geminiHome) - .replaceAll("__TARGET_DIR__", config.targetDir) - -export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => - [ - renderGeminiAuthConfig(config), - renderGeminiPermissionSettingsConfig(config), - renderGeminiMcpPlaywrightConfig(), - renderGeminiSudoConfig(config), - renderGeminiProfileSetup(config), - renderEntrypointGeminiNotice(config) - ].join("\n\n") -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts b/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts deleted file mode 100644 index eac12e71..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/git-hooks.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { renderEntrypointGitPostPushWrapperInstall } from "./git-post-push-wrapper.js" -import { - renderPlanToGitAgentHooksInstall, - renderPlanToGitHookPaths, - renderPlanToGitPostPushSync, - renderPlanToGitSyncHelperInstall -} from "./plan-to-git.js" -import { renderPostPushPrEnsure } from "./post-push-pr.js" - -const entrypointGitHooksTemplate = String - .raw`# 3) Install global git hooks to protect main/master + managed AGENTS context -HOOKS_DIR="/opt/docker-git/hooks" -PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" -POST_PUSH_ACTION="$HOOKS_DIR/post-push" -${renderPlanToGitHookPaths()} -mkdir -p "$HOOKS_DIR" - -cat <<'EOF' > "$PRE_PUSH_HOOK" -#!/usr/bin/env bash -set -euo pipefail - -protected_branches=("refs/heads/main" "refs/heads/master") -allow_delete="${"${"}DOCKER_GIT_ALLOW_DELETE:-}" -zero_sha="0000000000000000000000000000000000000000" -issue_managed_start='' -issue_managed_end='' - -extract_issue_block() { - local ref="$1" - - if ! git cat-file -e "$ref" 2>/dev/null; then - return 0 - fi - - local awk_status=0 - if ! git cat-file -p "$ref" | awk -v start="$issue_managed_start" -v end="$issue_managed_end" ' - BEGIN { in_block = 0; found = 0 } - $0 == start { in_block = 1; found = 1 } - in_block == 1 { print } - $0 == end && in_block == 1 { in_block = 0; exit } - END { - if (found == 0) exit 3 - if (in_block == 1) exit 2 - } - '; then - awk_status=$? - if [[ "$awk_status" -eq 3 ]]; then - return 0 - fi - return "$awk_status" - fi -} - -commit_changes_issue_block() { - local commit="$1" - local parent="" - local commit_block="" - local parent_block="" - - if ! git diff-tree --no-commit-id --name-only -r "$commit" -- AGENTS.md | grep -qx "AGENTS.md"; then - return 1 - fi - - if ! commit_block="$(extract_issue_block "$commit:AGENTS.md")"; then - return 2 - fi - - parent="$(git rev-list --parents -n 1 "$commit" | awk '{print $2}')" - if [[ -n "$parent" ]]; then - if ! parent_block="$(extract_issue_block "$parent:AGENTS.md")"; then - return 2 - fi - fi - - if [[ "$commit_block" != "$parent_block" ]]; then - return 0 - fi - return 1 -} - -check_issue_managed_block_range() { - local local_sha="$1" - local remote_sha="$2" - local commits="" - local commit="" - local guard_status=0 - - if [[ "$local_sha" == "$zero_sha" ]]; then - return 0 - fi - - if [[ "$remote_sha" == "$zero_sha" ]]; then - commits="$(git rev-list "$local_sha" --not --remotes 2>/dev/null || true)" - if [[ -z "$commits" ]]; then - commits="$local_sha" - fi - else - commits="$(git rev-list "$remote_sha..$local_sha" 2>/dev/null || true)" - fi - - for commit in $commits; do - commit_changes_issue_block "$commit" - guard_status=$? - if [[ "$guard_status" -eq 0 ]]; then - echo "docker-git: push contains commit updating managed issue block in AGENTS.md: $commit" - echo "docker-git: this block is runtime context and must stay outside repository history." - return 1 - fi - if [[ "$guard_status" -eq 2 ]]; then - echo "docker-git: failed to parse managed issue block in AGENTS.md for commit $commit" - echo "docker-git: push blocked to prevent committing runtime workspace metadata." - return 1 - fi - done - - return 0 -} - -while read -r local_ref local_sha remote_ref remote_sha; do - if [[ -z "$remote_ref" ]]; then - continue - fi - for protected in "${"${"}protected_branches[@]}"; do - if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then - echo "docker-git: push to protected branch '${"${"}protected##*/}' is disabled." - echo "docker-git: create a new branch: git checkout -b " - exit 1 - fi - done - if ! check_issue_managed_block_range "$local_sha" "$remote_sha"; then - exit 1 - fi - if [[ "$local_sha" == "$zero_sha" && "$remote_ref" == refs/heads/* ]]; then - if [[ "$allow_delete" != "1" ]]; then - echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." - exit 1 - fi - fi -done -EOF -chmod 0755 "$PRE_PUSH_HOOK" - -${renderPlanToGitSyncHelperInstall()} - -cat <<'EOF' > "$POST_PUSH_ACTION" -#!/usr/bin/env bash -set -euo pipefail - -# 5) Run plan sync and session backup after successful push -REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}" -if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then - REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -fi -cd "$REPO_ROOT" - -${renderPostPushPrEnsure()} - -${renderPlanToGitPostPushSync()} - -# CHANGE: keep post-push backup logic in a reusable action script -# WHY: git has no client-side post-push hook, so the global git wrapper -# invokes this after a successful git push -# REF: issue-192 -if [ "${"${"}DOCKER_GIT_SKIP_SESSION_BACKUP:-}" != "1" ]; then - if ! command -v gh >/dev/null 2>&1; then - echo "[session-backup] Error: gh CLI not found" - exit 1 - fi - if ! command -v docker-git-session-sync >/dev/null 2>&1; then - echo "[session-backup] Error: docker-git-session-sync not found" - exit 1 - fi - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 docker-git-session-sync backup --verbose --background --require-comment -fi -EOF -chmod 0755 "$POST_PUSH_ACTION" - -${renderPlanToGitAgentHooksInstall()} - -${renderEntrypointGitPostPushWrapperInstall()} - -git config --system core.hooksPath "$HOOKS_DIR" || true -git config --global core.hooksPath "$HOOKS_DIR" || true` - -export const renderEntrypointGitHooks = (): string => entrypointGitHooksTemplate diff --git a/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts b/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts deleted file mode 100644 index 5d30822e..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/git-post-push-wrapper.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* jscpd:ignore-start */ -const entrypointGitPostPushWrapperInstall = String - .raw`# 5.5) Install git wrapper so post-push actions run for normal git push invocations. -# Git has no client-side post-push hook, so core.hooksPath alone is insufficient. -GIT_WRAPPER_BIN="/usr/local/bin/git" -GIT_REAL_BIN="$(type -aP git | awk -v wrapper="$GIT_WRAPPER_BIN" '$0 != wrapper { print; exit }')" -if [[ -n "$GIT_REAL_BIN" ]]; then - cat <<'EOF' > "$GIT_WRAPPER_BIN" -#!/usr/bin/env bash -set -euo pipefail - -# docker-git managed git wrapper -DOCKER_GIT_REAL_GIT_BIN="__DOCKER_GIT_REAL_BIN__" -DOCKER_GIT_POST_PUSH_ACTION="/opt/docker-git/hooks/post-push" - -docker_git_git_subcommand() { - local expect_value="0" - local arg="" - for arg in "$@"; do - if [[ "$expect_value" == "1" ]]; then - expect_value="0" - continue - fi - - case "$arg" in - --help|-h|--version|--html-path|--man-path|--info-path|--list-cmds|--list-cmds=*) - return 1 - ;; - -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) - expect_value="1" - continue - ;; - --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) - continue - ;; - --) - return 1 - ;; - -*) - continue - ;; - *) - printf "%s" "$arg" - return 0 - ;; - esac - done - - return 1 -} - -docker_git_git_resolve_repo_root() { - local -a git_context=() - local expect_value="0" - local arg="" - - for arg in "$@"; do - if [[ "$expect_value" == "1" ]]; then - git_context+=("$arg") - expect_value="0" - continue - fi - - case "$arg" in - -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) - git_context+=("$arg") - expect_value="1" - continue - ;; - --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) - git_context+=("$arg") - continue - ;; - --) - break - ;; - -*) - continue - ;; - *) - break - ;; - esac - done - - "$DOCKER_GIT_REAL_GIT_BIN" "${"${"}git_context[@]}" rev-parse --show-toplevel 2>/dev/null -} - -docker_git_git_push_is_dry_run() { - local expect_value="0" - local parsing_push_args="0" - local arg="" - - for arg in "$@"; do - if [[ "$parsing_push_args" == "0" ]]; then - if [[ "$expect_value" == "1" ]]; then - expect_value="0" - continue - fi - - case "$arg" in - -c|-C|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env) - expect_value="1" - continue - ;; - --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--super-prefix=*|--config-env=*|--bare|--no-pager|--paginate|--literal-pathspecs|--no-literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-lazy-fetch) - continue - ;; - push) - parsing_push_args="1" - continue - ;; - esac - - continue - fi - - case "$arg" in - --) - break - ;; - --dry-run|-n) - return 0 - ;; - esac - done - - return 1 -} - -docker_git_post_push_action() { - local repo_root="" - - if [[ "${"${"}DOCKER_GIT_SKIP_POST_PUSH_ACTION:-}" == "1" ]]; then - return 0 - fi - - if [[ -x "$DOCKER_GIT_POST_PUSH_ACTION" ]]; then - if repo_root="$(docker_git_git_resolve_repo_root "$@")" && [[ -n "$repo_root" ]]; then - DOCKER_GIT_POST_PUSH_REPO_ROOT="$repo_root" DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" - else - DOCKER_GIT_SKIP_POST_PUSH_ACTION=1 "$DOCKER_GIT_POST_PUSH_ACTION" - fi - fi -} - -subcommand="" -if subcommand="$(docker_git_git_subcommand "$@")" && [[ "$subcommand" == "push" ]]; then - if "$DOCKER_GIT_REAL_GIT_BIN" "$@"; then - status=0 - else - status=$? - fi - - if [[ "$status" -eq 0 ]] && ! docker_git_git_push_is_dry_run "$@"; then - docker_git_post_push_action "$@" || status=$? - fi - - exit "$status" -fi - -exec "$DOCKER_GIT_REAL_GIT_BIN" "$@" -EOF - sed -i "s#__DOCKER_GIT_REAL_BIN__#$GIT_REAL_BIN#g" "$GIT_WRAPPER_BIN" || true - chmod 0755 "$GIT_WRAPPER_BIN" || true -fi` - -export const renderEntrypointGitPostPushWrapperInstall = (): string => entrypointGitPostPushWrapperInstall -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/grok.ts b/packages/app/src/lib/core/templates-entrypoint/grok.ts deleted file mode 100644 index 60ffd5b2..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/grok.ts +++ /dev/null @@ -1,352 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -// CHANGE: add Grok CLI entrypoint configuration -// WHY: issue #304 requires Grok auth, Playwright MCP and unrestricted agent permissions -// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" -// REF: issue-304 -// SOURCE: https://x.ai/news/grok-build-cli -// FORMAT THEOREM: renderEntrypointGrokConfig(config) -> valid_bash_script -// PURITY: CORE -// INVARIANT: Grok credentials are isolated by GROK_AUTH_LABEL -// COMPLEXITY: O(1) - -const grokAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/grok` - -// CHANGE: render shell parameter defaults through TypeScript interpolation -// WHY: String.raw preserves escaped \${...}, making optional Grok credentials fail under bash nounset -// QUOTE(ТЗ): "GitHub Actions ... all CI checks are passing" -// REF: issue-304-ci -// SOURCE: n/a -// FORMAT THEOREM: unset(GROK_DEPLOYMENT_KEY) -> safe_empty_value(GROK_DEPLOYMENT_KEY) -// PURITY: CORE -// INVARIANT: Optional Grok API credentials never require a bound environment variable -// COMPLEXITY: O(1) -const grokDeploymentKeyDefaultExpansion = "${GROK_DEPLOYMENT_KEY:-}" -const grokApiKeyDefaultExpansion = "${GROK_API_KEY:-}" -const grokAuthLabelDefaultExpansion = "${GROK_AUTH_LABEL:-}" -const xaiApiKeyDefaultExpansion = "${XAI_API_KEY:-}" - -const grokAuthConfigTemplate = String - .raw`# Grok CLI: keep ~/.grok as a real home directory while sharing auth files from ~/.docker-git/.orch/auth/grok -GROK_LABEL_RAW="${grokAuthLabelDefaultExpansion}" -if [[ -z "$GROK_LABEL_RAW" ]]; then - GROK_LABEL_RAW="default" -fi - -GROK_LABEL_NORM="$(printf "%s" "$GROK_LABEL_RAW" \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" -if [[ -z "$GROK_LABEL_NORM" ]]; then - GROK_LABEL_NORM="default" -fi -export GROK_AUTH_LABEL="$GROK_LABEL_NORM" - -GROK_AUTH_ROOT="__GROK_AUTH_ROOT__" -export GROK_CONFIG_DIR="$GROK_AUTH_ROOT/$GROK_LABEL_NORM" - -mkdir -p "$GROK_CONFIG_DIR" || true -GROK_HOME_DIR="__GROK_HOME_DIR__" -mkdir -p "$GROK_HOME_DIR" || true -GROK_SHARED_HOME_DIR="$GROK_CONFIG_DIR/.grok" -mkdir -p "$GROK_SHARED_HOME_DIR" || true - -docker_git_link_grok_file() { - local source_path="$1" - local link_path="$2" - - if [[ -e "$link_path" && ! -L "$link_path" ]]; then - if [[ -f "$link_path" && ! -e "$source_path" ]]; then - cp "$link_path" "$source_path" || true - chmod 0600 "$source_path" || true - fi - if [[ -d "$link_path" ]]; then - return 0 - fi - fi - - ln -sfn "$source_path" "$link_path" || true -} - -docker_git_prepare_grok_home_dir() { - if [[ -L "$GROK_HOME_DIR" ]]; then - local previous_target - previous_target="$(readlink -f "$GROK_HOME_DIR" || true)" - rm -f "$GROK_HOME_DIR" || true - mkdir -p "$GROK_HOME_DIR" || true - if [[ -n "$previous_target" && -d "$previous_target" ]]; then - cp -a "$previous_target"/. "$GROK_HOME_DIR"/ 2>/dev/null || true - fi - return 0 - fi - - mkdir -p "$GROK_HOME_DIR" || true -} - -docker_git_prepare_grok_home_dir - -docker_git_link_grok_file "$GROK_CONFIG_DIR/.api-key" "$GROK_HOME_DIR/.api-key" -docker_git_link_grok_file "$GROK_CONFIG_DIR/.env" "$GROK_HOME_DIR/.env" -docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/auth.json" "$GROK_HOME_DIR/auth.json" -docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/config.toml" "$GROK_HOME_DIR/config.toml" -docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/managed_config.toml" "$GROK_HOME_DIR/managed_config.toml" -docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/requirements.toml" "$GROK_HOME_DIR/requirements.toml" -docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/user-settings.json" "$GROK_HOME_DIR/user-settings.json" -docker_git_link_grok_file "$GROK_SHARED_HOME_DIR/settings.json" "$GROK_HOME_DIR/settings.json" - -GROK_REAL_BIN="$(command -v grok || echo "/usr/local/bin/grok")" -GROK_WRAPPER_BIN="/usr/local/bin/grok-wrapper" -if [[ -f "$GROK_REAL_BIN" && "$GROK_REAL_BIN" != "$GROK_WRAPPER_BIN" ]]; then - if [[ ! -f "$GROK_WRAPPER_BIN" ]]; then - cat <<'EOF' > "$GROK_WRAPPER_BIN" -#!/usr/bin/env bash -GROK_ORIGINAL_BIN="__GROK_REAL_BIN__" -for arg in "$@"; do - if [[ "$arg" == "--no-sandbox" ]]; then - exec "$GROK_ORIGINAL_BIN" "$@" - fi -done -exec "$GROK_ORIGINAL_BIN" --no-sandbox "$@" -EOF - sed -i "s#__GROK_REAL_BIN__#$GROK_REAL_BIN#g" "$GROK_WRAPPER_BIN" || true - chmod 0755 "$GROK_WRAPPER_BIN" || true - fi -fi - -docker_git_refresh_grok_env() { - local RESOLVED_GROK_API_KEY - if [[ -f "$GROK_HOME_DIR/.api-key" ]]; then - RESOLVED_GROK_API_KEY="$(cat "$GROK_HOME_DIR/.api-key" | tr -d '\r\n')" - elif [[ -f "$GROK_HOME_DIR/.env" ]]; then - RESOLVED_GROK_API_KEY="$(grep -E "^GROK_DEPLOYMENT_KEY=" "$GROK_HOME_DIR/.env" 2>/dev/null | head -n 1 | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//" || true)" - if [[ -z "$RESOLVED_GROK_API_KEY" ]]; then - RESOLVED_GROK_API_KEY="$(grep -E "^GROK_API_KEY=" "$GROK_HOME_DIR/.env" 2>/dev/null | head -n 1 | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//" || true)" - fi - if [[ -z "$RESOLVED_GROK_API_KEY" ]]; then - RESOLVED_GROK_API_KEY="$(grep -E "^XAI_API_KEY=" "$GROK_HOME_DIR/.env" 2>/dev/null | head -n 1 | cut -d'=' -f2- | sed "s/^['\"]//;s/['\"]$//" || true)" - fi - elif [[ -n "${grokDeploymentKeyDefaultExpansion}" ]]; then - RESOLVED_GROK_API_KEY="${grokDeploymentKeyDefaultExpansion}" - elif [[ -n "${grokApiKeyDefaultExpansion}" ]]; then - RESOLVED_GROK_API_KEY="${grokApiKeyDefaultExpansion}" - elif [[ -n "${xaiApiKeyDefaultExpansion}" ]]; then - RESOLVED_GROK_API_KEY="${xaiApiKeyDefaultExpansion}" - else - RESOLVED_GROK_API_KEY="" - fi - # Priority: selected account files, then GROK_DEPLOYMENT_KEY, GROK_API_KEY, XAI_API_KEY. - if [[ -n "$RESOLVED_GROK_API_KEY" ]]; then - export GROK_DEPLOYMENT_KEY="$RESOLVED_GROK_API_KEY" - export GROK_API_KEY="$RESOLVED_GROK_API_KEY" - export XAI_API_KEY="$RESOLVED_GROK_API_KEY" - fi -} - -docker_git_refresh_grok_env` - -const renderGrokAuthConfig = (config: TemplateConfig): string => - grokAuthConfigTemplate - .replaceAll("__GROK_AUTH_ROOT__", grokAuthRootContainerPath(config.sshUser)) - .replaceAll("__GROK_HOME_DIR__", config.grokHome) - -const grokSettingsJsonTemplate = `{ - "sandboxMode": "off", - "confirmBeforeToolUse": false -}` - -const grokUserSettingsJsonTemplate = `{ - "sandboxMode": "off", - "confirmBeforeToolUse": false -}` - -const renderGrokPermissionSettingsConfig = (config: TemplateConfig): string => - String.raw`# Grok CLI: keep sandbox and MCP settings in sync with docker-git defaults -GROK_SETTINGS_DIR="${config.grokHome}" -GROK_CONFIG_SETTINGS_FILE="$GROK_SETTINGS_DIR/settings.json" -GROK_USER_SETTINGS_FILE="$GROK_SETTINGS_DIR/user-settings.json" - -mkdir -p "$GROK_SETTINGS_DIR" || true - -cat <<'EOF' > "$GROK_CONFIG_SETTINGS_FILE" -${grokSettingsJsonTemplate} -EOF - -if [[ ! -s "$GROK_USER_SETTINGS_FILE" ]]; then - cat <<'EOF' > "$GROK_USER_SETTINGS_FILE" -${grokUserSettingsJsonTemplate} -EOF -fi - -GROK_SETTINGS_OWNER_UID="$(id -u "${config.sshUser}" 2>/dev/null || id -u)" -GROK_SETTINGS_OWNER_GID="$(id -g "${config.sshUser}" 2>/dev/null || id -g)" -chown -R "$GROK_SETTINGS_OWNER_UID:$GROK_SETTINGS_OWNER_GID" "$GROK_SETTINGS_DIR" || true -chmod 0600 "$GROK_CONFIG_SETTINGS_FILE" "$GROK_USER_SETTINGS_FILE" 2>/dev/null || true` - -const renderGrokMcpPlaywrightConfig = (): string => - String.raw`# Grok CLI: keep Playwright MCP config in sync with container settings -docker_git_sync_grok_playwright_mcp() { - local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}" - if [[ -z "$browser_project" ]]; then - browser_project="$(hostname)" - fi - local browser_network="container:$browser_project" - GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" -const browserNetwork = process.env.DOCKER_GIT_BROWSER_NETWORK || (browserProject.length > 0 ? "container:" + browserProject : "") -const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", browserNetwork] : [] -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } -} else { - delete nextServers.playwright -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_grok_playwright_mcp` - -const renderGrokSudoConfig = (config: TemplateConfig): string => - String.raw`# Grok CLI: allow passwordless sudo for agent tasks -# Risk rationale: Grok runs inside an isolated per-project container. The sshUser -# value is validated as a Unix username before TemplateConfig construction, and -# passwordless sudo matches the broad container-local privileges expected for -# docker-git coding agents that need to install packages or manage services. -if [[ -d /etc/sudoers.d ]]; then - echo "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/grok-agent - chmod 0440 /etc/sudoers.d/grok-agent -fi` - -const renderGrokProfileSetup = (config: TemplateConfig): string => - String.raw`GROK_PROFILE="/etc/profile.d/grok-config.sh" -printf "export GROK_AUTH_LABEL=%q\n" "$GROK_AUTH_LABEL" > "$GROK_PROFILE" -printf "export GROK_HOME=%q\n" "${config.grokHome}" >> "$GROK_PROFILE" -printf "alias grok='/usr/local/bin/grok-wrapper'\n" >> "$GROK_PROFILE" -cat <<'EOF' >> "$GROK_PROFILE" -if [[ -f "$GROK_HOME/.api-key" ]]; then - API_KEY="$(cat "$GROK_HOME/.api-key" | tr -d '\r\n')" - export GROK_DEPLOYMENT_KEY="$API_KEY" - export GROK_API_KEY="$API_KEY" - export XAI_API_KEY="$API_KEY" -fi -EOF -chmod 0644 "$GROK_PROFILE" || true - -docker_git_upsert_ssh_env "GROK_AUTH_LABEL" "$GROK_AUTH_LABEL" -docker_git_upsert_ssh_env "GROK_DEPLOYMENT_KEY" "${grokDeploymentKeyDefaultExpansion}" -docker_git_upsert_ssh_env "GROK_API_KEY" "${grokApiKeyDefaultExpansion}" -docker_git_upsert_ssh_env "XAI_API_KEY" "${xaiApiKeyDefaultExpansion}"` - -const entrypointGrokNoticeTemplate = String.raw`# Ensure global GROK.md exists for container context -GROK_MD_PATH="__GROK_HOME__/GROK.md" -GROK_WORKSPACE_CONTEXT="Контекст workspace: repository" -if [[ "$REPO_REF" == issue-* ]]; then - ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" - ISSUE_URL="" - if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi - fi - if [[ -n "$ISSUE_URL" ]]; then - GROK_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" - else - GROK_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID" - fi -elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" - PR_URL="" - if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then - PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO" ]]; then - PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" - fi - fi - if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then - GROK_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID ($PR_URL)" - elif [[ -n "$PR_ID" ]]; then - GROK_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID" - else - GROK_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF)" - fi -fi - -GROK_SYSTEM_PROMPT_OVERRIDE_FILE="${"$"}{GROK_SYSTEM_PROMPT_OVERRIDE_FILE:-}" -GROK_SYSTEM_PROMPT_OVERRIDE="${"$"}{GROK_SYSTEM_PROMPT_OVERRIDE:-}" -GROK_DEFAULT_PROMPT_BODY="$(cat < "$GROK_MD_PATH" - -$GROK_PROMPT_BODY - -EOF -GROK_NOTICE_OWNER_UID="$(id -u "__SSH_USER__" 2>/dev/null || id -u)" -GROK_NOTICE_OWNER_GID="$(id -g "__SSH_USER__" 2>/dev/null || id -g)" -chown "$GROK_NOTICE_OWNER_UID:$GROK_NOTICE_OWNER_GID" "$GROK_MD_PATH" || true` - -const renderEntrypointGrokNotice = (config: TemplateConfig): string => - entrypointGrokNoticeTemplate - .replaceAll("__GROK_HOME__", config.grokHome) - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__TARGET_DIR__", config.targetDir) - -/** - * Renders the Grok CLI entrypoint bootstrap for a generated project container. - * - * @param config Project template configuration with SSH user, Grok home, and target directory paths. - * @returns Bash fragment that wires Grok auth labels, config files, profile exports, sudo policy, and managed GROK.md. - * @pure true - * @effect none; CORE template renderer only constructs a string. - * @invariant returned script keeps Grok credentials scoped by GROK_AUTH_LABEL. - * @precondition config contains validated container paths from TemplateConfig construction. - * @postcondition returned string contains all Grok setup fragments in deterministic order. - * @complexity O(1) time / O(1) space. - */ -export const renderEntrypointGrokConfig = (config: TemplateConfig): string => - [ - renderGrokAuthConfig(config), - renderGrokPermissionSettingsConfig(config), - renderGrokMcpPlaywrightConfig(), - renderGrokSudoConfig(config), - renderGrokProfileSetup(config), - renderEntrypointGrokNotice(config) - ].join("\n\n") -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts deleted file mode 100644 index c385058d..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -const entrypointDockerGitBootstrapTemplate = String - .raw`# Bootstrap ~/.docker-git for nested docker-git usage inside this container. -DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git" -DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" -DOCKER_GIT_CLAUDE_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/claude" -DOCKER_GIT_GROK_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/grok" -DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" -DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" -DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" -DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys" -BOOTSTRAP_ROOT="/opt/docker-git/bootstrap" -BOOTSTRAP_SOURCE_ROOT="$BOOTSTRAP_ROOT/source" -BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_SOURCE_ROOT/authorized-keys/__AUTHORIZED_KEYS_BASENAME__" -BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/codex" -BOOTSTRAP_CODEX_SHARED_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/shared-auth/codex" -BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/claude" -BOOTSTRAP_GROK_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/grok" -BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_SOURCE_ROOT/env-global/__ENV_GLOBAL_BASENAME__" -BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_SOURCE_ROOT/env-project/__ENV_PROJECT_BASENAME__" - -mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" - -sync_file_if_present() { - local source="$1" - local target="$2" - if [[ ! -f "$source" ]]; then - return 1 - fi - mkdir -p "$(dirname "$target")" - cp "$source" "$target" - return 0 -} - -sync_file_or_remove() { - local source="$1" - local target="$2" - if [[ -f "$source" ]]; then - sync_file_if_present "$source" "$target" - return 0 - fi - rm -f "$target" || true - return 1 -} - -sync_dir_entries() { - local source="$1" - local target="$2" - if [[ ! -d "$source" ]]; then - return 0 - fi - mkdir -p "$target" - ( - cd "$source" - find . -mindepth 1 -print - ) | while IFS= read -r entry; do - local source_entry="$source/$entry" - local target_entry="$target/$entry" - if [[ -d "$source_entry" ]]; then - mkdir -p "$target_entry" - elif [[ -f "$source_entry" ]]; then - mkdir -p "$(dirname "$target_entry")" - cp "$source_entry" "$target_entry" - fi - done -} - -sync_labeled_auth_files() { - local source_root="$1" - local target_root="$2" - - sync_file_or_remove "$source_root/auth.json" "$target_root/auth.json" || true - - if [[ -d "$source_root" ]]; then - ( - cd "$source_root" - find . -mindepth 1 -maxdepth 1 -type d -print - ) | while IFS= read -r entry; do - sync_file_or_remove "$source_root/$entry/auth.json" "$target_root/$entry/auth.json" || true - done - fi - - if [[ -d "$target_root" ]]; then - ( - cd "$target_root" - find . -mindepth 1 -maxdepth 1 -type d -print - ) | while IFS= read -r entry; do - if [[ ! -d "$source_root/$entry" ]]; then - rm -f "$target_root/$entry/auth.json" || true - fi - done - fi -} - -if [[ ! -f "$DOCKER_GIT_AUTH_KEYS" && -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then - cp "/home/__SSH_USER__/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" -fi -sync_file_if_present "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true -if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then - chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true -fi - -sync_file_if_present "$BOOTSTRAP_ENV_GLOBAL" "$DOCKER_GIT_ENV_GLOBAL" || true -if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then - cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" -# docker-git env -# KEY=value -EOF -fi -sync_file_if_present "$BOOTSTRAP_ENV_PROJECT" "$DOCKER_GIT_ENV_PROJECT" || true -if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then - cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" -# docker-git project env defaults -CODEX_SHARE_AUTH=1 -CODEX_AUTO_UPDATE=1 -DOCKER_GIT_RTK_ENABLE=1 -DOCKER_GIT_ZSH_AUTOSUGGEST=0 -DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic -DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion -MCP_PLAYWRIGHT_ISOLATED=0 -EOF -fi - -upsert_env_var() { - local file="$1" - local key="$2" - local value="$3" - local tmp - tmp="$(mktemp)" - awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp" - printf "%s=%s\n" "$key" "$value" >> "$tmp" - mv "$tmp" "$file" -} - -docker_git_export_env_if_unset() { - local key="$1" - local value="$2" - - if [[ -n "${"$"}{!key+x}" ]]; then - docker_git_upsert_ssh_env "$key" "${"$"}{!key}" - return 0 - fi - - export "$key=$value" - docker_git_upsert_ssh_env "$key" "$value" - return 0 -} - -docker_git_load_env_file() { - local file="$1" - if [[ ! -f "$file" ]]; then - return 0 - fi - - while IFS= read -r line || [[ -n "$line" ]]; do - case "$line" in - ""|\#*) - continue - ;; - esac - if [[ "$line" != *=* ]]; then - continue - fi - - local key="${"$"}{line%%=*}" - local value="${"$"}{line#*=}" - if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then - continue - fi - - docker_git_export_env_if_unset "$key" "$value" - done < "$file" -} - -copy_if_distinct_file() { - local source="$1" - local target="$2" - if [[ ! -f "$source" ]]; then - return 1 - fi - local source_real="" - local target_real="" - source_real="$(readlink -f "$source" 2>/dev/null || true)" - target_real="$(readlink -f "$target" 2>/dev/null || true)" - if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then - return 0 - fi - cp "$source" "$target" - return 0 -} - -sync_dir_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" -sync_labeled_auth_files "$BOOTSTRAP_CODEX_SHARED_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" -sync_dir_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" -sync_dir_entries "$BOOTSTRAP_GROK_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR" - -if [[ -n "$GH_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" -fi -if [[ -n "$GITHUB_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN" -elif [[ -n "$GH_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" -fi - -docker_git_load_env_file "$DOCKER_GIT_ENV_GLOBAL" -docker_git_load_env_file "$DOCKER_GIT_ENV_PROJECT" -if [[ -z "$GIT_AUTH_TOKEN" ]]; then - GIT_AUTH_TOKEN="$GITHUB_TOKEN" -fi -if [[ -z "$GIT_AUTH_TOKEN" ]]; then - GIT_AUTH_TOKEN="$GH_TOKEN" -fi -if [[ -z "$GH_TOKEN" ]]; then - GH_TOKEN="$GIT_AUTH_TOKEN" -fi -if [[ -z "$GITHUB_TOKEN" ]]; then - GITHUB_TOKEN="$GH_TOKEN" -fi - -SOURCE_CODEX_CONFIG="__CODEX_HOME__/config.toml" -copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true -if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then - chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true -fi - -chown -R 1000:1000 "$DOCKER_GIT_HOME" || true` - -export const renderEntrypointDockerGitBootstrap = (config: TemplateConfig): string => - entrypointDockerGitBootstrapTemplate - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll( - "__AUTHORIZED_KEYS_BASENAME__", - config.authorizedKeysPath.replaceAll("\\", "/").split("/").at(-1) ?? "authorized_keys" - ) - .replaceAll("__ENV_GLOBAL_BASENAME__", config.envGlobalPath.replaceAll("\\", "/").split("/").at(-1) ?? "global.env") - .replaceAll( - "__ENV_PROJECT_BASENAME__", - config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? "project.env" - ) - .replaceAll("__CODEX_HOME__", config.codexHome) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/opencode.ts b/packages/app/src/lib/core/templates-entrypoint/opencode.ts deleted file mode 100644 index 78a95068..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/opencode.ts +++ /dev/null @@ -1,211 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -const entrypointOpenCodeTemplate = `OPENCODE_DATA_DIR="/home/__SSH_USER__/.local/share/opencode" -OPENCODE_AUTH_FILE="$OPENCODE_DATA_DIR/auth.json" -OPENCODE_SHARED_HOME="__CODEX_HOME__-shared/opencode" -OPENCODE_SHARED_AUTH_FILE="$OPENCODE_SHARED_HOME/auth.json" - -# OpenCode: share auth.json across projects (so /connect is one-time) -OPENCODE_SHARE_AUTH="\${OPENCODE_SHARE_AUTH:-1}" -if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then - # Store in the shared auth volume to persist across projects/containers. - mkdir -p "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME" - chown -R 1000:1000 "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME" || true - - # Guard against a bad bind mount creating a directory at auth.json. - if [[ -d "$OPENCODE_AUTH_FILE" ]]; then - mv "$OPENCODE_AUTH_FILE" "$OPENCODE_AUTH_FILE.bak-$(date +%s)" || true - fi - - # Migrate existing per-project auth into the shared location once. - if [[ -f "$OPENCODE_AUTH_FILE" && ! -L "$OPENCODE_AUTH_FILE" ]]; then - if [[ -f "$OPENCODE_SHARED_AUTH_FILE" ]]; then - LOCAL_AUTH="$OPENCODE_AUTH_FILE" SHARED_AUTH="$OPENCODE_SHARED_AUTH_FILE" node - <<'NODE' -const fs = require("fs") -const localPath = process.env.LOCAL_AUTH -const sharedPath = process.env.SHARED_AUTH -const readJson = (p) => { - try { - return JSON.parse(fs.readFileSync(p, "utf8")) - } catch { - return {} - } -} -const local = readJson(localPath) -const shared = readJson(sharedPath) -const merged = { ...local, ...shared } // shared wins on conflicts -fs.writeFileSync(sharedPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) -NODE - else - cp "$OPENCODE_AUTH_FILE" "$OPENCODE_SHARED_AUTH_FILE" || true - chmod 600 "$OPENCODE_SHARED_AUTH_FILE" || true - fi - chown 1000:1000 "$OPENCODE_SHARED_AUTH_FILE" || true - rm -f "$OPENCODE_AUTH_FILE" || true - fi - - ln -sf "$OPENCODE_SHARED_AUTH_FILE" "$OPENCODE_AUTH_FILE" -fi - -# OpenCode: auto-seed auth from Codex (so /connect is automatic) -OPENCODE_AUTO_CONNECT="\${OPENCODE_AUTO_CONNECT:-1}" -if [[ "$OPENCODE_AUTO_CONNECT" == "1" ]]; then - CODEX_AUTH_FILE="__CODEX_HOME__/auth.json" - OPENCODE_SEED_AUTH="$OPENCODE_AUTH_FILE" - if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then - OPENCODE_SEED_AUTH="$OPENCODE_SHARED_AUTH_FILE" - fi - CODEX_AUTH="$CODEX_AUTH_FILE" OPENCODE_AUTH="$OPENCODE_SEED_AUTH" node - <<'NODE' -const fs = require("fs") -const path = require("path") - -const codexPath = process.env.CODEX_AUTH -const opencodePath = process.env.OPENCODE_AUTH - -if (!codexPath || !opencodePath) { - process.exit(0) -} - -const readJson = (p) => { - try { - return JSON.parse(fs.readFileSync(p, "utf8")) - } catch { - return undefined - } -} - -const writeJsonAtomic = (p, value) => { - const dir = path.dirname(p) - fs.mkdirSync(dir, { recursive: true }) - const tmp = path.join(dir, ".tmp-" + path.basename(p) + "-" + process.pid + "-" + Date.now()) - fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 }) - fs.renameSync(tmp, p) -} - -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) - -const decodeJwtClaims = (jwt) => { - if (typeof jwt !== "string") return undefined - const parts = jwt.split(".") - if (parts.length !== 3) return undefined - try { - const payload = Buffer.from(parts[1], "base64url").toString("utf8") - return JSON.parse(payload) - } catch { - return undefined - } -} - -const extractAccountIdFromClaims = (claims) => { - if (!isRecord(claims)) return undefined - if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id - const openaiAuth = claims["https://api.openai.com/auth"] - if (isRecord(openaiAuth) && typeof openaiAuth.chatgpt_account_id === "string") { - return openaiAuth.chatgpt_account_id - } - const orgs = claims.organizations - if (Array.isArray(orgs) && orgs.length > 0) { - const first = orgs[0] - if (isRecord(first) && typeof first.id === "string") return first.id - } - return undefined -} - -const extractJwtExpiryMs = (claims) => { - if (!isRecord(claims)) return undefined - if (typeof claims.exp !== "number") return undefined - return claims.exp * 1000 -} - -const codex = readJson(codexPath) -if (!isRecord(codex)) process.exit(0) - -let opencode = readJson(opencodePath) -if (!isRecord(opencode)) opencode = {} - -const apiKey = codex.OPENAI_API_KEY -if (typeof apiKey === "string" && apiKey.trim().length > 0) { - opencode.openai = { type: "api", key: apiKey.trim() } - writeJsonAtomic(opencodePath, opencode) - process.exit(0) -} - -const tokens = codex.tokens -if (!isRecord(tokens)) process.exit(0) - -const access = tokens.access_token -const refresh = tokens.refresh_token -if (typeof access !== "string" || access.length === 0) process.exit(0) -if (typeof refresh !== "string" || refresh.length === 0) process.exit(0) - -const accessClaims = decodeJwtClaims(access) -const expires = extractJwtExpiryMs(accessClaims) -if (typeof expires !== "number") process.exit(0) - -let accountId = undefined -if (typeof tokens.account_id === "string" && tokens.account_id.length > 0) { - accountId = tokens.account_id -} else { - const idClaims = decodeJwtClaims(tokens.id_token) - accountId = - extractAccountIdFromClaims(idClaims) || - extractAccountIdFromClaims(accessClaims) -} - -const entry = { - type: "oauth", - refresh, - access, - expires, - ...(typeof accountId === "string" && accountId.length > 0 ? { accountId } : {}) -} - -opencode.openai = entry -writeJsonAtomic(opencodePath, opencode) -NODE - chown 1000:1000 "$OPENCODE_SEED_AUTH" 2>/dev/null || true -fi - -# OpenCode: ensure global config exists (plugins + permissions) -OPENCODE_CONFIG_DIR="/home/__SSH_USER__/.config/opencode" -OPENCODE_CONFIG_JSON="$OPENCODE_CONFIG_DIR/opencode.json" -OPENCODE_CONFIG_JSONC="$OPENCODE_CONFIG_DIR/opencode.jsonc" - -mkdir -p "$OPENCODE_CONFIG_DIR" -chown -R 1000:1000 "$OPENCODE_CONFIG_DIR" || true - -if [[ ! -f "$OPENCODE_CONFIG_JSON" && ! -f "$OPENCODE_CONFIG_JSONC" ]]; then - cat <<'EOF' > "$OPENCODE_CONFIG_JSON" -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["oh-my-opencode"], - "permission": { - "doom_loop": "allow", - "external_directory": "allow", - "read": { - "*": "allow", - "*.env": "allow", - "*.env.*": "allow", - "*.env.example": "allow" - } - } -} -EOF - chown 1000:1000 "$OPENCODE_CONFIG_JSON" || true -fi` - -// CHANGE: bootstrap OpenCode config (permissions + plugins) and share OpenCode auth.json across projects -// WHY: make OpenCode usable out-of-the-box inside disposable docker-git containers -// QUOTE(ТЗ): "Preinstall OpenCode and oh-my-opencode with full authorization of existing tools" -// REF: issue-34 -// SOURCE: n/a -// FORMAT THEOREM: forall s: start(s) -> config_exists(s) -// PURITY: CORE -// INVARIANT: never overwrites an existing opencode.json/opencode.jsonc -// COMPLEXITY: O(1) -export const renderEntrypointOpenCodeConfig = (config: TemplateConfig): string => - entrypointOpenCodeTemplate - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__CODEX_HOME__", config.codexHome) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts b/packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts deleted file mode 100644 index dac9e855..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/plan-to-git.ts +++ /dev/null @@ -1,223 +0,0 @@ -const planToGitHookPathsTemplate = String.raw`PLAN_TO_GIT_SYNC_HELPER="$HOOKS_DIR/plan-to-git-sync" -PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook" -PLAN_TO_GIT_CLAUDE_HOOK="$HOOKS_DIR/plan-to-git-claude-hook" -CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml" -CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"` - -const planToGitSyncHelperInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_SYNC_HELPER" -#!/usr/bin/env bash -set -euo pipefail - -if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then - exit 0 -fi - -if ! command -v plan-to-git >/dev/null 2>&1; then - echo "[plan-to-git] Error: plan-to-git not found" >&2 - exit 1 -fi - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" - -docker_git_plan_to_git_explicit_pr_supported() { - plan-to-git sync --help 2>/dev/null | grep -q -- "--pr " -} - -docker_git_plan_to_git_resolve_pr_number() { - local candidate="" - local key="" - for key in DOCKER_GIT_PR_NUMBER PR_NUMBER GITHUB_PR_NUMBER; do - candidate="${"${"}!key:-}" - if [[ "$candidate" =~ ^[0-9]+$ ]]; then - printf "%s\n" "$candidate" - return 0 - fi - done - - candidate="${"${"}REPO_REF:-}" - if [[ "$candidate" =~ ^refs/pull/([0-9]+)/head$ ]]; then - printf "%s\n" "${"${"}BASH_REMATCH[1]}" - return 0 - fi - if [[ "$candidate" =~ ^pull/([0-9]+)$ ]]; then - printf "%s\n" "${"${"}BASH_REMATCH[1]}" - return 0 - fi - - if command -v gh >/dev/null 2>&1; then - candidate="$(gh pr view --json number --jq .number 2>/dev/null || true)" - if [[ "$candidate" =~ ^[0-9]+$ ]]; then - printf "%s\n" "$candidate" - return 0 - fi - fi - - return 0 -} - -docker_git_plan_to_git_sync() { - local pr_number="" - pr_number="$(docker_git_plan_to_git_resolve_pr_number || true)" - - if [[ -n "$pr_number" ]] && docker_git_plan_to_git_explicit_pr_supported; then - echo "[plan-to-git] Syncing queued agent plans to PR #$pr_number" - plan-to-git sync --pr "$pr_number" - return 0 - fi - - echo "[plan-to-git] Syncing queued agent plans via current branch discovery" - plan-to-git sync -} - -docker_git_plan_to_git_sync -EOF -chmod 0755 "$PLAN_TO_GIT_SYNC_HELPER"` - -const planToGitPostPushSyncTemplate = String - .raw`# CHANGE: backfill agent session plans before syncing the current branch or explicit PR. -# WHY: live agent hooks can be unavailable in already-running sessions; session logs are the durable fallback. -# QUOTE(ТЗ): "что бы всё уходило на гитхаб автоматически" -# REF: issue-397 -if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then - if ! command -v plan-to-git >/dev/null 2>&1; then - echo "[plan-to-git] Error: plan-to-git not found" >&2 - exit 1 - fi - plan-to-git import-codex --no-sync - plan-to-git import-claude --no-sync - PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" - if [[ -x "$PLAN_TO_GIT_SYNC_HELPER" ]]; then - "$PLAN_TO_GIT_SYNC_HELPER" - else - echo "[plan-to-git] Sync helper not found; falling back to current branch discovery" >&2 - plan-to-git sync - fi -fi` - -const planToGitAgentHooksInstallTemplate = String.raw`cat <<'EOF' > "$PLAN_TO_GIT_CODEX_HOOK" -#!/usr/bin/env bash -set -euo pipefail - -if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then - exit 0 -fi - -if ! command -v plan-to-git >/dev/null 2>&1; then - echo "[plan-to-git] Error: plan-to-git not found" >&2 - exit 1 -fi - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source codex -PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" -"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true -EOF -chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK" - -cat <<'EOF' > "$PLAN_TO_GIT_CLAUDE_HOOK" -#!/usr/bin/env bash -set -euo pipefail - -if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then - exit 0 -fi - -if ! command -v plan-to-git >/dev/null 2>&1; then - echo "[plan-to-git] Error: plan-to-git not found" >&2 - exit 1 -fi - -export PLAN_TO_GIT_STATE_DIR="${"${"}PLAN_TO_GIT_STATE_DIR:-/tmp/plan-to-git}" -plan-to-git hook --source claude -PLAN_TO_GIT_SYNC_HELPER="${"${"}DOCKER_GIT_PLAN_TO_GIT_SYNC_HELPER:-/opt/docker-git/hooks/plan-to-git-sync}" -"$PLAN_TO_GIT_SYNC_HELPER" >&2 || true -EOF -chmod 0755 "$PLAN_TO_GIT_CLAUDE_HOOK" - -mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")" -cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE" -# docker-git managed Codex requirements - -[features] -hooks = true - -[hooks] -managed_dir = "/opt/docker-git/hooks" - -[[hooks.UserPromptSubmit]] -[[hooks.UserPromptSubmit.hooks]] -type = "command" -command = "/opt/docker-git/hooks/plan-to-git-codex-hook" -statusMessage = "Capturing plan decision" - -[[hooks.Stop]] -[[hooks.Stop.hooks]] -type = "command" -command = "/opt/docker-git/hooks/plan-to-git-codex-hook" -statusMessage = "Capturing agent plan" -EOF -chmod 0644 "$CODEX_REQUIREMENTS_FILE" - -docker_git_install_claude_plan_to_git_hooks() { - if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then - return 0 - fi - - CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="${"${"}CLAUDE_PLAN_TO_GIT_SETTINGS_FILE:-${"${"}CLAUDE_CONFIG_DIR:-/home/dev/.claude}/settings.json}" - CLAUDE_PLAN_TO_GIT_SETTINGS_FILE="$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" PLAN_TO_GIT_CLAUDE_HOOK="$PLAN_TO_GIT_CLAUDE_HOOK" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") - -const settingsPath = process.env.CLAUDE_PLAN_TO_GIT_SETTINGS_FILE -const hookCommand = process.env.PLAN_TO_GIT_CLAUDE_HOOK || "/opt/docker-git/hooks/plan-to-git-claude-hook" -if (typeof settingsPath !== "string" || settingsPath.length === 0) { - process.exit(0) -} - -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - settings = isRecord(parsed) ? parsed : {} -} catch { - settings = {} -} - -const currentHooks = isRecord(settings.hooks) ? settings.hooks : {} -const nextHooks = { ...currentHooks } -const managedHook = { type: "command", command: hookCommand } -const ensureEventHook = (eventName) => { - const currentEventHooks = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - const alreadyInstalled = currentEventHooks.some((entry) => - isRecord(entry) && - Array.isArray(entry.hooks) && - entry.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === hookCommand) - ) - nextHooks[eventName] = alreadyInstalled ? currentEventHooks : [...currentEventHooks, { hooks: [managedHook] }] -} - -ensureEventHook("UserPromptSubmit") -ensureEventHook("Stop") - -const nextSettings = { ...settings, hooks: nextHooks } -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { - process.exit(0) -} - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE - chmod 0600 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true - chown 1000:1000 "$CLAUDE_PLAN_TO_GIT_SETTINGS_FILE" 2>/dev/null || true -} - -docker_git_install_claude_plan_to_git_hooks` - -export const renderPlanToGitHookPaths = (): string => planToGitHookPathsTemplate - -export const renderPlanToGitSyncHelperInstall = (): string => planToGitSyncHelperInstallTemplate - -export const renderPlanToGitPostPushSync = (): string => planToGitPostPushSyncTemplate - -export const renderPlanToGitAgentHooksInstall = (): string => planToGitAgentHooksInstallTemplate diff --git a/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts b/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts deleted file mode 100644 index 8ef23598..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/post-push-pr.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* jscpd:ignore-start */ -// Mirror of packages/lib/src/core/templates-entrypoint/post-push-pr.ts. -// The lib template test asserts rendered output equality to prevent drift. -const postPushPrEnsureTemplate = String - .raw`# CHANGE: ensure an open GitHub PR exists for the pushed branch before PR-bound post-push tools run. -# WHY: issue #375 requires every successful git push to leave the branch with an open PR; plan sync and session backup both target PR discussion. -# REF: issue-375 -docker_git_github_repo_from_remote_url() { - local remote_url="$1" - local repo_path="" - local owner="" - local repo="" - - case "$remote_url" in - https://github.com/*) - repo_path="${"${"}remote_url#https://github.com/}" - ;; - http://github.com/*) - repo_path="${"${"}remote_url#http://github.com/}" - ;; - https://*@github.com/*) - repo_path="${"${"}remote_url#https://*@github.com/}" - ;; - http://*@github.com/*) - repo_path="${"${"}remote_url#http://*@github.com/}" - ;; - ssh://git@github.com/*) - repo_path="${"${"}remote_url#ssh://git@github.com/}" - ;; - git@github.com:*) - repo_path="${"${"}remote_url#git@github.com:}" - ;; - *) - return 1 - ;; - esac - - repo_path="${"${"}repo_path%%\?*}" - repo_path="${"${"}repo_path%%#*}" - repo_path="${"${"}repo_path%/}" - repo_path="${"${"}repo_path%.git}" - owner="${"${"}repo_path%%/*}" - repo="${"${"}repo_path#*/}" - repo="${"${"}repo%%/*}" - repo="${"${"}repo%.git}" - - if [[ -z "$owner" || -z "$repo" || "$owner" == "$repo_path" ]]; then - return 1 - fi - - printf "%s/%s\n" "$owner" "$repo" -} - -docker_git_github_repo_from_remote() { - local remote="$1" - local remote_url="" - - remote_url="$(git remote get-url "$remote" 2>/dev/null || true)" - if [[ -z "$remote_url" ]]; then - return 1 - fi - - docker_git_github_repo_from_remote_url "$remote_url" -} - -docker_git_ensure_open_pr() { - local branch="" - local base_repo="" - local head_repo="" - local head_owner="" - local head_arg="" - local base_branch="" - local pr_url="" - - if ! command -v gh >/dev/null 2>&1; then - echo "[post-push-pr] Error: gh CLI not found" >&2 - return 1 - fi - - branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - if [[ -z "$branch" || "$branch" == "HEAD" ]]; then - echo "[post-push-pr] Error: cannot create PR from detached HEAD" >&2 - return 1 - fi - - if ! base_repo="$(docker_git_github_repo_from_remote upstream)"; then - if ! base_repo="$(docker_git_github_repo_from_remote origin)"; then - echo "[post-push-pr] Skipped: no GitHub remote found" - return 0 - fi - fi - - if ! head_repo="$(docker_git_github_repo_from_remote origin)"; then - head_repo="$base_repo" - fi - - base_branch="$(gh repo view "$base_repo" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)" - if [[ -z "$base_branch" ]]; then - echo "[post-push-pr] Error: failed to resolve default branch for $base_repo" >&2 - return 1 - fi - - if [[ "$head_repo" == "$base_repo" ]]; then - head_arg="$branch" - else - head_owner="${"${"}head_repo%%/*}" - head_arg="${"${"}head_owner}:${"${"}branch}" - fi - - if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$head_arg" --json url --jq '.[0].url // ""' 2>/dev/null)"; then - echo "[post-push-pr] Error: failed to list open PRs for $head_arg in $base_repo" >&2 - return 1 - fi - if [[ -z "$pr_url" && "$head_arg" != "$branch" ]]; then - if ! pr_url="$(gh pr list --repo "$base_repo" --state open --head "$branch" --json url --jq '.[0].url // ""' 2>/dev/null)"; then - echo "[post-push-pr] Error: failed to list open PRs for $branch in $base_repo" >&2 - return 1 - fi - fi - - if [[ -n "$pr_url" ]]; then - echo "[post-push-pr] Open PR: $pr_url" - return 0 - fi - - echo "[post-push-pr] Creating PR for $head_arg into $base_repo:$base_branch" - gh pr create --repo "$base_repo" --base "$base_branch" --head "$head_arg" --fill -} - -docker_git_ensure_open_pr` - -export const renderPostPushPrEnsure = (): string => postPushPrEnsureTemplate -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/project-rules.ts b/packages/app/src/lib/core/templates-entrypoint/project-rules.ts deleted file mode 100644 index 17a8e619..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/project-rules.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* jscpd:ignore-start */ -// CHANGE: separate project rule preparation by active agent mode -// WHY: Codex, Claude Code, Gemini CLI, and Grok CLI each have different native project-level config models -// REF: issue-207 -// PURITY: CORE -// INVARIANT: Codex gets a bridge for skills that live outside CODEX_HOME; Claude/Gemini/Grok stay on native project-local discovery -// COMPLEXITY: O(1) -const entrypointProjectAgentRulesTemplate = String - .raw`# Prepare project-local rules using each agent's native conventions. -docker_git_detect_claude_project_rules() { - local project_dir="${"$"}{TARGET_DIR:-}" - - if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then - return 0 - fi - - if [[ -f "$project_dir/CLAUDE.md" \ - || -f "$project_dir/.claude/CLAUDE.md" \ - || -f "$project_dir/.claude/settings.json" \ - || -d "$project_dir/.claude/agents" \ - || -f "$project_dir/.mcp.json" ]]; then - echo "[claude] project-local Claude rules available in $project_dir" - fi -} - -docker_git_detect_gemini_project_rules() { - local project_dir="${"$"}{TARGET_DIR:-}" - - if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then - return 0 - fi - - if [[ -f "$project_dir/GEMINI.md" \ - || -f "$project_dir/.gemini/settings.json" \ - || -d "$project_dir/.gemini/commands" \ - || -d "$project_dir/.gemini/skills" \ - || -d "$project_dir/.agents/skills" ]]; then - echo "[gemini] project-local Gemini rules available in $project_dir" - fi -} - -docker_git_detect_grok_project_rules() { - local project_dir="${"$"}{TARGET_DIR:-}" - - if [[ -z "$project_dir" || ! -d "$project_dir" ]]; then - return 0 - fi - - if [[ -f "$project_dir/GROK.md" \ - || -f "$project_dir/.grok/settings.json" \ - || -d "$project_dir/.grok/commands" \ - || -d "$project_dir/.grok/skills" \ - || -d "$project_dir/.agents/skills" ]]; then - echo "[grok] project-local Grok rules available in $project_dir" - fi -} - -docker_git_prepare_active_agent_project_rules() { - case "$AGENT_MODE" in - "codex") - docker_git_sync_project_codex_skills - ;; - "claude") - docker_git_detect_claude_project_rules - ;; - "gemini") - docker_git_detect_gemini_project_rules - ;; - "grok") - docker_git_detect_grok_project_rules - ;; - *) - docker_git_sync_project_codex_skills - docker_git_detect_claude_project_rules - docker_git_detect_gemini_project_rules - docker_git_detect_grok_project_rules - ;; - esac -}` - -export const renderEntrypointProjectAgentRules = (): string => entrypointProjectAgentRulesTemplate -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/rtk.ts b/packages/app/src/lib/core/templates-entrypoint/rtk.ts deleted file mode 100644 index 13bc72ff..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/rtk.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* jscpd:ignore-start */ -import type { TemplateConfig } from "../domain.js" - -// CHANGE: configure RTK hooks/instructions for the bundled AI agents at startup. -// WHY: generated docker-git containers should reduce command-output tokens without manual setup. -// QUOTE(TASK): "make it work out of the box for docker-git" -// REF: issue-266 -// SOURCE: https://github.com/rtk-ai/rtk/blob/develop/README.md -// FORMAT THEOREM: forall start: RTK_ENABLED(start) -> configured(codex, claude, gemini, grok, opencode) -// PURITY: CORE (pure template renderer) -// INVARIANT: RTK init runs as the non-root SSH user and never blocks container startup. -// COMPLEXITY: O(1) -export const renderEntrypointRtkConfig = (config: TemplateConfig): string => - String.raw`# RTK: configure command-output token optimization for supported agents. -DOCKER_GIT_RTK_ENABLE="${"$"}{DOCKER_GIT_RTK_ENABLE:-1}" -docker_git_upsert_ssh_env "DOCKER_GIT_RTK_ENABLE" "$DOCKER_GIT_RTK_ENABLE" - -docker_git_rtk_init_as_user() { - local label="$1" - local command="$2" - - if [[ "$DOCKER_GIT_RTK_ENABLE" != "1" ]]; then - return 0 - fi - - if ! command -v rtk >/dev/null 2>&1; then - echo "[rtk] warning: rtk binary not found; skipping $label setup" >&2 - return 0 - fi - - mkdir -p "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config/opencode" "/home/__SSH_USER__/.gemini" "/home/__SSH_USER__/.grok" || true - chown -R 1000:1000 "$CLAUDE_CONFIG_DIR" "__CODEX_HOME__" "/home/__SSH_USER__/.config" "/home/__SSH_USER__/.gemini" "/home/__SSH_USER__/.grok" 2>/dev/null || true - - if su - __SSH_USER__ -s /bin/bash -c "$command" &2 - fi -} - -docker_git_rtk_init_as_user "codex" "HOME=/home/__SSH_USER__ CODEX_HOME='__CODEX_HOME__' rtk init -g --codex" -docker_git_rtk_init_as_user "claude" "HOME=/home/__SSH_USER__ RTK_CLAUDE_DIR='$CLAUDE_CONFIG_DIR' rtk init -g --auto-patch" -docker_git_rtk_init_as_user "gemini" "HOME=/home/__SSH_USER__ rtk init -g --gemini --auto-patch" -docker_git_rtk_init_as_user "opencode" "HOME=/home/__SSH_USER__ rtk init -g --opencode"` - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__CODEX_HOME__", config.codexHome) -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts deleted file mode 100644 index 7d38fbe0..00000000 --- a/packages/app/src/lib/core/templates-entrypoint/tasks.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { TemplateConfig } from "../domain.js" -import { renderAgentLaunch } from "./agent.js" - -const renderEntrypointAutoUpdate = (): string => - `# 1) Keep Codex CLI up to date if requested (bun only) -if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then - if command -v bun >/dev/null 2>&1; then - echo "[codex] updating via bun..." - BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true - else - echo "[codex] bun not found, skipping auto-update" - fi -fi` - -const renderClonePreamble = (): string => - `# 2) Auto-clone repo if not already present -mkdir -p /run/docker-git -CLONE_DONE_PATH="/run/docker-git/clone.done" -CLONE_FAIL_PATH="/run/docker-git/clone.failed" -rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" - -CLONE_OK=1` - -const renderCloneRemotes = (config: TemplateConfig): string => - `if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" ]]; then - if [[ -n "$FORK_REPO_URL" && "$FORK_REPO_URL" != "$REPO_URL" ]]; then - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$FORK_REPO_URL'" || true - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote add upstream '$REPO_URL' 2>/dev/null || git remote set-url upstream '$REPO_URL'" || true - else - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$REPO_URL'" || true - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote remove upstream >/dev/null 2>&1 || true" || true - fi -fi` - -const renderCloneGuard = (config: TemplateConfig): string => - `if [[ -z "$REPO_URL" ]]; then - echo "[clone] skip (no repo url)" -elif [[ -d "$TARGET_DIR/.git" ]]; then - echo "[clone] skip (already cloned)" -else - mkdir -p "$TARGET_DIR" - if [[ "$TARGET_DIR" != "/" ]]; then - chown -R 1000:1000 "$TARGET_DIR" - fi - chown -R 1000:1000 /home/${config.sshUser}` - -const cloneAuthLabelResolutionTemplate = ` RESOLVED_GIT_AUTH_LABEL="" - GIT_TOKEN_LABEL_RAW="\${GIT_AUTH_LABEL:-\${GITHUB_AUTH_LABEL:-\${GITLAB_AUTH_LABEL:-}}}" - - if [[ -z "$GIT_TOKEN_LABEL_RAW" && "$REPO_URL" == https://github.com/* ]]; then - GIT_TOKEN_LABEL_RAW="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##' | cut -d/ -f1)" - fi - if [[ -z "$GIT_TOKEN_LABEL_RAW" && "$REPO_URL" == https://gitlab.com/* ]]; then - GIT_TOKEN_LABEL_RAW="$(printf "%s" "$REPO_URL" | sed -E 's#^https://gitlab.com/##; s#[.]git$##; s#/*$##' | cut -d/ -f1)" - fi - - if [[ -n "$GIT_TOKEN_LABEL_RAW" ]]; then - RESOLVED_GIT_AUTH_LABEL="$(printf "%s" "$GIT_TOKEN_LABEL_RAW" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+//; s/_+$//')" - if [[ "$RESOLVED_GIT_AUTH_LABEL" == "DEFAULT" ]]; then - RESOLVED_GIT_AUTH_LABEL="" - fi - fi` - -const cloneAuthLabeledTokenTemplate = ` if [[ -n "$RESOLVED_GIT_AUTH_LABEL" ]]; then - LABELED_GIT_TOKEN_KEY="GIT_AUTH_TOKEN__$RESOLVED_GIT_AUTH_LABEL" - LABELED_GITHUB_TOKEN_KEY="GITHUB_TOKEN__$RESOLVED_GIT_AUTH_LABEL" - LABELED_GITLAB_TOKEN_KEY="GITLAB_TOKEN__$RESOLVED_GIT_AUTH_LABEL" - LABELED_GIT_USER_KEY="GIT_AUTH_USER__$RESOLVED_GIT_AUTH_LABEL" - - LABELED_GIT_TOKEN="\${!LABELED_GIT_TOKEN_KEY-}" - LABELED_GITHUB_TOKEN="\${!LABELED_GITHUB_TOKEN_KEY-}" - LABELED_GITLAB_TOKEN="\${!LABELED_GITLAB_TOKEN_KEY-}" - LABELED_GIT_USER="\${!LABELED_GIT_USER_KEY-}" - - if [[ -n "$LABELED_GIT_TOKEN" ]]; then - RESOLVED_GIT_AUTH_TOKEN="$LABELED_GIT_TOKEN" - elif [[ "$REPO_URL" == https://gitlab.com/* && -n "$LABELED_GITLAB_TOKEN" ]]; then - RESOLVED_GIT_AUTH_TOKEN="$LABELED_GITLAB_TOKEN" - elif [[ -n "$LABELED_GITHUB_TOKEN" ]]; then - RESOLVED_GIT_AUTH_TOKEN="$LABELED_GITHUB_TOKEN" - fi - - if [[ -n "$LABELED_GIT_USER" ]]; then - RESOLVED_GIT_AUTH_USER="$LABELED_GIT_USER" - fi - fi - - if [[ -z "$RESOLVED_GIT_AUTH_USER" ]]; then - if [[ "$REPO_URL" == https://gitlab.com/* ]]; then - RESOLVED_GIT_AUTH_USER="oauth2" - else - RESOLVED_GIT_AUTH_USER="x-access-token" - fi - fi -` - -const renderCloneAuthSelection = (): string => - ` if [[ "\${GITHUB_AUTH_SKIP:-0}" == "1" && "$REPO_URL" == https://github.com/* ]]; then - RESOLVED_GIT_AUTH_USER="" - RESOLVED_GIT_AUTH_TOKEN="" - RESOLVED_GIT_AUTH_LABEL="" - else - RESOLVED_GIT_AUTH_USER="$GIT_AUTH_USER" - RESOLVED_GIT_AUTH_TOKEN="$GIT_AUTH_TOKEN" - if [[ "$REPO_URL" == https://gitlab.com/* && -n "\${GITLAB_TOKEN:-}" ]]; then - RESOLVED_GIT_AUTH_TOKEN="$GITLAB_TOKEN" - fi -${cloneAuthLabelResolutionTemplate} - -${cloneAuthLabeledTokenTemplate} - fi -` - -const renderCloneAuthRepoUrl = (): string => - ` AUTH_REPO_URL="$REPO_URL" - if [[ -n "$RESOLVED_GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then - AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" - fi` - -// CHANGE: restrict clone-cache mirror refresh to branch and tag refs -// WHY: broad refs include hosted forge PR refs and make cache reuse proportional to every remote ref -// QUOTE(ТЗ): "Для тестов можно реализовать CI/CD workflow для Linux, MAC, Windows" -// REF: issue-278-ci-check-clone-cache -// SOURCE: n/a -// FORMAT THEOREM: forall r in refreshedRefs: r in refs/heads/* union refs/tags/* -// PURITY: CORE -// INVARIANT: clone-cache refresh never requests refs/pull/* or refs/merge-requests/* -// COMPLEXITY: O(|heads| + |tags|) -const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'" - -const renderCloneCacheInit = (config: TemplateConfig): string => - ` CLONE_CACHE_ARGS="" - CACHE_REPO_DIR="" - CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/git-mirrors" - if command -v sha256sum >/dev/null 2>&1; then - REPO_CACHE_KEY="$(printf "%s" "$REPO_URL" | sha256sum | awk '{print $1}')" - elif command -v shasum >/dev/null 2>&1; then - REPO_CACHE_KEY="$(printf "%s" "$REPO_URL" | shasum -a 256 | awk '{print $1}')" - else - REPO_CACHE_KEY="$(printf "%s" "$REPO_URL" | tr '/:@' '_' | tr -cd '[:alnum:]_.-')" - fi - - if [[ -n "$REPO_CACHE_KEY" ]]; then - CACHE_REPO_DIR="$CACHE_ROOT/$REPO_CACHE_KEY.git" - mkdir -p "$CACHE_ROOT" - chown 1000:1000 "$CACHE_ROOT" || true - if [[ -d "$CACHE_REPO_DIR" ]]; then - if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then - echo "[clone-cache] mirror refresh failed for $REPO_URL" - fi - CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" - echo "[clone-cache] using mirror: $CACHE_REPO_DIR" - else - echo "[clone-cache] invalid mirror removed: $CACHE_REPO_DIR" - rm -rf "$CACHE_REPO_DIR" - fi - fi - fi` - -const renderCloneBodyStart = (config: TemplateConfig): string => - [ - renderCloneGuard(config), - renderCloneAuthSelection(), - renderCloneAuthRepoUrl(), - renderCloneCacheInit(config) - ].join("\n\n") - -const renderCloneBodyRef = (config: TemplateConfig): string => - String.raw` if [[ -n "$REPO_REF" ]]; then - if [[ "$REPO_REF" == refs/pull/* || "$REPO_REF" == refs/merge-requests/* ]]; then - REF_BRANCH="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([^/]+)/head$#pr-\1#; s#^refs/merge-requests/([^/]+)/head$#mr-\1#')" - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - else - if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then - echo "[clone] git fetch failed for $REPO_REF" - CLONE_OK=0 - fi - fi - else - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] branch '$REPO_REF' missing; retrying without --branch" - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - elif [[ "$REPO_REF" == issue-* ]]; then - if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then - echo "[clone] failed to create local branch '$REPO_REF'" - CLONE_OK=0 - fi - fi - fi - fi - else - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi` - -const renderCloneCacheFinalize = (config: TemplateConfig): string => - `CACHE_REPO_DIR="\${CACHE_REPO_DIR:-}" -if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" && -n "$CACHE_REPO_DIR" && ! -d "$CACHE_REPO_DIR" ]]; then - CACHE_TMP_DIR="$CACHE_REPO_DIR.tmp-$$" - if su - ${config.sshUser} -c "rm -rf '$CACHE_TMP_DIR' && GIT_TERMINAL_PROMPT=0 git clone --mirror --progress '$TARGET_DIR/.git' '$CACHE_TMP_DIR'"; then - if mv "$CACHE_TMP_DIR" "$CACHE_REPO_DIR" 2>/dev/null; then - echo "[clone-cache] mirror created: $CACHE_REPO_DIR" - else - rm -rf "$CACHE_TMP_DIR" - fi - else - echo "[clone-cache] mirror bootstrap failed for $REPO_URL" - rm -rf "$CACHE_TMP_DIR" - fi -fi` - -const renderCloneBody = (config: TemplateConfig): string => - [ - renderCloneBodyStart(config), - renderCloneBodyRef(config), - "fi", - "", - renderCloneRemotes(config), - "", - renderCloneCacheFinalize(config) - ].join("\n") - -// CHANGE: provision docker-git scripts into workspace after successful clone -// WHY: git hooks reference scripts/ relative to repo root; -// symlinking embedded /opt/docker-git/scripts makes them available in any cloned repo -// REF: issue-176 -// PURITY: SHELL -// INVARIANT: symlink created only when /opt/docker-git/scripts exists ∧ TARGET_DIR/scripts absent -// COMPLEXITY: O(1) -const renderCloneFinalize = (): string => - `if [[ "$CLONE_OK" -eq 1 ]]; then - echo "[clone] done" - touch "$CLONE_DONE_PATH" - - # Provision docker-git scripts into workspace (symlink if not already present) - if [[ -d /opt/docker-git/scripts && -n "$TARGET_DIR" && "$TARGET_DIR" != "/" ]]; then - if [[ ! -e "$TARGET_DIR/scripts" ]]; then - ln -s /opt/docker-git/scripts "$TARGET_DIR/scripts" || true - chown -h 1000:1000 "$TARGET_DIR/scripts" 2>/dev/null || true - echo "[scripts] provisioned docker-git scripts into workspace" - fi - fi -else - echo "[clone] failed" - touch "$CLONE_FAIL_PATH" -fi` - -const renderEntrypointClone = (config: TemplateConfig): string => - [renderClonePreamble(), renderCloneBody(config), renderCloneFinalize()].join("\n\n") - -export const renderEntrypointBackgroundTasks = (config: TemplateConfig): string => - `# 4) Start background tasks so SSH can come up immediately -( -${renderEntrypointAutoUpdate()} - -${renderEntrypointClone(config)} - -if [[ "$CLONE_OK" -eq 1 ]]; then - docker_git_prepare_active_agent_project_rules -fi - -${renderAgentLaunch(config)} -) &` -// CHANGE: leave browser start/reuse ownership with the external Rust MCP module. -// WHY: issue #347 moves browser lifecycle to ProverCoderAI/rust-browser-connection; docker-git only wires MCP config and cleanup. -// QUOTE(ТЗ): "Вынести noVNC + MCP Playright в единый модуль." -// REF: issue-347 -// SOURCE: n/a -// FORMAT THEOREM: MCP_PLAYWRIGHT_ENABLE=1 -> browser-connection owns start/reuse(DOCKER_GIT_BROWSER_CONTAINER_NAME) -// PURITY: SHELL -// EFFECT: generated bash calls docker-git-browser-connection stop during teardown. -// INVARIANT: docker-git entrypoint never eagerly starts the browser; browser-connection starts/reuses it on demand. -// COMPLEXITY: O(1) cleanup orchestration; Docker lifecycle is delegated to Rust. -const renderEntrypointRustBrowserConnectionStop = (): ReadonlyArray => [ - "# Rust browser connection cleanup only; browser-connection owns start/reuse on demand.", - "# Do not call docker-git-browser-connection start here, or lifecycle ownership is duplicated.", - "docker_git_stop_playwright_browser() {", - " if [[ \"${MCP_PLAYWRIGHT_ENABLE:-0}\" != \"1\" ]]; then", - " return 0", - " fi", - "", - " local browser_bin=\"\"", - " local candidate", - " for candidate in /usr/local/bin/docker-git-browser-connection /root/.cargo/bin/docker-git-browser-connection /usr/local/cargo/bin/docker-git-browser-connection $(command -v docker-git-browser-connection 2>/dev/null || true); do", - " if [[ -x \"$candidate\" ]]; then", - " browser_bin=\"$candidate\"", - " break", - " fi", - " done", - "", - " if [[ -z \"$browser_bin\" ]]; then", - " return 0", - " fi", - "", - " local project_container=\"${DOCKER_GIT_PROJECT_CONTAINER_NAME:-$(hostname)}\"", - " \"$browser_bin\" stop --project \"$project_container\" >> /var/log/docker-git-browser.log 2>&1 || true", - "}" -] - -export const renderEntrypointRustBrowserConnection = (): string => - [ - ...renderEntrypointRustBrowserConnectionStop() - ].join("\n") diff --git a/packages/app/src/lib/core/templates-prompt.ts b/packages/app/src/lib/core/templates-prompt.ts deleted file mode 100644 index 62f5f852..00000000 --- a/packages/app/src/lib/core/templates-prompt.ts +++ /dev/null @@ -1,344 +0,0 @@ -/* jscpd:ignore-start */ -import { renderZshConfig as renderZshConfigTemplate } from "./templates-zsh.js" - -// CHANGE: standardize docker-git prompt script for interactive shells -// WHY: keep prompt consistent between Dockerfile and entrypoint -// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" -// REF: user-request-2026-02-05-restore-prompt -// SOURCE: n/a -// FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: script is deterministic and does not touch TTY state outside interactive shells -// COMPLEXITY: O(1) -const dockerGitTerminalSanitizeShell = String.raw`docker_git_terminal_write_escape() { - if [ -c /dev/tty ]; then - { printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty; } 2>/dev/null && return 0 - fi - if [ -t 1 ]; then - printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[/dev/null || true -} -docker_git_terminal_parent_pid() { - ps -o ppid= -p "$1" 2>/dev/null | tr -d '[:space:]' -} -docker_git_terminal_command_basename() { - local command_line="$1" - printf "%s\n" "$command_line" | awk '{ name = $1; sub(/^.*\//, "", name); print name; exit }' -} -docker_git_terminal_is_agent_command() { - local command_name - command_name="$(docker_git_terminal_command_basename "$1")" - case "$command_name" in - .docker-git-claude-real|claude|codex|opencode|gemini|grok) - return 0 - ;; - *) - return 1 - ;; - esac -} -docker_git_terminal_has_agent_ancestor() { - local pid="$1" - local depth=0 - local command_line="" - local parent_pid="" - if [ -z "$pid" ]; then - pid="$$" - fi - while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$depth" -lt 32 ]; do - command_line="$(docker_git_terminal_process_args "$pid")" - if docker_git_terminal_is_agent_command "$command_line"; then - return 0 - fi - parent_pid="$(docker_git_terminal_parent_pid "$pid")" - if [ -z "$parent_pid" ] || [ "$parent_pid" = "$pid" ]; then - return 1 - fi - pid="$parent_pid" - depth=$((depth + 1)) - done - return 1 -} -docker_git_terminal_should_sanitize() { - if [ -n "$(printenv DOCKER_GIT_TERMINAL_FORCE_SANITIZE 2>/dev/null)" ]; then - return 0 - fi - if [ -n "$(printenv DOCKER_GIT_TERMINAL_DISABLE_SANITIZE 2>/dev/null)" ]; then - return 1 - fi - if docker_git_terminal_has_agent_ancestor "$$"; then - return 1 - fi - return 0 -} -docker_git_terminal_sanitize() { - # Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools. - docker_git_terminal_should_sanitize || return 0 - if [ -c /dev/tty ]; then - { stty sane < /dev/tty > /dev/tty; } 2>/dev/null || { stty sane < /dev/tty; } 2>/dev/null || true - elif [ -t 0 ]; then - stty sane 2>/dev/null || true - fi - docker_git_terminal_write_escape || true -}` - -const dockerGitPromptScript = `${dockerGitTerminalSanitizeShell} -case "$-" in - *i*) ;; - *) return 0 2>/dev/null || exit 0 ;; -esac - -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_short_pwd() { - local full_path - full_path="\${PWD:-}" - if [[ -z "$full_path" ]]; then - printf "%s" "?" - return - fi - - local display="$full_path" - if [[ -n "\${HOME:-}" && "$full_path" == "$HOME" ]]; then - display="~" - elif [[ -n "\${HOME:-}" && "$full_path" == "$HOME/"* ]]; then - display="~/\${full_path#$HOME/}" - fi - - if [[ "$display" == "~" || "$display" == "/" ]]; then - printf "%s" "$display" - return - fi - - local prefix="" - local body="$display" - if [[ "$body" == "~/"* ]]; then - prefix="~/" - body="\${body#~/}" - elif [[ "$body" == /* ]]; then - prefix="/" - body="\${body#/}" - fi - - local result="$prefix" - local segment="" - local rest="$body" - while [[ "$rest" == */* ]]; do - segment="\${rest%%/*}" - rest="\${rest#*/}" - if [[ -n "$segment" ]]; then - result+="\${segment:0:1}/" - fi - done - - if [[ -n "$rest" ]]; then - result+="$rest" - elif [[ "$result" == "~/" ]]; then - result="~" - elif [[ -z "$result" ]]; then - result="/" - fi - - printf "%s" "$result" -} -docker_git_prompt_apply() { - docker_git_terminal_sanitize - local b - b="$(docker_git_branch)" - local short_pwd - short_pwd="$(docker_git_short_pwd)" - local base="[\\t] $short_pwd" - if [ -n "$b" ]; then - PS1="\${base} (\${b})> " - else - PS1="\${base}> " - fi -} -if [ -n "\${PROMPT_COMMAND-}" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;\${PROMPT_COMMAND}" -else - PROMPT_COMMAND="docker_git_prompt_apply" -fi -docker_git_terminal_sanitize -trap 'docker_git_terminal_sanitize' EXIT` - -export const renderPromptScript = (): string => dockerGitPromptScript - -// CHANGE: enable bash completion for interactive shells -// WHY: allow tab completion for CLI tools in SSH terminals -// QUOTE(ТЗ): "А почему у меня не работает автодополенние в терминале?" -// REF: user-request-2026-02-05-bash-completion -// SOURCE: n/a -// FORMAT THEOREM: forall s in InteractiveShells: completion(s) -> enabled(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: only runs when bash completion files exist -// COMPLEXITY: O(1) -export const renderBashCompletionScript = (): string => - `if ! shopt -oq posix; then - if [ -f /usr/share/bash-completion/bash_completion ]; then - . /usr/share/bash-completion/bash_completion - elif [ -f /etc/bash_completion ]; then - . /etc/bash_completion - fi -fi` - -// CHANGE: enable bash history persistence and prefix search -// WHY: keep command history between sessions and allow prefix-based navigation -// QUOTE(ТЗ): "Он не помнит прошлый вывод команд" -// REF: user-request-2026-02-05-bash-history -// SOURCE: n/a -// FORMAT THEOREM: forall s in InteractiveShells: history(s) -> persisted(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: PROMPT_COMMAND preserves existing prompt logic -// COMPLEXITY: O(1) -export const renderBashHistoryScript = (): string => - `if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - HISTFILE="\${HISTFILE:-$HOME/.bash_history}" - HISTSIZE="\${HISTSIZE:-10000}" - HISTFILESIZE="\${HISTFILESIZE:-20000}" - HISTCONTROL="\${HISTCONTROL:-ignoredups:erasedups}" - export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL - shopt -s histappend - if [ -n "\${PROMPT_COMMAND-}" ]; then - PROMPT_COMMAND="history -a; \${PROMPT_COMMAND}" - else - PROMPT_COMMAND="history -a" - fi - ;; - esac -fi` - -// CHANGE: add readline bindings for prefix history search -// WHY: allow up/down arrows to search history by current prefix -// QUOTE(ТЗ): "если я писал cd ... то он должен запомнить и когда я напишу cd он мне предложит" -// REF: user-request-2026-02-05-inputrc -// SOURCE: n/a -// FORMAT THEOREM: forall p: prefix(p) -> history_search(p) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: does not override user inputrc when already present -// COMPLEXITY: O(1) -export const renderInputRc = (): string => - String.raw`set show-all-if-ambiguous on -set completion-ignore-case on -"\e[A": history-search-backward -"\e[B": history-search-forward` - -// CHANGE: configure zsh with autosuggestions, history search, and non-noisy completion UX -// WHY: avoid dumping completion candidates into the terminal scrollback on ambiguous prefixes -// QUOTE(ТЗ): "пусть будет zzh если он сделате то что я хочу" | "Почему при наборе текста он пишет в моём терминале какую-то билиберду?" -// REF: user-request-2026-02-05-zsh-autosuggest | user-request-2026-02-10-zsh-completion-noise -// SOURCE: n/a -// FORMAT THEOREM: forall s in ZshInteractive: autosuggest(s) -> enabled(s) ∧ completion(s) -> non_noisy(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: zsh config does not depend on user dotfiles -// COMPLEXITY: O(1) -export const renderZshConfig = (): string => renderZshConfigTemplate(dockerGitTerminalSanitizeShell) - -// CHANGE: add git branch info to interactive shell prompt -// WHY: restore docker-git prompt with time + path + branch -// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" -// REF: user-request-2026-02-05-restore-prompt -// SOURCE: n/a -// FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: only interactive shells mutate prompt or TTY state -// COMPLEXITY: O(1) -export const renderDockerfilePrompt = (): string => - String.raw`# Shell prompt: show git branch for interactive sessions -RUN cat <<'EOF' > /etc/profile.d/zz-prompt.sh -${renderPromptScript()} -EOF -RUN chmod 0644 /etc/profile.d/zz-prompt.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" \ - >> /etc/bash.bashrc -RUN cat <<'EOF' > /etc/profile.d/zz-bash-completion.sh -${renderBashCompletionScript()} -EOF -RUN chmod 0644 /etc/profile.d/zz-bash-completion.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" \ - >> /etc/bash.bashrc -RUN cat <<'EOF' > /etc/profile.d/zz-bash-history.sh -${renderBashHistoryScript()} -EOF -RUN chmod 0644 /etc/profile.d/zz-bash-history.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" \ - >> /etc/bash.bashrc -RUN mkdir -p /etc/zsh -RUN cat <<'EOF' > /etc/zsh/zshrc -${renderZshConfig()} -EOF` - -// CHANGE: ensure the docker-git prompt is always available at runtime -// WHY: --force rebuilds can reuse cached layers that left an empty prompt file -// QUOTE(ТЗ): "Промт должен создаваться нашим docker-git тулой" -// REF: user-request-2026-02-05-restore-prompt -// SOURCE: n/a -// FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: /etc/profile.d/zz-prompt.sh is non-empty after entrypoint and inert for non-interactive shells -// COMPLEXITY: O(1) -export const renderEntrypointPrompt = (): string => - String.raw`# Ensure docker-git prompt is configured for interactive shells -PROMPT_PATH="/etc/profile.d/zz-prompt.sh" -if [[ ! -s "$PROMPT_PATH" ]]; then - cat <<'EOF' > "$PROMPT_PATH" -${renderPromptScript()} -EOF - chmod 0644 "$PROMPT_PATH" -fi -if ! grep -q "zz-prompt.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" >> /etc/bash.bashrc -fi` - -export const renderEntrypointBashCompletion = (): string => - String.raw`# Ensure bash completion is configured for interactive shells -COMPLETION_PATH="/etc/profile.d/zz-bash-completion.sh" -if [[ ! -s "$COMPLETION_PATH" ]]; then - cat <<'EOF' > "$COMPLETION_PATH" -${renderBashCompletionScript()} -EOF - chmod 0644 "$COMPLETION_PATH" -fi -if ! grep -q "zz-bash-completion.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" >> /etc/bash.bashrc -fi` - -export const renderEntrypointBashHistory = (): string => - String.raw`# Ensure bash history is configured for interactive shells -HISTORY_PATH="/etc/profile.d/zz-bash-history.sh" -if [[ ! -s "$HISTORY_PATH" ]]; then - cat <<'EOF' > "$HISTORY_PATH" -${renderBashHistoryScript()} -EOF - chmod 0644 "$HISTORY_PATH" -fi -if ! grep -q "zz-bash-history.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" >> /etc/bash.bashrc -fi` - -export const renderEntrypointZshConfig = (): string => - String.raw`# Ensure zsh config exists for autosuggestions -ZSHRC_PATH="/etc/zsh/zshrc" -if [[ ! -s "$ZSHRC_PATH" ]]; then - mkdir -p /etc/zsh - cat <<'EOF' > "$ZSHRC_PATH" -${renderZshConfig()} -EOF -fi` -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/core/templates/docker-compose.ts b/packages/app/src/lib/core/templates/docker-compose.ts deleted file mode 100644 index 397724e2..00000000 --- a/packages/app/src/lib/core/templates/docker-compose.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* jscpd:ignore-start */ -import { - dockerGitSharedCacheVolumeName, - dockerGitSharedCodexVolumeName, - resolveComposeNetworkName, - resolveComposeProjectName, - resolveProjectBootstrapVolumeName, - type TemplateConfig -} from "../domain.js" -import type { ResolvedComposeResourceLimits } from "../resource-limits.js" - -type ComposeFragments = { - readonly networkMode: TemplateConfig["dockerNetworkMode"] - readonly networkName: string - readonly maybeGithubAuthSkipEnv: string - readonly maybeGitTokenLabelEnv: string - readonly maybeCodexAuthLabelEnv: string - readonly maybeClaudeAuthLabelEnv: string - readonly maybeGeminiAuthLabelEnv: string - readonly maybeGrokAuthLabelEnv: string - readonly maybeAgentModeEnv: string - readonly maybeAgentAutoEnv: string - readonly maybeDependsOn: string - readonly maybeDockerSocketMount: string - readonly maybePlaywrightEnv: string - readonly maybeBrowserVolume: string - readonly maybeBootstrapMounts: string - readonly forkRepoUrl: string -} - -type PlaywrightFragments = Pick< - ComposeFragments, - "maybeDependsOn" | "maybeDockerSocketMount" | "maybePlaywrightEnv" | "maybeBrowserVolume" -> - -type AuthEnvFragments = Pick< - ComposeFragments, - | "maybeGitTokenLabelEnv" - | "maybeCodexAuthLabelEnv" - | "maybeClaudeAuthLabelEnv" - | "maybeGeminiAuthLabelEnv" - | "maybeGrokAuthLabelEnv" -> - -type AgentEnvFragments = Pick - -export type DockerComposeRenderOptions = { - readonly enableLocalDockerSocket: boolean -} - -export type ComposeResourceLimits = { - readonly main: ResolvedComposeResourceLimits | undefined - readonly playwright: ResolvedComposeResourceLimits | undefined -} - -const defaultDockerComposeRenderOptions: DockerComposeRenderOptions = { enableLocalDockerSocket: false } -const sharedCodexVolumeKey = "docker_git_shared_codex" -const sharedCacheVolumeKey = "docker_git_shared_cache" -const bootstrapVolumeKey = "docker_git_bootstrap" - -const renderGitTokenLabelEnv = (gitTokenLabel: string): string => - gitTokenLabel.length > 0 - ? ` GITHUB_AUTH_LABEL: "${gitTokenLabel}"\n GIT_AUTH_LABEL: "${gitTokenLabel}"\n` - : "" - -const renderGithubAuthSkipEnv = (skipGithubAuth: boolean): string => - skipGithubAuth - ? ` GITHUB_AUTH_SKIP: "1"\n` - : "" - -const renderCodexAuthLabelEnv = (codexAuthLabel: string): string => - codexAuthLabel.length > 0 - ? ` CODEX_AUTH_LABEL: "${codexAuthLabel}"\n` - : "" - -const renderClaudeAuthLabelEnv = (claudeAuthLabel: string): string => - claudeAuthLabel.length > 0 - ? ` CLAUDE_AUTH_LABEL: "${claudeAuthLabel}"\n` - : "" - -const renderGeminiAuthLabelEnv = (geminiAuthLabel: string): string => - geminiAuthLabel.length > 0 - ? ` GEMINI_AUTH_LABEL: "${geminiAuthLabel}"\n` - : "" - -const renderGrokAuthLabelEnv = (grokAuthLabel: string): string => - grokAuthLabel.length > 0 - ? ` GROK_AUTH_LABEL: "${grokAuthLabel}"\n` - : "" - -const renderAgentModeEnv = (agentMode: string | undefined): string => - agentMode !== undefined && agentMode.length > 0 - ? ` AGENT_MODE: "${agentMode}"\n` - : "" - -const renderAgentAutoEnv = (agentAuto: boolean | undefined): string => - agentAuto === true - ? ` AGENT_AUTO: "1"\n` - : "" - -const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string => - resourceLimits === undefined - ? "" - : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.swapLimit}"\n` - -const renderGpu = (gpu: TemplateConfig["gpu"]): string => - gpu === "all" - ? " gpus: all\n" - : "" - -const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/docker-git/bootstrap/source:ro` - -const renderYamlSingleQuoted = (value: string): string => `'${value.replaceAll("'", "''")}'` - -const renderOptionalDockerSocketMount = (enableLocalDockerSocket: boolean): string => - enableLocalDockerSocket - ? ` - /var/run/docker.sock:/var/run/docker.sock` - : "" - -const renderEnvFiles = (config: TemplateConfig): string => - ` env_file:\n - ${renderYamlSingleQuoted(config.envGlobalPath)}\n - ${ - renderYamlSingleQuoted(config.envProjectPath) - }\n` - -const optionalTrimmed = (value: string | undefined): string => value?.trim() ?? "" - -const buildAuthEnvFragments = (config: TemplateConfig): AuthEnvFragments => ({ - maybeGitTokenLabelEnv: renderGitTokenLabelEnv(optionalTrimmed(config.gitTokenLabel)), - maybeCodexAuthLabelEnv: renderCodexAuthLabelEnv(optionalTrimmed(config.codexAuthLabel)), - maybeClaudeAuthLabelEnv: renderClaudeAuthLabelEnv(optionalTrimmed(config.claudeAuthLabel)), - maybeGeminiAuthLabelEnv: renderGeminiAuthLabelEnv(optionalTrimmed(config.geminiAuthLabel)), - maybeGrokAuthLabelEnv: renderGrokAuthLabelEnv(optionalTrimmed(config.grokAuthLabel)) -}) - -const buildAgentEnvFragments = (config: TemplateConfig): AgentEnvFragments => ({ - maybeAgentModeEnv: renderAgentModeEnv(config.agentMode), - maybeAgentAutoEnv: renderAgentAutoEnv(config.agentAuto) -}) - -const renderBrowserLimitEnv = ( - key: string, - value: number | string | undefined -): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` - -const buildPlaywrightFragments = ( - config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined, - options: DockerComposeRenderOptions -): PlaywrightFragments => { - if (!config.enableMcpPlaywright) { - return { - maybeDependsOn: "", - maybeDockerSocketMount: "", - maybePlaywrightEnv: "", - maybeBrowserVolume: "" - } - } - - const browserContainerName = `${config.containerName}-browser` - const browserVolumeName = `${config.volumeName}-browser` - const browserImageName = `${browserContainerName}:docker-git-browser` - - return { - maybeDependsOn: "", - maybeDockerSocketMount: renderOptionalDockerSocketMount(options.enableLocalDockerSocket), - maybePlaywrightEnv: - ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ - renderBrowserLimitEnv("DOCKER_GIT_BROWSER_CPU_LIMIT", resourceLimits?.cpuLimit) - }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, - maybeBrowserVolume: ` ${browserVolumeName}:` - } -} - -const isResolvedComposeResourceLimits = ( - value: ResolvedComposeResourceLimits | ComposeResourceLimits -): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value - -const normalizeComposeResourceLimits = ( - resourceLimits: ResolvedComposeResourceLimits | ComposeResourceLimits | undefined -): ComposeResourceLimits => { - if (resourceLimits === undefined) { - return { main: undefined, playwright: undefined } - } - if (isResolvedComposeResourceLimits(resourceLimits)) { - return { main: resourceLimits, playwright: resourceLimits } - } - return resourceLimits -} - -const buildComposeFragments = ( - config: TemplateConfig, - resourceLimits: ComposeResourceLimits, - options: DockerComposeRenderOptions -): ComposeFragments => { - const networkMode = config.dockerNetworkMode - const networkName = resolveComposeNetworkName(config) - const forkRepoUrl = config.forkRepoUrl ?? "" - const maybeGithubAuthSkipEnv = renderGithubAuthSkipEnv(config.skipGithubAuth) - const authEnv = buildAuthEnvFragments(config) - const agentEnv = buildAgentEnvFragments(config) - const playwright = buildPlaywrightFragments(config, resourceLimits.playwright, options) - - return { - networkMode, - networkName, - maybeGithubAuthSkipEnv, - ...authEnv, - ...agentEnv, - maybeDependsOn: playwright.maybeDependsOn, - maybeDockerSocketMount: playwright.maybeDockerSocketMount, - maybePlaywrightEnv: playwright.maybePlaywrightEnv, - maybeBrowserVolume: playwright.maybeBrowserVolume, - maybeBootstrapMounts: renderBootstrapMounts(), - forkRepoUrl - } -} - -const renderComposeServices = ( - config: TemplateConfig, - fragments: ComposeFragments, - resourceLimits: ComposeResourceLimits -): string => - `services: - ${config.serviceName}: - build: . - container_name: ${config.containerName} -${renderGpu(config.gpu)}${ - renderEnvFiles(config) - } # runtime auth/env must be loaded into the container process, not only bootstrap scripts - environment: - REPO_URL: "${config.repoUrl}" - REPO_REF: "${config.repoRef}" - FORK_REPO_URL: "${fragments.forkRepoUrl}" -${fragments.maybeGithubAuthSkipEnv} # Optional anonymous public GitHub clone override -${fragments.maybeGitTokenLabelEnv} # Optional token label selector (maps to GITHUB_TOKEN__