diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index d50efe9f..cfc299db 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -64,20 +64,27 @@ 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 +export const dockerComposeUpRecreateArgs: ReadonlyArray = [ + "up", + "-d", + "--build", + "--force-recreate" +] + +// CHANGE: recreate running containers and refresh images when needed +// WHY: apply env/template updates while preserving workspace volumes // 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)) +// FORMAT THEOREM: ∀dir: up_force_recreate(dir) → recreated(containers(dir)) ∧ preserved(volumes(dir)) ∧ updated(images(dir)) // PURITY: SHELL // EFFECT: Effect -// INVARIANT: does not invoke image build and does not remove volumes +// INVARIANT: may rebuild images but does not remove volumes // COMPLEXITY: O(command) export const runDockerComposeUpRecreate = ( cwd: string ): Effect.Effect => - runCompose(cwd, ["up", "-d", "--force-recreate"], [Number(ExitCode(0))]) + runCompose(cwd, dockerComposeUpRecreateArgs, [Number(ExitCode(0))]) // CHANGE: run docker compose down in the target directory // WHY: allow stopping managed containers from the CLI/menu diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 5f2fbe6b..9b8d8cca 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -122,9 +122,10 @@ export const prepareProjectFiles = ( options: PrepareProjectFilesOptions ): Effect.Effect, PrepareProjectFilesError, FileSystem.FileSystem | Path.Path> => Effect.gen(function*(_) { + const rewriteManagedFiles = options.force || options.forceEnv const envOnlyRefresh = options.forceEnv && !options.force const createdFiles = yield* _( - writeProjectFiles(resolvedOutDir, projectConfig, options.force, envOnlyRefresh) + writeProjectFiles(resolvedOutDir, projectConfig, rewriteManagedFiles) ) yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath)) yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath, defaultGlobalEnvContents)) diff --git a/packages/lib/tests/shell/docker.test.ts b/packages/lib/tests/shell/docker.test.ts new file mode 100644 index 00000000..bc3c49fd --- /dev/null +++ b/packages/lib/tests/shell/docker.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "@effect/vitest" + +import { dockerComposeUpRecreateArgs } from "../../src/shell/docker.js" + +describe("docker compose args", () => { + it("uses build when force-env recreates containers", () => { + expect(dockerComposeUpRecreateArgs).toEqual(["up", "-d", "--build", "--force-recreate"]) + }) +}) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts new file mode 100644 index 00000000..e34bd913 --- /dev/null +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -0,0 +1,103 @@ +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../src/core/domain.js" +import { prepareProjectFiles } from "../../src/usecases/actions/prepare-files.js" + +const withTempDir = (use: (tempDir: string) => Effect.Effect): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const tempDir = yield* _( + Effect.acquireRelease( + Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "docker-git-force-env-"))), + (dir) => Effect.sync(() => fs.rmSync(dir, { recursive: true, force: true })) + ) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeGlobalConfig = (root: string): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" +}) + +const makeProjectConfig = (outDir: string, enableMcpPlaywright: boolean): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + authorizedKeysPath: path.join(outDir, "authorized_keys"), + envGlobalPath: path.join(outDir, ".orch/env/global.env"), + envProjectPath: path.join(outDir, ".orch/env/project.env"), + codexAuthPath: path.join(outDir, ".orch/auth/codex"), + codexSharedAuthPath: path.join(outDir, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + enableMcpPlaywright, + pnpmVersion: "10.27.0" +}) + +describe("prepareProjectFiles", () => { + it.effect("force-env refresh rewrites managed templates", () => + withTempDir((root) => + Effect.gen(function*(_) { + const outDir = path.join(root, "project") + const globalConfig = makeGlobalConfig(root) + const withoutMcp = makeProjectConfig(outDir, false) + const withMcp = makeProjectConfig(outDir, true) + + yield* _( + prepareProjectFiles(outDir, root, globalConfig, withoutMcp, { + force: false, + forceEnv: false + }) + ) + + const composeBefore = yield* _( + Effect.sync(() => fs.readFileSync(path.join(outDir, "docker-compose.yml"), "utf8")) + ) + expect(composeBefore).not.toContain("dg-test-browser") + + yield* _( + prepareProjectFiles(outDir, root, globalConfig, withMcp, { + force: false, + forceEnv: true + }) + ) + + const composeAfter = yield* _( + Effect.sync(() => fs.readFileSync(path.join(outDir, "docker-compose.yml"), "utf8")) + ) + const configAfter = yield* _( + Effect.sync(() => JSON.parse(fs.readFileSync(path.join(outDir, "docker-git.json"), "utf8"))) + ) + + expect(composeAfter).toContain("dg-test-browser") + expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"') + expect(configAfter.template.enableMcpPlaywright).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +})