Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions packages/lib/src/shell/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,27 @@ export const runDockerComposeUp = (
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
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<string> = [
"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<void, DockerCommandError | PlatformError, CommandExecutor>
// 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<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
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
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/usecases/actions/prepare-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ export const prepareProjectFiles = (
options: PrepareProjectFilesOptions
): Effect.Effect<ReadonlyArray<string>, 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))
Expand Down
9 changes: 9 additions & 0 deletions packages/lib/tests/shell/docker.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
103 changes: 103 additions & 0 deletions packages/lib/tests/usecases/prepare-files.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <A, E, R>(use: (tempDir: string) => Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
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)))
})