diff --git a/README.md b/README.md index a7e04863..5d0806e7 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,32 @@ pnpm run docker-git # Clone a repo into its own container (creates under ~/.docker-git) pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force +# Clone an issue URL (creates isolated workspace + issue branch) +pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force + +# Reset only project env defaults (keep workspace volume/data) +pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env + # Same, but also enable Playwright MCP + Chromium sidecar for Codex pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force --mcp-playwright ``` +## Parallel Issues / PRs + +When you clone GitHub issue or PR URLs, docker-git creates isolated project paths and container names: +- `.../issues/123` -> `///issue-123` (branch `issue-123`) +- `.../pull/45` -> `///pr-45` (ref `refs/pull/45/head`) + +This lets you run multiple issues/PRs for the same repository in parallel without container/path collisions. + +Force modes: +- `--force`: overwrite managed files and wipe compose volumes (`docker compose down -v`). +- `--force-env`: reset only project env defaults and recreate containers without wiping volumes. + +Agent context for issue workspaces: +- Global `${CODEX_HOME}/AGENTS.md` includes workspace path + issue/PR context. +- For `issue-*` workspaces, docker-git creates `${TARGET_DIR}/AGENTS.md` (if missing) with issue context and auto-adds it to `.git/info/exclude`. + ## Projects Root Layout The projects root is: diff --git a/packages/app/src/docker-git/cli/parser-clone.ts b/packages/app/src/docker-git/cli/parser-clone.ts index 484fe98a..59945a5e 100644 --- a/packages/app/src/docker-git/cli/parser-clone.ts +++ b/packages/app/src/docker-git/cli/parser-clone.ts @@ -5,21 +5,24 @@ import type { RawOptions } from "@effect-template/lib/core/command-options" import { type Command, defaultTemplateConfig, - deriveRepoPathParts, type ParseError, resolveRepoInput } from "@effect-template/lib/core/domain" import { parseRawOptions } from "./parser-options.js" -import { splitPositionalRepo } from "./parser-shared.js" +import { resolveWorkspaceRepoPath, splitPositionalRepo } from "./parser-shared.js" -const applyCloneDefaults = (raw: RawOptions, repoUrl: string): RawOptions => { - const repoPath = deriveRepoPathParts(repoUrl).pathParts.join("/") +const applyCloneDefaults = ( + raw: RawOptions, + rawRepoUrl: string, + resolvedRepo: ReturnType +): RawOptions => { + const repoPath = resolveWorkspaceRepoPath(resolvedRepo) const sshUser = raw.sshUser?.trim() ?? defaultTemplateConfig.sshUser const homeDir = `/home/${sshUser}` return { ...raw, - repoUrl, + repoUrl: rawRepoUrl, outDir: raw.outDir ?? `.docker-git/${repoPath}`, targetDir: raw.targetDir ?? `${homeDir}/${repoPath}` } @@ -42,7 +45,7 @@ export const parseClone = (args: ReadonlyArray): Either.Either RawOptio "--up": (raw) => ({ ...raw, up: true }), "--no-up": (raw) => ({ ...raw, up: false }), "--force": (raw) => ({ ...raw, force: true }), + "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), "--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }), "--web": (raw) => ({ ...raw, authWeb: true }), diff --git a/packages/app/src/docker-git/cli/parser-shared.ts b/packages/app/src/docker-git/cli/parser-shared.ts index 7144f378..eda4fc4c 100644 --- a/packages/app/src/docker-git/cli/parser-shared.ts +++ b/packages/app/src/docker-git/cli/parser-shared.ts @@ -9,6 +9,14 @@ type PositionalRepo = { readonly restArgs: ReadonlyArray } +export const resolveWorkspaceRepoPath = ( + resolvedRepo: ReturnType +): string => { + const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts + const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts + return projectParts.join("/") +} + export const splitPositionalRepo = (args: ReadonlyArray): PositionalRepo => { const first = args[0] const positionalRepoUrl = first !== undefined && !first.startsWith("-") ? first : undefined @@ -24,10 +32,10 @@ export const parseProjectDirWithOptions = ( const { positionalRepoUrl, restArgs } = splitPositionalRepo(args) const raw = yield* _(parseRawOptions(restArgs)) const rawRepoUrl = raw.repoUrl ?? positionalRepoUrl - const resolvedRepo = rawRepoUrl ? resolveRepoInput(rawRepoUrl).repoUrl : null + const repoPath = rawRepoUrl ? resolveWorkspaceRepoPath(resolveRepoInput(rawRepoUrl)) : null const projectDir = raw.projectDir ?? - (resolvedRepo - ? `.docker-git/${deriveRepoPathParts(resolvedRepo).pathParts.join("/")}` + (repoPath + ? `.docker-git/${repoPath}` : defaultProjectDir) return { projectDir, raw } diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index d89a3a5b..aeabd2d1 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -30,7 +30,7 @@ Commands: Options: --repo-ref Git ref/branch (default: main) --branch, -b Alias for --repo-ref - --target-dir Target dir inside container (create default: /home/dev/app, clone default: /home/dev//) + --target-dir Target dir inside container (create default: /home/dev/app, clone default: /home/dev//[/issue-|/pr-]) --ssh-port Local SSH port (default: 2222) --ssh-user SSH user inside container (default: dev) --container-name Docker container name (default: dg-) @@ -42,13 +42,14 @@ Options: --env-project Host path to project env file (default: ./.orch/env/project.env) --codex-auth Host path for Codex auth cache (default: /.orch/auth/codex) --codex-home Container path for Codex auth (default: /home/dev/.codex) - --out-dir Output directory (default: //) + --out-dir Output directory (default: //[/issue-|/pr-]) --project-dir Project directory for attach (default: .) --lines Tail last N lines for sessions logs (default: 200) --include-default Show default/system processes in sessions list --up | --no-up Run docker compose up after init (default: --up) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) --force Overwrite existing files and wipe compose volumes (docker compose down -v) + --force-env Reset project env defaults only (keep workspace volume/data) -h, --help Show this help Container runtime env (set via .orch/env/project.env): diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index d0a2c1d1..6b43e6ab 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -1,4 +1,4 @@ -import { type CreateCommand, deriveRepoPathParts } from "@effect-template/lib/core/domain" +import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain" import { createProject } from "@effect-template/lib/usecases/actions" import type { AppError } from "@effect-template/lib/usecases/errors" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" @@ -59,6 +59,9 @@ export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => { if (input.force) { args.push("--force") } + if (input.forceEnv) { + args.push("--force-env") + } return args } @@ -91,8 +94,10 @@ const joinPath = (...parts: ReadonlyArray): string => { } const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => { - const repoPath = deriveRepoPathParts(repoUrl).pathParts - return joinPath(defaultProjectsRoot(cwd), ...repoPath) + const resolvedRepo = resolveRepoInput(repoUrl) + const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts + const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts + return joinPath(defaultProjectsRoot(cwd), ...projectParts) } export const resolveCreateInputs = ( @@ -100,17 +105,19 @@ export const resolveCreateInputs = ( values: Partial ): CreateInputs => { const repoUrl = values.repoUrl ?? "" + const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets") const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "") return { repoUrl, - repoRef: values.repoRef ?? "main", + repoRef: values.repoRef ?? resolvedRepoRef ?? "main", outDir, secretsRoot, runUp: values.runUp !== false, enableMcpPlaywright: values.enableMcpPlaywright === true, - force: values.force === true + force: values.force === true, + forceEnv: values.forceEnv === true } } diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index a59e273d..8d3a21f4 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -50,6 +50,7 @@ export type CreateInputs = { readonly runUp: boolean readonly enableMcpPlaywright: boolean readonly force: boolean + readonly forceEnv: boolean } export type CreateStep = diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index 1e93e157..4ed60f45 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -33,6 +33,7 @@ const expectCreateDefaults = (command: CreateCommand) => { expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef) expect(command.outDir).toBe(".docker-git/org/repo") expect(command.runUp).toBe(true) + expect(command.forceEnv).toBe(false) } describe("parseArgs", () => { @@ -45,6 +46,16 @@ describe("parseArgs", () => { expect(command.config.sshPort).toBe(defaultTemplateConfig.sshPort) })) + it.effect("parses create command with issue url into isolated defaults", () => + expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => { + expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") + expect(command.config.repoRef).toBe("issue-9") + expect(command.outDir).toBe(".docker-git/org/repo/issue-9") + expect(command.config.containerName).toBe("dg-repo-issue-9") + expect(command.config.serviceName).toBe("dg-repo-issue-9") + expect(command.config.volumeName).toBe("dg-repo-issue-9-home") + })) + it.effect("fails on missing repo url", () => Effect.sync(() => { Either.match(parseArgs(["create"]), { @@ -68,6 +79,18 @@ describe("parseArgs", () => { expect(command.config.repoRef).toBe("feature-x") })) + it.effect("parses force-env flag for clone", () => + expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => { + expect(command.force).toBe(false) + expect(command.forceEnv).toBe(true) + })) + + it.effect("supports force + force-env together", () => + expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force", "--force-env"], (command) => { + expect(command.force).toBe(true) + expect(command.forceEnv).toBe(true) + })) + it.effect("parses GitHub tree url as repo + ref", () => expectCreateCommand(["clone", "https://github.com/agiens/crm/tree/vova-fork"], (command) => { expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git") @@ -76,6 +99,37 @@ describe("parseArgs", () => { expect(command.config.targetDir).toBe("/home/dev/agiens/crm") })) + it.effect("parses GitHub issue url as isolated project + issue branch", () => + expectCreateCommand(["clone", "https://github.com/org/repo/issues/5"], (command) => { + expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") + expect(command.config.repoRef).toBe("issue-5") + expect(command.outDir).toBe(".docker-git/org/repo/issue-5") + expect(command.config.targetDir).toBe("/home/dev/org/repo/issue-5") + expect(command.config.containerName).toBe("dg-repo-issue-5") + expect(command.config.serviceName).toBe("dg-repo-issue-5") + expect(command.config.volumeName).toBe("dg-repo-issue-5-home") + })) + + it.effect("parses GitHub PR url as isolated project", () => + expectCreateCommand(["clone", "https://github.com/org/repo/pull/42"], (command) => { + expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") + expect(command.config.repoRef).toBe("refs/pull/42/head") + expect(command.outDir).toBe(".docker-git/org/repo/pr-42") + expect(command.config.targetDir).toBe("/home/dev/org/repo/pr-42") + expect(command.config.containerName).toBe("dg-repo-pr-42") + expect(command.config.serviceName).toBe("dg-repo-pr-42") + expect(command.config.volumeName).toBe("dg-repo-pr-42-home") + })) + + it.effect("parses attach with GitHub issue url into issue workspace", () => + Effect.sync(() => { + const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"]) + if (command._tag !== "Attach") { + throw new Error("expected Attach command") + } + expect(command.projectDir).toBe(".docker-git/org/repo/issue-7") + })) + it.effect("parses down-all command", () => Effect.sync(() => { const command = parseOrThrow(["down-all"]) diff --git a/packages/docker-git/src/server/http.ts b/packages/docker-git/src/server/http.ts index 56bef9ff..df22fe97 100644 --- a/packages/docker-git/src/server/http.ts +++ b/packages/docker-git/src/server/http.ts @@ -1116,6 +1116,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor outDir: project.directory, runUp: false, force: true, + forceEnv: false, waitForClone: false })) yield* _(syncProjectCodexAuth(projectsRoot, project)) @@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)} outDir: project.directory, runUp: false, force: true, + forceEnv: false, waitForClone: false })) yield* _(syncProjectCodexAuth(projectsRoot, project)) diff --git a/packages/docker-git/tests/core/templates.test.ts b/packages/docker-git/tests/core/templates.test.ts index 684b0b30..8356b5dd 100644 --- a/packages/docker-git/tests/core/templates.test.ts +++ b/packages/docker-git/tests/core/templates.test.ts @@ -95,4 +95,39 @@ describe("planFiles", () => { expect(browserDockerfile !== undefined && browserDockerfile._tag === "File").toBe(true) expect(browserScript !== undefined && browserScript._tag === "File").toBe(true) })) + + it.effect("embeds issue workspace AGENTS context in entrypoint", () => + Effect.sync(() => { + const config: TemplateConfig = { + containerName: "dg-repo-issue-5", + serviceName: "dg-repo-issue-5", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "issue-5", + targetDir: "/home/dev/org/repo/issue-5", + volumeName: "dg-repo-issue-5-home", + authorizedKeysPath: "./authorized_keys", + envGlobalPath: "./.orch/env/global.env", + envProjectPath: "./.orch/env/project.env", + codexAuthPath: "./.orch/auth/codex", + codexSharedAuthPath: "../../.orch/auth/codex", + codexHome: "/home/dev/.codex", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + const specs = planFiles(config) + const entrypointSpec = specs.find( + (spec) => spec._tag === "File" && spec.relativePath === "entrypoint.sh" + ) + expect(entrypointSpec !== undefined && entrypointSpec._tag === "File").toBe(true) + if (entrypointSpec && entrypointSpec._tag === "File") { + expect(entrypointSpec.contents).toContain("Доступные workspace пути:") + expect(entrypointSpec.contents).toContain("Контекст workspace:") + expect(entrypointSpec.contents).toContain("Issue AGENTS.md:") + expect(entrypointSpec.contents).toContain("ISSUE_AGENTS_PATH=\"$TARGET_DIR/AGENTS.md\"") + expect(entrypointSpec.contents).toContain("grep -qx \"AGENTS.md\" \"$EXCLUDE_PATH\"") + } + })) }) diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index cc95b9e2..01b98612 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -50,6 +50,7 @@ const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/" type RepoBasics = { readonly repoUrl: string readonly repoSlug: string + readonly projectSlug: string readonly repoPath: string readonly repoRef: string readonly targetDir: string @@ -63,7 +64,10 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either => Either.gen(function*(_) { - const derivedContainerName = `dg-${repoSlug}` - const derivedServiceName = `dg-${repoSlug}` - const derivedVolumeName = `dg-${repoSlug}-home` + const derivedContainerName = `dg-${projectSlug}` + const derivedServiceName = `dg-${projectSlug}` + const derivedVolumeName = `dg-${projectSlug}-home` const containerName = yield* _( nonEmpty("--container-name", raw.containerName, derivedContainerName) ) @@ -111,7 +115,7 @@ type PathConfig = { const resolvePaths = ( raw: RawOptions, - repoSlug: string, + projectSlug: string, repoPath: string ): Either.Either => Either.gen(function*(_) { @@ -127,7 +131,7 @@ const resolvePaths = ( : `${normalizedSecretsRoot}/global.env` const defaultEnvProjectPath = normalizedSecretsRoot === undefined ? defaultTemplateConfig.envProjectPath - : `${normalizedSecretsRoot}/${repoSlug}.env` + : `${normalizedSecretsRoot}/${projectSlug}.env` const defaultCodexAuthPath = normalizedSecretsRoot === undefined ? defaultTemplateConfig.codexAuthPath : `${normalizedSecretsRoot}/codex` @@ -163,10 +167,11 @@ export const buildCreateCommand = ( ): Either.Either => Either.gen(function*(_) { const repo = yield* _(resolveRepoBasics(raw)) - const names = yield* _(resolveNames(raw, repo.repoSlug)) - const paths = yield* _(resolvePaths(raw, repo.repoSlug, repo.repoPath)) + const names = yield* _(resolveNames(raw, repo.projectSlug)) + const paths = yield* _(resolvePaths(raw, repo.projectSlug, repo.repoPath)) const runUp = raw.up ?? true const force = raw.force ?? false + const forceEnv = raw.forceEnv ?? false const enableMcpPlaywright = raw.enableMcpPlaywright ?? false return { @@ -174,6 +179,7 @@ export const buildCreateCommand = ( outDir: paths.outDir, runUp, force, + forceEnv, waitForClone: false, config: { containerName: names.containerName, diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 2fb5ed19..4c284008 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -37,6 +37,7 @@ export interface RawOptions { readonly includeDefault?: boolean readonly up?: boolean readonly force?: boolean + readonly forceEnv?: boolean } // CHANGE: helper type alias for builder signatures that produce parse errors diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 61142102..248d1cf9 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -33,6 +33,7 @@ export interface CreateCommand { readonly outDir: string readonly runUp: boolean readonly force: boolean + readonly forceEnv: boolean readonly waitForClone: boolean } diff --git a/packages/lib/src/core/repo.ts b/packages/lib/src/core/repo.ts index eec73312..9def8751 100644 --- a/packages/lib/src/core/repo.ts +++ b/packages/lib/src/core/repo.ts @@ -205,9 +205,10 @@ export const parseGithubRepoUrl = (input: string): GithubRepo | null => { return { owner, repo } } -type ResolvedRepoInput = { +export type ResolvedRepoInput = { readonly repoUrl: string readonly repoRef?: string + readonly workspaceSuffix?: string } type GithubRefParts = { @@ -244,9 +245,11 @@ const parseGithubPrUrl = (input: string): ResolvedRepoInput | 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` + repoRef: `refs/pull/${parsed.ref}/head`, + workspaceSuffix } } @@ -278,7 +281,7 @@ const parseGithubTreeUrl = (input: string): ResolvedRepoInput | null => { // FORMAT THEOREM: ∀u: issue(u) → repo(u) // PURITY: CORE // EFFECT: n/a -// INVARIANT: issue URL yields repoUrl without repoRef +// INVARIANT: issue URL yields repoUrl + deterministic issue branch // COMPLEXITY: O(n) where n = |url| const parseGithubIssueUrl = (input: string): ResolvedRepoInput | null => { const parsed = parseGithubRefParts(input) @@ -287,7 +290,12 @@ const parseGithubIssueUrl = (input: string): ResolvedRepoInput | null => { } const repo = stripGitSuffix(parsed.repoRaw) - return { repoUrl: `https://github.com/${parsed.owner}/${repo}.git` } + const workspaceSuffix = `issue-${slugify(parsed.ref)}` + return { + repoUrl: `https://github.com/${parsed.owner}/${repo}.git`, + repoRef: workspaceSuffix, + workspaceSuffix + } } // CHANGE: normalize repo input and PR/issue URLs into repo + ref diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 05c6606a..db19091c 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -135,32 +135,97 @@ if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/d printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc fi` -export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => - String.raw`# Ensure global AGENTS.md exists for container context -AGENTS_PATH="${config.codexHome}/AGENTS.md" -LEGACY_AGENTS_PATH="/home/${config.sshUser}/AGENTS.md" -PROJECT_LINE="Рабочая папка проекта (git clone): ${config.targetDir}" +const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context +AGENTS_PATH="__CODEX_HOME__/AGENTS.md" +LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): __TARGET_DIR__" +WORKSPACES_LINE="Доступные workspace пути: __TARGET_DIR__" +WORKSPACE_INFO_LINE="Контекст workspace: repository" +FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__" +ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: n/a" INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +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 + ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: __TARGET_DIR__/AGENTS.md" +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')" + if [[ -n "$PR_ID" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" + else + WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" + fi +fi if [[ ! -f "$AGENTS_PATH" ]]; then - cat <<'AGENTS_EOF' > "$AGENTS_PATH" + MANAGED_START="" + MANAGED_END="" + MANAGED_BLOCK="$(cat < "$AGENTS_PATH" Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -Рабочая папка проекта (git clone): ${config.targetDir} -Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +$MANAGED_BLOCK Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. -AGENTS_EOF +EOF chown 1000:1000 "$AGENTS_PATH" || true fi if [[ -f "$AGENTS_PATH" ]]; then - if grep -q "^Рабочая папка проекта (git clone):" "$AGENTS_PATH"; then - sed -i "s|^Рабочая папка проекта (git clone):.*$|$PROJECT_LINE|" "$AGENTS_PATH" - else - printf "%s\n" "$PROJECT_LINE" >> "$AGENTS_PATH" - fi - if grep -q "^Доступ к интернету:" "$AGENTS_PATH"; then - sed -i "s|^Доступ к интернету:.*$|$INTERNET_LINE|" "$AGENTS_PATH" + MANAGED_START="" + MANAGED_END="" + MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" else - printf "%s\n" "$INTERNET_LINE" >> "$AGENTS_PATH" + sed \ + -e '/^Рабочая папка проекта (git clone):/d' \ + -e '/^Доступные workspace пути:/d' \ + -e '/^Контекст workspace:/d' \ + -e '/^Фокус задачи:/d' \ + -e '/^Issue AGENTS.md:/d' \ + -e '/^Доступ к интернету:/d' \ + "$AGENTS_PATH" > "$TMP_AGENTS_PATH" + if [[ -s "$TMP_AGENTS_PATH" ]]; then + printf "\n" >> "$TMP_AGENTS_PATH" + fi + printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" fi + mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" + chown 1000:1000 "$AGENTS_PATH" || true fi if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" @@ -169,3 +234,9 @@ if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; 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) diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index 7f148f9c..ea526865 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -21,14 +21,13 @@ rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" CLONE_OK=1` const renderCloneRemotes = (config: TemplateConfig): string => - `if [[ "$CLONE_OK" -eq 1 && -n "$FORK_REPO_URL" && -d "$TARGET_DIR/.git" ]]; then - AUTH_FORK_URL="$FORK_REPO_URL" - if [[ -n "$GIT_AUTH_TOKEN" && "$FORK_REPO_URL" == https://* ]]; then - AUTH_FORK_URL="$(printf "%s" "$FORK_REPO_URL" | sed "s#^https://#https://\${GIT_AUTH_USER}:\${GIT_AUTH_TOKEN}@#")" - fi - if [[ "$FORK_REPO_URL" != "$REPO_URL" ]]; then - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote set-url origin '$AUTH_FORK_URL'" || true - su - ${config.sshUser} -c "cd '$TARGET_DIR' && git remote add upstream '$AUTH_REPO_URL' 2>/dev/null || git remote set-url upstream '$AUTH_REPO_URL'" || true + `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` @@ -71,6 +70,11 @@ const renderCloneBodyRef = (config: TemplateConfig): string => if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then echo "[clone] git clone failed for $REPO_URL" CLONE_OK=0 + 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 else echo "[clone] git clone failed for $REPO_URL" @@ -85,8 +89,99 @@ const renderCloneBodyRef = (config: TemplateConfig): string => fi fi` +const renderIssueWorkspaceAgentsResolve = (): string => + `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 [[ -z "$ISSUE_URL" ]]; then + ISSUE_URL="n/a" +fi` + +const renderIssueWorkspaceAgentsManagedBlock = (): string => + `ISSUE_AGENTS_PATH="$TARGET_DIR/AGENTS.md" +ISSUE_MANAGED_START="" +ISSUE_MANAGED_END="" +ISSUE_MANAGED_BLOCK="$(cat < + `if [[ ! -e "$ISSUE_AGENTS_PATH" ]]; then + printf "%s\n" "$ISSUE_MANAGED_BLOCK" > "$ISSUE_AGENTS_PATH" +else + TMP_ISSUE_AGENTS_PATH="$(mktemp)" + if grep -qF "$ISSUE_MANAGED_START" "$ISSUE_AGENTS_PATH" && grep -qF "$ISSUE_MANAGED_END" "$ISSUE_AGENTS_PATH"; then + awk -v start="$ISSUE_MANAGED_START" -v end="$ISSUE_MANAGED_END" -v repl="$ISSUE_MANAGED_BLOCK" ' + BEGIN { in_block = 0 } + $0 == start { print repl; in_block = 1; next } + $0 == end { in_block = 0; next } + in_block == 0 { print } + ' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" + else + sed \ + -e '/^# docker-git issue workspace$/d' \ + -e '/^Issue workspace: #/d' \ + -e '/^Issue URL: /d' \ + -e '/^Workspace path: /d' \ + -e '/^Работай только над этим issue, если пользователь не попросил другое[.]$/d' \ + -e '/^Если нужен первоисточник требований, открой Issue URL[.]$/d' \ + "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" + if [[ -s "$TMP_ISSUE_AGENTS_PATH" ]]; then + printf "\n" >> "$TMP_ISSUE_AGENTS_PATH" + fi + printf "%s\n" "$ISSUE_MANAGED_BLOCK" >> "$TMP_ISSUE_AGENTS_PATH" + fi + mv "$TMP_ISSUE_AGENTS_PATH" "$ISSUE_AGENTS_PATH" +fi +if [[ -e "$ISSUE_AGENTS_PATH" ]]; then + chown 1000:1000 "$ISSUE_AGENTS_PATH" || true +fi` + +const renderIssueWorkspaceAgentsExclude = (): string => + `EXCLUDE_PATH="$TARGET_DIR/.git/info/exclude" +if [[ -f "$ISSUE_AGENTS_PATH" ]]; then + touch "$EXCLUDE_PATH" + if ! grep -qx "AGENTS.md" "$EXCLUDE_PATH"; then + printf "%s\n" "AGENTS.md" >> "$EXCLUDE_PATH" + fi +fi` + +const renderIssueWorkspaceAgents = (): string => + [ + `if [[ "$CLONE_OK" -eq 1 && "$REPO_REF" == issue-* && -d "$TARGET_DIR/.git" ]]; then`, + renderIssueWorkspaceAgentsResolve(), + "", + renderIssueWorkspaceAgentsManagedBlock(), + "", + renderIssueWorkspaceAgentsWrite(), + "", + renderIssueWorkspaceAgentsExclude(), + "fi" + ].join("\n") + const renderCloneBody = (config: TemplateConfig): string => - [renderCloneBodyStart(config), renderCloneBodyRef(config), "", renderCloneRemotes(config), "fi"].join("\n") + [ + renderCloneBodyStart(config), + renderCloneBodyRef(config), + "fi", + "", + renderCloneRemotes(config), + "", + renderIssueWorkspaceAgents() + ].join("\n") const renderCloneFinalize = (): string => `if [[ "$CLONE_OK" -eq 1 ]]; then diff --git a/packages/lib/src/shell/command-runner.ts b/packages/lib/src/shell/command-runner.ts index 016bccc0..7e2cf991 100644 --- a/packages/lib/src/shell/command-runner.ts +++ b/packages/lib/src/shell/command-runner.ts @@ -55,14 +55,14 @@ export const runCommandWithExitCodes = ( // FORMAT THEOREM: forall cmd: exitCode(cmd) = n // PURITY: SHELL // EFFECT: Effect -// INVARIANT: stdout/stderr are inherited +// INVARIANT: stdout/stderr are suppressed for status checks // COMPLEXITY: O(command) export const runCommandExitCode = ( spec: RunCommandSpec ): Effect.Effect => Effect.map( Command.exitCode( - buildCommand(spec, "inherit", "inherit", "inherit") + buildCommand(spec, "pipe", "pipe", "inherit") ), Number ) diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 97cb16a1..d50efe9f 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -64,6 +64,21 @@ export const runDockerComposeUp = ( ): Effect.Effect => runCompose(cwd, ["up", "-d", "--build"], [Number(ExitCode(0))]) +// CHANGE: recreate running containers without rebuilding images +// WHY: apply env-file changes while preserving workspace volumes and docker layer cache +// QUOTE(ТЗ): "сбросит только окружение" +// REF: user-request-2026-02-11-force-env +// SOURCE: n/a +// FORMAT THEOREM: ∀dir: up_force_recreate(dir) → recreated(containers(dir)) ∧ preserved(volumes(dir)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: does not invoke image build and does not remove volumes +// COMPLEXITY: O(command) +export const runDockerComposeUpRecreate = ( + cwd: string +): Effect.Effect => + runCompose(cwd, ["up", "-d", "--force-recreate"], [Number(ExitCode(0))]) + // CHANGE: run docker compose down in the target directory // WHY: allow stopping managed containers from the CLI/menu // QUOTE(ТЗ): "Могу удалить / Отключить" diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 33d42a24..dfa2f470 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -11,6 +11,14 @@ import { resolveBaseDir } from "./paths.js" const ensureParentDir = (path: Path.Path, fs: FileSystem.FileSystem, filePath: string) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) +const isFileSpec = (spec: FileSpec): spec is Extract => spec._tag === "File" + +const resolveSpecPath = ( + path: Path.Path, + baseDir: string, + spec: Extract +): string => path.join(baseDir, spec.relativePath) + const writeSpec = ( path: Path.Path, fs: FileSystem.FileSystem, @@ -36,6 +44,41 @@ const writeSpec = ( ) } +const collectExistingFilePaths = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string, + specs: ReadonlyArray +): Effect.Effect, PlatformError> => + Effect.gen(function*(_) { + const existingPaths: Array = [] + for (const spec of specs) { + if (!isFileSpec(spec)) { + continue + } + const filePath = resolveSpecPath(path, baseDir, spec) + const exists = yield* _(fs.exists(filePath)) + if (exists) { + existingPaths.push(filePath) + } + } + return existingPaths + }) + +const failOnExistingFiles = ( + existingFilePaths: ReadonlyArray, + skipExistingFiles: boolean +): Effect.Effect => { + if (skipExistingFiles || existingFilePaths.length === 0) { + return Effect.void + } + const firstPath = existingFilePaths[0] + if (!firstPath) { + return Effect.void + } + return Effect.fail(new FileExistsError({ path: firstPath })) +} + // CHANGE: write generated docker-git files to disk // WHY: isolate all filesystem effects in a thin shell // QUOTE(ТЗ): "создавать докер образы" @@ -49,7 +92,8 @@ const writeSpec = ( export const writeProjectFiles = ( outDir: string, config: TemplateConfig, - force: boolean + force: boolean, + skipExistingFiles: boolean = false ): Effect.Effect< ReadonlyArray, FileExistsError | PlatformError, @@ -62,23 +106,21 @@ export const writeProjectFiles = ( const specs = planFiles(config) const created: Array = [] + const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs)) + const existingSet = new Set(existingFilePaths) - if (!force) { - for (const spec of specs) { - if (spec._tag === "File") { - const filePath = path.join(baseDir, spec.relativePath) - const exists = yield* _(fs.exists(filePath)) - if (exists) { - return yield* _(Effect.fail(new FileExistsError({ path: filePath }))) - } - } - } - } + yield* _(failOnExistingFiles(existingFilePaths, skipExistingFiles)) for (const spec of specs) { + if (!force && skipExistingFiles && isFileSpec(spec)) { + const filePath = resolveSpecPath(path, baseDir, spec) + if (existingSet.has(filePath)) { + continue + } + } yield* _(writeSpec(path, fs, baseDir, spec)) - if (spec._tag === "File") { - created.push(path.join(baseDir, spec.relativePath)) + if (isFileSpec(spec)) { + created.push(resolveSpecPath(path, baseDir, spec)) } } diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 32b7c7ce..214fc2a4 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -85,11 +85,21 @@ const runCreateProject = ( yield* _(migrateProjectOrchLayout(ctx.baseDir, globalConfig, ctx.resolveRootPath)) const createdFiles = yield* _( - prepareProjectFiles(resolvedOutDir, ctx.baseDir, globalConfig, projectConfig, command.force) + prepareProjectFiles(resolvedOutDir, ctx.baseDir, globalConfig, projectConfig, { + force: command.force, + forceEnv: command.forceEnv + }) ) yield* _(logCreatedProject(resolvedOutDir, createdFiles)) - yield* _(runDockerUpIfNeeded(resolvedOutDir, projectConfig, command.runUp, command.waitForClone, command.force)) + yield* _( + runDockerUpIfNeeded(resolvedOutDir, projectConfig, { + runUp: command.runUp, + waitForClone: command.waitForClone, + force: command.force, + forceEnv: command.forceEnv + }) + ) if (command.runUp) { yield* _(logDockerAccessInfo(resolvedOutDir, projectConfig)) } diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index 21c38100..32d36d80 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -9,6 +9,7 @@ import { runDockerComposeDownVolumes, runDockerComposeLogsFollow, runDockerComposeUp, + runDockerComposeUpRecreate, runDockerExecExitCode, runDockerInspectContainerBridgeIp, runDockerNetworkConnectBridge @@ -46,6 +47,14 @@ const logSshAccess = ( }) type CloneState = "pending" | "done" | "failed" +type DockerUpError = CloneFailedError | DockerCommandError | PlatformError +type DockerUpEnvironment = CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path +type DockerUpOptions = { + readonly runUp: boolean + readonly waitForClone: boolean + readonly force: boolean + readonly forceEnv: boolean +} const checkCloneState = ( cwd: string, @@ -100,53 +109,74 @@ const waitForCloneCompletion = ( } }) -export const runDockerUpIfNeeded = ( +const runDockerComposeUpByMode = ( resolvedOutDir: string, - projectConfig: CreateCommand["config"], - runUp: boolean, - waitForClone: boolean, - force: boolean -): Effect.Effect< - void, - CloneFailedError | DockerCommandError | PlatformError, - CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path -> => + force: boolean, + forceEnv: boolean +): Effect.Effect => Effect.gen(function*(_) { - if (!runUp) { - return - } if (force) { yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)...")) yield* _(runDockerComposeDownVolumes(resolvedOutDir)) + yield* _(Effect.log("Running: docker compose up -d --build")) + yield* _(runDockerComposeUp(resolvedOutDir)) + return + } + if (forceEnv) { + yield* _(Effect.log("Force env enabled: resetting env defaults and recreating containers (volumes preserved)...")) + yield* _(runDockerComposeUpRecreate(resolvedOutDir)) + return } yield* _(Effect.log("Running: docker compose up -d --build")) yield* _(runDockerComposeUp(resolvedOutDir)) + }) - const ensureBridgeAccess = (containerName: string) => - runDockerInspectContainerBridgeIp(resolvedOutDir, containerName).pipe( - Effect.flatMap((bridgeIp) => - bridgeIp.length > 0 - ? Effect.void - : runDockerNetworkConnectBridge(resolvedOutDir, containerName) +const ensureContainerBridgeAccess = ( + resolvedOutDir: string, + containerName: string +): Effect.Effect => + runDockerInspectContainerBridgeIp(resolvedOutDir, containerName).pipe( + Effect.flatMap((bridgeIp) => + bridgeIp.length > 0 + ? Effect.void + : runDockerNetworkConnectBridge(resolvedOutDir, containerName) + ), + Effect.matchEffect({ + onFailure: (error) => + Effect.logWarning( + `Failed to connect ${containerName} to bridge network: ${ + error instanceof Error ? error.message : String(error) + }` ), - Effect.matchEffect({ - onFailure: (error) => - Effect.logWarning( - `Failed to connect ${containerName} to bridge network: ${ - error instanceof Error ? error.message : String(error) - }` - ), - onSuccess: () => Effect.void - }) - ) + onSuccess: () => Effect.void + }) + ) +const ensureBridgeAccess = ( + resolvedOutDir: string, + projectConfig: CreateCommand["config"] +): Effect.Effect => + Effect.gen(function*(_) { // Make container ports reachable from other (non-compose) containers by IP. - yield* _(ensureBridgeAccess(projectConfig.containerName)) + yield* _(ensureContainerBridgeAccess(resolvedOutDir, projectConfig.containerName)) if (projectConfig.enableMcpPlaywright) { - yield* _(ensureBridgeAccess(`${projectConfig.containerName}-browser`)) + yield* _(ensureContainerBridgeAccess(resolvedOutDir, `${projectConfig.containerName}-browser`)) + } + }) + +export const runDockerUpIfNeeded = ( + resolvedOutDir: string, + projectConfig: CreateCommand["config"], + options: DockerUpOptions +): Effect.Effect => + Effect.gen(function*(_) { + if (!options.runUp) { + return } + yield* _(runDockerComposeUpByMode(resolvedOutDir, options.force, options.forceEnv)) + yield* _(ensureBridgeAccess(resolvedOutDir, projectConfig)) - if (waitForClone) { + if (options.waitForClone) { yield* _(Effect.log("Streaming container logs until clone completes...")) yield* _(waitForCloneCompletion(resolvedOutDir, projectConfig)) } diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 4b21fb6a..5f2fbe6b 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -70,11 +70,24 @@ const ensureAuthorizedKeys = ( }) ) -const defaultEnvContents = "# docker-git env\n# KEY=value\n" +const defaultGlobalEnvContents = "# docker-git env\n# KEY=value\n" + +const defaultProjectEnvContents = [ + "# docker-git project env defaults", + "CODEX_SHARE_AUTH=1", + "CODEX_AUTO_UPDATE=1", + "DOCKER_GIT_ZSH_AUTOSUGGEST=1", + "DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic", + "DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion", + "MCP_PLAYWRIGHT_ISOLATED=1", + "" +].join("\n") const ensureEnvFile = ( baseDir: string, - envPath: string + envPath: string, + defaultContents: string, + overwrite: boolean = false ): Effect.Effect => withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { @@ -86,29 +99,43 @@ const ensureEnvFile = ( (_resolvedPath, backupPath) => `Env file was a directory, moved to ${backupPath}.` ) ) - if (state === "exists") { + if (state === "exists" && !overwrite) { return } yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) - yield* _(fs.writeFileString(resolved, defaultEnvContents)) + yield* _(fs.writeFileString(resolved, defaultContents)) }) ) export type PrepareProjectFilesError = FileExistsError | PlatformError +type PrepareProjectFilesOptions = { + readonly force: boolean + readonly forceEnv: boolean +} export const prepareProjectFiles = ( resolvedOutDir: string, baseDir: string, globalConfig: CreateCommand["config"], projectConfig: CreateCommand["config"], - force: boolean + options: PrepareProjectFilesOptions ): Effect.Effect, PrepareProjectFilesError, FileSystem.FileSystem | Path.Path> => Effect.gen(function*(_) { - const createdFiles = yield* _(writeProjectFiles(resolvedOutDir, projectConfig, force)) + const envOnlyRefresh = options.forceEnv && !options.force + const createdFiles = yield* _( + writeProjectFiles(resolvedOutDir, projectConfig, options.force, envOnlyRefresh) + ) yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath)) - yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath)) - yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envProjectPath)) + yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath, defaultGlobalEnvContents)) + yield* _( + ensureEnvFile( + resolvedOutDir, + projectConfig.envProjectPath, + defaultProjectEnvContents, + envOnlyRefresh + ) + ) yield* _(ensureCodexConfigFile(baseDir, globalConfig.codexAuthPath)) yield* _( syncAuthArtifacts({ diff --git a/packages/lib/src/usecases/auth-copy.ts b/packages/lib/src/usecases/auth-copy.ts new file mode 100644 index 00000000..543514ed --- /dev/null +++ b/packages/lib/src/usecases/auth-copy.ts @@ -0,0 +1,84 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +const copyDirRecursive = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const sourceInfo = yield* _(fs.stat(sourcePath)) + if (sourceInfo.type !== "Directory") { + return + } + yield* _(fs.makeDirectory(targetPath, { recursive: true })) + const entries = yield* _(fs.readDirectory(sourcePath)) + for (const entry of entries) { + const sourceEntry = path.join(sourcePath, entry) + const targetEntry = path.join(targetPath, entry) + const entryInfo = yield* _(fs.stat(sourceEntry)) + if (entryInfo.type === "Directory") { + yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry)) + } else if (entryInfo.type === "File") { + yield* _(fs.copyFile(sourceEntry, targetEntry)) + } + } + }) + +type CodexFileCopySpec = { + readonly sourceDir: string + readonly targetDir: string + readonly fileName: string + readonly label: string +} + +export const copyCodexFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + spec: CodexFileCopySpec +): Effect.Effect => + Effect.gen(function*(_) { + const sourceFile = path.join(spec.sourceDir, spec.fileName) + const targetFile = path.join(spec.targetDir, spec.fileName) + const sourceExists = yield* _(fs.exists(sourceFile)) + if (!sourceExists) { + return + } + const targetExists = yield* _(fs.exists(targetFile)) + if (targetExists) { + return + } + yield* _(fs.copyFile(sourceFile, targetFile)) + yield* _(Effect.log(`Copied Codex ${spec.label} from ${sourceFile} to ${targetFile}`)) + }) + +export const copyDirIfEmpty = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourceDir: string, + targetDir: string, + label: string +): Effect.Effect => + Effect.gen(function*(_) { + if (sourceDir === targetDir) { + return + } + const sourceExists = yield* _(fs.exists(sourceDir)) + if (!sourceExists) { + return + } + const sourceInfo = yield* _(fs.stat(sourceDir)) + if (sourceInfo.type !== "Directory") { + return + } + yield* _(fs.makeDirectory(targetDir, { recursive: true })) + const targetEntries = yield* _(fs.readDirectory(targetDir)) + if (targetEntries.length > 0) { + return + } + yield* _(copyDirRecursive(fs, path, sourceDir, targetDir)) + yield* _(Effect.log(`Copied ${label} from ${sourceDir} to ${targetDir}`)) + }) diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 0b801285..790ce51a 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -3,6 +3,7 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" +import { copyCodexFile, copyDirIfEmpty } from "./auth-copy.js" import { parseEnvEntries, removeEnvKey, upsertEnvKey } from "./env-file.js" import { withFsPathContext } from "./runtime.js" @@ -163,58 +164,6 @@ const copyFileIfNeeded = ( }) ) -const copyDirRecursive = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const sourceInfo = yield* _(fs.stat(sourcePath)) - if (sourceInfo.type !== "Directory") { - return - } - yield* _(fs.makeDirectory(targetPath, { recursive: true })) - const entries = yield* _(fs.readDirectory(sourcePath)) - for (const entry of entries) { - const sourceEntry = path.join(sourcePath, entry) - const targetEntry = path.join(targetPath, entry) - const entryInfo = yield* _(fs.stat(sourceEntry)) - if (entryInfo.type === "Directory") { - yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry)) - } else if (entryInfo.type === "File") { - yield* _(fs.copyFile(sourceEntry, targetEntry)) - } - } - }) - -type CodexFileCopySpec = { - readonly sourceDir: string - readonly targetDir: string - readonly fileName: string - readonly label: string -} - -const copyCodexFile = ( - fs: FileSystem.FileSystem, - path: Path.Path, - spec: CodexFileCopySpec -): Effect.Effect => - Effect.gen(function*(_) { - const sourceFile = path.join(spec.sourceDir, spec.fileName) - const targetFile = path.join(spec.targetDir, spec.fileName) - const sourceExists = yield* _(fs.exists(sourceFile)) - if (!sourceExists) { - return - } - const targetExists = yield* _(fs.exists(targetFile)) - if (targetExists) { - return - } - yield* _(fs.copyFile(sourceFile, targetFile)) - yield* _(Effect.log(`Copied Codex ${spec.label} from ${sourceFile} to ${targetFile}`)) - }) - // CHANGE: ensure Codex config exists with full-access defaults // WHY: enable all codex commands without extra prompts inside containers // QUOTE(ТЗ): "сразу настраивал полностью весь доступ ко всем командам" @@ -249,44 +198,6 @@ export const ensureCodexConfigFile = ( }) ) -const copyDirIfEmpty = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourceDir: string, - targetDir: string, - label: string -): Effect.Effect => - Effect.gen(function*(_) { - if (sourceDir === targetDir) { - return - } - const sourceExists = yield* _(fs.exists(sourceDir)) - if (!sourceExists) { - return - } - const sourceInfo = yield* _(fs.stat(sourceDir)) - if (sourceInfo.type !== "Directory") { - return - } - yield* _(fs.makeDirectory(targetDir, { recursive: true })) - const targetEntries = yield* _(fs.readDirectory(targetDir)) - if (targetEntries.length > 0) { - return - } - yield* _(copyDirRecursive(fs, path, sourceDir, targetDir)) - yield* _(Effect.log(`Copied ${label} from ${sourceDir} to ${targetDir}`)) - }) - -// CHANGE: sync shared auth artifacts into new project directory -// WHY: reuse global GH/Codex auth across containers automatically -// QUOTE(ТЗ): "автоматически всё копировали на наш контейнер? и gh тоже" -// REF: user-request-2026-01-29-auth-sync -// SOURCE: n/a -// FORMAT THEOREM: forall p: sync(p) -> env,codex_auth available(p) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: only copies when target is empty or placeholder -// COMPLEXITY: O(n) where n = |files| type AuthPaths = { readonly envGlobalPath: string readonly envProjectPath: string @@ -341,16 +252,6 @@ export const syncAuthArtifacts = ( }) ) -// CHANGE: migrate legacy .orch layout into the new .docker-git/.orch location -// WHY: keep all shared auth/config files under .docker-git by default -// QUOTE(ТЗ): "по умолчанию все конфиги хранились вместе ... .docker-git" -// REF: user-request-2026-01-29-orch-layout -// SOURCE: n/a -// FORMAT THEOREM: forall s: legacy(s) -> migrated(s) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: never overwrites existing non-empty targets -// COMPLEXITY: O(n) where n = |files| export const migrateLegacyOrchLayout = ( baseDir: string, envGlobalPath: string, diff --git a/packages/web/scripts/terminal-ws.mjs b/packages/web/scripts/terminal-ws.mjs index 203fba31..57c167b7 100644 --- a/packages/web/scripts/terminal-ws.mjs +++ b/packages/web/scripts/terminal-ws.mjs @@ -180,6 +180,7 @@ const runRecreateFlow = async (projectDir, send) => { outDir: projectDir, runUp: false, force: true, + forceEnv: false, waitForClone: false }) );