Skip to content

Commit 49564e1

Browse files
committed
feat(cli): apply docker-git config to existing project
1 parent 1488471 commit 49564e1

8 files changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Either } from "effect"
2+
3+
import { type ApplyCommand, type ParseError } from "@effect-template/lib/core/domain"
4+
5+
import { parseProjectDirWithOptions } from "./parser-shared.js"
6+
7+
// CHANGE: parse "apply" command for existing docker-git projects
8+
// WHY: update managed docker-git config on the current project/container without creating a new project
9+
// QUOTE(ТЗ): "Не создавать новый... а прямо в текущем обновить её на актуальную"
10+
// REF: issue-72-followup-apply-current-config
11+
// SOURCE: n/a
12+
// FORMAT THEOREM: forall argv: parseApply(argv) = cmd -> deterministic(cmd)
13+
// PURITY: CORE
14+
// EFFECT: Effect<ApplyCommand, ParseError, never>
15+
// INVARIANT: projectDir is never empty
16+
// COMPLEXITY: O(n) where n = |argv|
17+
export const parseApply = (
18+
args: ReadonlyArray<string>
19+
): Either.Either<ApplyCommand, ParseError> =>
20+
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
21+
_tag: "Apply",
22+
projectDir,
23+
runUp: raw.up ?? true
24+
}))

packages/app/src/docker-git/cli/parser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Either, Match } from "effect"
22

33
import { type Command, type ParseError } from "@effect-template/lib/core/domain"
44

5+
import { parseApply } from "./parser-apply.js"
56
import { parseAttach } from "./parser-attach.js"
67
import { parseAuth } from "./parser-auth.js"
78
import { parseClone } from "./parser-clone.js"
@@ -74,6 +75,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
7475
Match.when("auth", () => parseAuth(rest))
7576
)
7677
.pipe(
78+
Match.when("apply", () => parseApply(rest)),
7779
Match.when("state", () => parseState(rest)),
7880
Match.orElse(() => Either.left(unknownCommandError))
7981
)

packages/app/src/docker-git/cli/usage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ParseError } from "@effect-template/lib/core/domain"
55
export const usageText = `docker-git menu
66
docker-git create --repo-url <url> [options]
77
docker-git clone <url> [options]
8+
docker-git apply [<url>] [options]
89
docker-git mcp-playwright [<url>] [options]
910
docker-git attach [<url>] [options]
1011
docker-git panes [<url>] [options]
@@ -21,6 +22,7 @@ Commands:
2122
menu Interactive menu (default when no args)
2223
create, init Generate docker development environment
2324
clone Create + run container and clone repo
25+
apply Apply docker-git config to an existing project/container (current dir by default)
2426
mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir
2527
attach, tmux Open tmux workspace for a docker-git project
2628
panes, terms List tmux panes for a docker-git project

packages/app/src/docker-git/program.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Command, ParseError } from "@effect-template/lib/core/domain"
22
import { createProject } from "@effect-template/lib/usecases/actions"
3+
import { applyProjectConfig } from "@effect-template/lib/usecases/apply"
34
import {
45
authClaudeLogin,
56
authClaudeLogout,
@@ -97,6 +98,7 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
9798
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd))
9899
)
99100
.pipe(
101+
Match.when({ _tag: "Apply" }, (cmd) => applyProjectConfig(cmd)),
100102
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
101103
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
102104
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)),

packages/app/tests/docker-git/parser.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,34 @@ describe("parseArgs", () => {
196196
expect(command.projectDir).toBe(".docker-git/org/repo")
197197
}))
198198

199+
it.effect("parses apply command in current directory", () =>
200+
Effect.sync(() => {
201+
const command = parseOrThrow(["apply"])
202+
if (command._tag !== "Apply") {
203+
throw new Error("expected Apply command")
204+
}
205+
expect(command.projectDir).toBe(".")
206+
expect(command.runUp).toBe(true)
207+
}))
208+
209+
it.effect("parses apply command with --no-up", () =>
210+
Effect.sync(() => {
211+
const command = parseOrThrow(["apply", "--no-up"])
212+
if (command._tag !== "Apply") {
213+
throw new Error("expected Apply command")
214+
}
215+
expect(command.runUp).toBe(false)
216+
}))
217+
218+
it.effect("parses apply with positional repo url into project dir", () =>
219+
Effect.sync(() => {
220+
const command = parseOrThrow(["apply", "https://github.com/org/repo.git"])
221+
if (command._tag !== "Apply") {
222+
throw new Error("expected Apply command")
223+
}
224+
expect(command.projectDir).toBe(".docker-git/org/repo")
225+
}))
226+
199227
it.effect("parses down-all command", () =>
200228
Effect.sync(() => {
201229
const command = parseOrThrow(["down-all"])

packages/lib/src/core/domain.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ export interface McpPlaywrightUpCommand {
106106
readonly runUp: boolean
107107
}
108108

109+
export interface ApplyCommand {
110+
readonly _tag: "Apply"
111+
readonly projectDir: string
112+
readonly runUp: boolean
113+
}
114+
109115
export interface HelpCommand {
110116
readonly _tag: "Help"
111117
readonly message: string
@@ -243,6 +249,7 @@ export type Command =
243249
| SessionsCommand
244250
| ScrapCommand
245251
| McpPlaywrightUpCommand
252+
| ApplyCommand
246253
| HelpCommand
247254
| StatusCommand
248255
| DownAllCommand

packages/lib/src/usecases/apply.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { CommandExecutor } from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import type { FileSystem } from "@effect/platform/FileSystem"
4+
import type { Path } from "@effect/platform/Path"
5+
import { Effect } from "effect"
6+
7+
import type { ApplyCommand, TemplateConfig } from "../core/domain.js"
8+
import { readProjectConfig } from "../shell/config.js"
9+
import { ensureDockerDaemonAccess } from "../shell/docker.js"
10+
import type * as ShellErrors from "../shell/errors.js"
11+
import { writeProjectFiles } from "../shell/files.js"
12+
import { ensureCodexConfigFile } from "./auth-sync.js"
13+
import { runDockerComposeUpWithPortCheck } from "./projects-up.js"
14+
15+
type ApplyProjectFilesError =
16+
| ShellErrors.ConfigNotFoundError
17+
| ShellErrors.ConfigDecodeError
18+
| ShellErrors.FileExistsError
19+
| PlatformError
20+
type ApplyProjectFilesEnv = FileSystem | Path
21+
22+
// CHANGE: apply existing docker-git.json to managed files in an already created project
23+
// WHY: allow updating current project/container config without creating a new project directory
24+
// QUOTE(ТЗ): "Не создавать новый... а прямо в текущем обновить её на актуальную"
25+
// REF: issue-72-followup-apply-current-config
26+
// SOURCE: n/a
27+
// FORMAT THEOREM: forall p: apply_files(p) -> files(p) = plan(read_config(p))
28+
// PURITY: SHELL
29+
// EFFECT: Effect<TemplateConfig, ConfigNotFoundError | ConfigDecodeError | FileExistsError | PlatformError, FileSystem | Path>
30+
// INVARIANT: rewrites only managed files from docker-git.json
31+
// COMPLEXITY: O(n) where n = |managed_files|
32+
export const applyProjectFiles = (
33+
projectDir: string
34+
): Effect.Effect<TemplateConfig, ApplyProjectFilesError, ApplyProjectFilesEnv> =>
35+
Effect.gen(function*(_) {
36+
yield* _(Effect.log(`Applying docker-git config files in ${projectDir}...`))
37+
const config = yield* _(readProjectConfig(projectDir))
38+
yield* _(writeProjectFiles(projectDir, config.template, true))
39+
yield* _(ensureCodexConfigFile(projectDir, config.template.codexAuthPath))
40+
return config.template
41+
})
42+
43+
export type ApplyProjectConfigError =
44+
| ApplyProjectFilesError
45+
| ShellErrors.DockerAccessError
46+
| ShellErrors.DockerCommandError
47+
| ShellErrors.PortProbeError
48+
49+
type ApplyProjectConfigEnv = ApplyProjectFilesEnv | CommandExecutor
50+
51+
const applyProjectWithUp = (
52+
projectDir: string
53+
): Effect.Effect<TemplateConfig, ApplyProjectConfigError, ApplyProjectConfigEnv> =>
54+
Effect.gen(function*(_) {
55+
yield* _(Effect.log(`Applying docker-git config and refreshing container in ${projectDir}...`))
56+
yield* _(ensureDockerDaemonAccess(process.cwd()))
57+
return yield* _(runDockerComposeUpWithPortCheck(projectDir))
58+
})
59+
60+
// CHANGE: add command handler to apply docker-git config on an existing project
61+
// WHY: update current project/container config without running create/clone again
62+
// QUOTE(ТЗ): "Не создавать новый... а прямо в текущем обновить её на актуальную"
63+
// REF: issue-72-followup-apply-current-config
64+
// SOURCE: n/a
65+
// FORMAT THEOREM: forall c: apply(c) -> updated(project(c)) && (c.runUp -> container_refreshed(c))
66+
// PURITY: SHELL
67+
// EFFECT: Effect<TemplateConfig, ApplyProjectConfigError, FileSystem | Path | CommandExecutor>
68+
// INVARIANT: project path remains unchanged; command only updates managed artifacts
69+
// COMPLEXITY: O(n) + O(command)
70+
export const applyProjectConfig = (
71+
command: ApplyCommand
72+
): Effect.Effect<TemplateConfig, ApplyProjectConfigError, ApplyProjectConfigEnv> =>
73+
command.runUp
74+
? applyProjectWithUp(command.projectDir)
75+
: applyProjectFiles(command.projectDir)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import * as FileSystem from "@effect/platform/FileSystem"
2+
import * as Path from "@effect/platform/Path"
3+
import { NodeContext } from "@effect/platform-node"
4+
import { describe, expect, it } from "@effect/vitest"
5+
import { Effect } from "effect"
6+
7+
import type { TemplateConfig } from "../../src/core/domain.js"
8+
import { applyProjectFiles } from "../../src/usecases/apply.js"
9+
import { prepareProjectFiles } from "../../src/usecases/actions/prepare-files.js"
10+
11+
const withTempDir = <A, E, R>(
12+
use: (tempDir: string) => Effect.Effect<A, E, R>
13+
): Effect.Effect<A, E, R | FileSystem.FileSystem> =>
14+
Effect.scoped(
15+
Effect.gen(function*(_) {
16+
const fs = yield* _(FileSystem.FileSystem)
17+
const tempDir = yield* _(
18+
fs.makeTempDirectoryScoped({
19+
prefix: "docker-git-apply-config-"
20+
})
21+
)
22+
return yield* _(use(tempDir))
23+
})
24+
)
25+
26+
const makeTemplateConfig = (
27+
root: string,
28+
outDir: string,
29+
path: Path.Path,
30+
targetDir: string
31+
): TemplateConfig => ({
32+
containerName: "dg-test",
33+
serviceName: "dg-test",
34+
sshUser: "dev",
35+
sshPort: 2222,
36+
repoUrl: "https://github.com/org/repo.git",
37+
repoRef: "main",
38+
targetDir,
39+
volumeName: "dg-test-home",
40+
dockerGitPath: path.join(root, ".docker-git"),
41+
authorizedKeysPath: path.join(root, "authorized_keys"),
42+
envGlobalPath: path.join(root, ".orch/env/global.env"),
43+
envProjectPath: path.join(outDir, ".orch/env/project.env"),
44+
codexAuthPath: path.join(root, ".orch/auth/codex"),
45+
codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"),
46+
codexHome: "/home/dev/.codex",
47+
enableMcpPlaywright: false,
48+
pnpmVersion: "10.27.0"
49+
})
50+
51+
const isRecord = (value: unknown): value is Record<string, unknown> =>
52+
typeof value === "object" && value !== null
53+
54+
const rewriteTargetDirInConfig = (source: string, targetDir: string): string => {
55+
const parsed: unknown = JSON.parse(source)
56+
if (!isRecord(parsed)) {
57+
throw new Error("invalid docker-git.json root")
58+
}
59+
const template = parsed["template"]
60+
if (!isRecord(template)) {
61+
throw new Error("invalid docker-git.json template")
62+
}
63+
const next = { ...parsed, template: { ...template, targetDir } }
64+
return `${JSON.stringify(next, null, 2)}\n`
65+
}
66+
67+
describe("applyProjectFiles", () => {
68+
it.effect("applies updated docker-git.json to managed files in existing project", () =>
69+
withTempDir((root) =>
70+
Effect.gen(function*(_) {
71+
const fs = yield* _(FileSystem.FileSystem)
72+
const path = yield* _(Path.Path)
73+
const outDir = path.join(root, "project")
74+
const initialTargetDir = "/home/dev/workspaces/org/repo"
75+
const updatedTargetDir = "/home/dev/workspaces/org/repo-updated"
76+
const globalConfig = makeTemplateConfig(root, outDir, path, initialTargetDir)
77+
const projectConfig = makeTemplateConfig(root, outDir, path, initialTargetDir)
78+
79+
yield* _(
80+
prepareProjectFiles(outDir, root, globalConfig, projectConfig, {
81+
force: false,
82+
forceEnv: false
83+
})
84+
)
85+
86+
const envProjectPath = path.join(outDir, ".orch/env/project.env")
87+
yield* _(fs.writeFileString(envProjectPath, "# custom env\nCUSTOM_KEY=1\n"))
88+
89+
const configPath = path.join(outDir, "docker-git.json")
90+
const configBefore = yield* _(fs.readFileString(configPath))
91+
yield* _(fs.writeFileString(configPath, rewriteTargetDirInConfig(configBefore, updatedTargetDir)))
92+
93+
const appliedTemplate = yield* _(applyProjectFiles(outDir))
94+
expect(appliedTemplate.targetDir).toBe(updatedTargetDir)
95+
96+
const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml")))
97+
expect(composeAfter).toContain(`TARGET_DIR: "${updatedTargetDir}"`)
98+
99+
const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile")))
100+
expect(dockerfileAfter).toContain(`RUN mkdir -p ${updatedTargetDir}`)
101+
102+
const envAfter = yield* _(fs.readFileString(envProjectPath))
103+
expect(envAfter).toContain("CUSTOM_KEY=1")
104+
})
105+
).pipe(Effect.provide(NodeContext.layer)))
106+
})

0 commit comments

Comments
 (0)