@prover-coder-ai/docker-git 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.jscpd.json +16 -0
  2. package/.package.json.release.bak +109 -0
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +173 -0
  5. package/biome.json +34 -0
  6. package/dist/main.js +847 -0
  7. package/dist/main.js.map +1 -0
  8. package/dist/src/app/main.js +15 -0
  9. package/dist/src/app/program.js +61 -0
  10. package/dist/src/docker-git/cli/input.js +21 -0
  11. package/dist/src/docker-git/cli/parser-attach.js +19 -0
  12. package/dist/src/docker-git/cli/parser-auth.js +70 -0
  13. package/dist/src/docker-git/cli/parser-clone.js +40 -0
  14. package/dist/src/docker-git/cli/parser-create.js +1 -0
  15. package/dist/src/docker-git/cli/parser-options.js +101 -0
  16. package/dist/src/docker-git/cli/parser-panes.js +19 -0
  17. package/dist/src/docker-git/cli/parser-sessions.js +69 -0
  18. package/dist/src/docker-git/cli/parser-shared.js +26 -0
  19. package/dist/src/docker-git/cli/parser-state.js +62 -0
  20. package/dist/src/docker-git/cli/parser.js +42 -0
  21. package/dist/src/docker-git/cli/read-command.js +17 -0
  22. package/dist/src/docker-git/cli/usage.js +99 -0
  23. package/dist/src/docker-git/main.js +15 -0
  24. package/dist/src/docker-git/menu-actions.js +115 -0
  25. package/dist/src/docker-git/menu-create.js +203 -0
  26. package/dist/src/docker-git/menu-input.js +2 -0
  27. package/dist/src/docker-git/menu-menu.js +46 -0
  28. package/dist/src/docker-git/menu-render.js +151 -0
  29. package/dist/src/docker-git/menu-select.js +131 -0
  30. package/dist/src/docker-git/menu-shared.js +111 -0
  31. package/dist/src/docker-git/menu-types.js +19 -0
  32. package/dist/src/docker-git/menu.js +237 -0
  33. package/dist/src/docker-git/program.js +38 -0
  34. package/dist/src/docker-git/tmux.js +176 -0
  35. package/eslint.config.mts +305 -0
  36. package/eslint.effect-ts-check.config.mjs +220 -0
  37. package/linter.config.json +33 -0
  38. package/package.json +63 -0
  39. package/src/app/main.ts +18 -0
  40. package/src/app/program.ts +75 -0
  41. package/src/docker-git/cli/input.ts +29 -0
  42. package/src/docker-git/cli/parser-attach.ts +22 -0
  43. package/src/docker-git/cli/parser-auth.ts +124 -0
  44. package/src/docker-git/cli/parser-clone.ts +55 -0
  45. package/src/docker-git/cli/parser-create.ts +3 -0
  46. package/src/docker-git/cli/parser-options.ts +152 -0
  47. package/src/docker-git/cli/parser-panes.ts +22 -0
  48. package/src/docker-git/cli/parser-sessions.ts +101 -0
  49. package/src/docker-git/cli/parser-shared.ts +51 -0
  50. package/src/docker-git/cli/parser-state.ts +86 -0
  51. package/src/docker-git/cli/parser.ts +73 -0
  52. package/src/docker-git/cli/read-command.ts +26 -0
  53. package/src/docker-git/cli/usage.ts +112 -0
  54. package/src/docker-git/main.ts +18 -0
  55. package/src/docker-git/menu-actions.ts +246 -0
  56. package/src/docker-git/menu-create.ts +320 -0
  57. package/src/docker-git/menu-input.ts +2 -0
  58. package/src/docker-git/menu-menu.ts +58 -0
  59. package/src/docker-git/menu-render.ts +327 -0
  60. package/src/docker-git/menu-select.ts +250 -0
  61. package/src/docker-git/menu-shared.ts +141 -0
  62. package/src/docker-git/menu-types.ts +94 -0
  63. package/src/docker-git/menu.ts +339 -0
  64. package/src/docker-git/program.ts +134 -0
  65. package/src/docker-git/tmux.ts +292 -0
  66. package/tests/app/main.test.ts +60 -0
  67. package/tests/docker-git/entrypoint-auth.test.ts +29 -0
  68. package/tests/docker-git/parser.test.ts +172 -0
  69. package/tsconfig.build.json +8 -0
  70. package/tsconfig.json +20 -0
  71. package/vite.config.ts +32 -0
  72. package/vitest.config.ts +85 -0
@@ -0,0 +1,292 @@
1
+ import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2
+ import type { PlatformError } from "@effect/platform/Error"
3
+ import type * as FileSystem from "@effect/platform/FileSystem"
4
+ import type * as Path from "@effect/platform/Path"
5
+ import { Effect, pipe } from "effect"
6
+
7
+ import type { AttachCommand, PanesCommand } from "@effect-template/lib/core/domain"
8
+ import { deriveRepoPathParts, deriveRepoSlug } from "@effect-template/lib/core/domain"
9
+ import {
10
+ runCommandCapture,
11
+ runCommandExitCode,
12
+ runCommandWithExitCodes
13
+ } from "@effect-template/lib/shell/command-runner"
14
+ import { readProjectConfig } from "@effect-template/lib/shell/config"
15
+ import type {
16
+ ConfigDecodeError,
17
+ ConfigNotFoundError,
18
+ DockerCommandError,
19
+ FileExistsError,
20
+ PortProbeError
21
+ } from "@effect-template/lib/shell/errors"
22
+ import { CommandFailedError } from "@effect-template/lib/shell/errors"
23
+ import { resolveBaseDir } from "@effect-template/lib/shell/paths"
24
+ import { findSshPrivateKey } from "@effect-template/lib/usecases/path-helpers"
25
+ import { buildSshCommand } from "@effect-template/lib/usecases/projects"
26
+ import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
27
+
28
+ const tmuxOk = [0]
29
+ const layoutVersion = "v14"
30
+
31
+ const makeTmuxSpec = (args: ReadonlyArray<string>) => ({
32
+ cwd: process.cwd(),
33
+ command: "tmux",
34
+ args
35
+ })
36
+
37
+ const runTmux = (
38
+ args: ReadonlyArray<string>
39
+ ): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
40
+ runCommandWithExitCodes(
41
+ makeTmuxSpec(args),
42
+ tmuxOk,
43
+ (exitCode) => new CommandFailedError({ command: "tmux", exitCode })
44
+ )
45
+
46
+ const runTmuxExitCode = (
47
+ args: ReadonlyArray<string>
48
+ ): Effect.Effect<number, PlatformError, CommandExecutor.CommandExecutor> => runCommandExitCode(makeTmuxSpec(args))
49
+
50
+ const runTmuxCapture = (
51
+ args: ReadonlyArray<string>
52
+ ): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
53
+ runCommandCapture(
54
+ makeTmuxSpec(args),
55
+ tmuxOk,
56
+ (exitCode) => new CommandFailedError({ command: "tmux", exitCode })
57
+ )
58
+
59
+ const sendKeys = (
60
+ session: string,
61
+ pane: string,
62
+ text: string
63
+ ): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
64
+ pipe(
65
+ runTmux(["send-keys", "-t", `${session}:0.${pane}`, "-l", text]),
66
+ Effect.zipRight(runTmux(["send-keys", "-t", `${session}:0.${pane}`, "C-m"]))
67
+ )
68
+
69
+ const shellEscape = (value: string): string => {
70
+ if (value.length === 0) {
71
+ return "''"
72
+ }
73
+ if (!/[^\w@%+=:,./-]/.test(value)) {
74
+ return value
75
+ }
76
+ const escaped = value.replaceAll("'", "'\"'\"'")
77
+ return `'${escaped}'`
78
+ }
79
+
80
+ const wrapBash = (command: string): string => `bash -lc ${shellEscape(command)}`
81
+
82
+ const buildJobsCommand = (containerName: string): string =>
83
+ [
84
+ "while true; do",
85
+ "clear",
86
+ "echo \"LIVE TERMINALS / JOBS (container, refresh 1s)\"",
87
+ "echo \"\"",
88
+ `docker exec ${containerName} ps -eo pid,tty,cmd,etime --sort=start_time 2>/dev/null | awk 'NR==1 {print; next} $2 != "?" && $3 !~ /(sshd|^-?bash$|^bash$|^sh$|^zsh$|^fish$)/ {print; found=1} END { if (!found) print "(no interactive jobs)" }'`,
89
+ "|| echo \"container not running\"",
90
+ "sleep 1",
91
+ "done"
92
+ ].join("; ")
93
+
94
+ const readLayoutVersion = (
95
+ session: string
96
+ ): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
97
+ runTmuxCapture(["show-options", "-t", session, "-v", "@docker-git-layout"]).pipe(
98
+ Effect.map((value) => value.trim()),
99
+ Effect.catchTag("CommandFailedError", () => Effect.succeed(null))
100
+ )
101
+
102
+ const buildBottomBarCommand = (): string =>
103
+ [
104
+ "clear",
105
+ "echo \"[Focus: Alt+1/2/3] [Select: Alt+s] [Detach: Alt+d]\"",
106
+ "echo \"Tip: Mouse click = focus pane, Ctrl+a z = zoom\"",
107
+ "while true; do sleep 3600; done"
108
+ ].join("; ")
109
+
110
+ const formatRepoRefLabel = (repoRef: string): string => {
111
+ const match = /refs\/pull\/(\d+)\/head/.exec(repoRef)
112
+ const pr = match?.[1]
113
+ return pr ? `PR#${pr}` : repoRef
114
+ }
115
+
116
+ const formatRepoDisplayName = (repoUrl: string): string => {
117
+ const parts = deriveRepoPathParts(repoUrl)
118
+ return parts.pathParts.length > 0 ? parts.pathParts.join("/") : repoUrl
119
+ }
120
+
121
+ type PaneRow = {
122
+ readonly id: string
123
+ readonly window: string
124
+ readonly title: string
125
+ readonly command: string
126
+ }
127
+
128
+ const normalizePaneCell = (value: string | undefined): string => value?.trim() ?? "-"
129
+
130
+ const parsePaneRow = (line: string): PaneRow => {
131
+ const [id, window, title, command] = line.split("\t")
132
+ return {
133
+ id: normalizePaneCell(id),
134
+ window: normalizePaneCell(window),
135
+ title: normalizePaneCell(title),
136
+ command: normalizePaneCell(command)
137
+ }
138
+ }
139
+
140
+ const renderPaneRow = (row: PaneRow): string =>
141
+ `- ${row.id} ${row.window} ${row.title === "-" ? row.command : row.title} ${row.command}`
142
+
143
+ const configureSession = (
144
+ session: string,
145
+ repoDisplayName: string,
146
+ statusRight: string
147
+ ): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
148
+ Effect.gen(function*(_) {
149
+ yield* _(runTmux(["set-option", "-t", session, "@docker-git-layout", layoutVersion]))
150
+ yield* _(runTmux(["set-option", "-t", session, "window-size", "largest"]))
151
+ yield* _(runTmux(["set-option", "-t", session, "aggressive-resize", "on"]))
152
+ yield* _(runTmux(["set-option", "-t", session, "mouse", "on"]))
153
+ yield* _(runTmux(["set-option", "-t", session, "focus-events", "on"]))
154
+ yield* _(runTmux(["set-option", "-t", session, "prefix", "C-a"]))
155
+ yield* _(runTmux(["unbind-key", "C-b"]))
156
+ yield* _(runTmux(["set-option", "-t", session, "status", "on"]))
157
+ yield* _(runTmux(["set-option", "-t", session, "status-position", "top"]))
158
+ yield* _(runTmux(["set-option", "-t", session, "status-left", ` docker-git :: ${repoDisplayName} `]))
159
+ yield* _(runTmux(["set-option", "-t", session, "status-right", ` ${statusRight} `]))
160
+ })
161
+
162
+ const createLayout = (
163
+ session: string
164
+ ): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
165
+ Effect.gen(function*(_) {
166
+ yield* _(runTmux(["new-session", "-d", "-s", session, "-n", "main"]))
167
+ yield* _(runTmux(["split-window", "-v", "-p", "12", "-t", `${session}:0`]))
168
+ yield* _(runTmux(["split-window", "-h", "-p", "35", "-t", `${session}:0.0`]))
169
+ })
170
+
171
+ const setupPanes = (
172
+ session: string,
173
+ sshCommand: string,
174
+ containerName: string
175
+ ): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
176
+ Effect.gen(function*(_) {
177
+ const leftPane = "0"
178
+ const bottomPane = "1"
179
+ const rightPane = "2"
180
+ yield* _(sendKeys(session, leftPane, sshCommand))
181
+ yield* _(sendKeys(session, rightPane, wrapBash(buildJobsCommand(containerName))))
182
+ yield* _(sendKeys(session, bottomPane, wrapBash(buildBottomBarCommand())))
183
+ yield* _(runTmux(["bind-key", "-n", "M-1", "select-pane", "-t", `${session}:0.${leftPane}`]))
184
+ yield* _(runTmux(["bind-key", "-n", "M-2", "select-pane", "-t", `${session}:0.${rightPane}`]))
185
+ yield* _(runTmux(["bind-key", "-n", "M-3", "select-pane", "-t", `${session}:0.${bottomPane}`]))
186
+ yield* _(runTmux(["bind-key", "-n", "M-d", "detach-client"]))
187
+ yield* _(runTmux(["bind-key", "-n", "M-s", "choose-tree", "-Z"]))
188
+ yield* _(runTmux(["select-pane", "-t", `${session}:0.${leftPane}`]))
189
+ })
190
+
191
+ // CHANGE: list tmux panes for a docker-git project
192
+ // WHY: allow non-interactive inspection of terminal panes (CI/automation friendly)
193
+ // QUOTE(ТЗ): "сделай команду ... которая отобразит терминалы в докере"
194
+ // REF: user-request-2026-02-02-panes
195
+ // SOURCE: n/a
196
+ // FORMAT THEOREM: forall p: panes(p) -> deterministic output
197
+ // PURITY: SHELL
198
+ // EFFECT: Effect<void, CommandFailedError | ConfigNotFoundError | ConfigDecodeError | PlatformError, CommandExecutor | FileSystem | Path>
199
+ // INVARIANT: session name is deterministic from repo url
200
+ // COMPLEXITY: O(n) where n = number of panes
201
+ export const listTmuxPanes = (
202
+ command: PanesCommand
203
+ ): Effect.Effect<
204
+ void,
205
+ CommandFailedError | ConfigNotFoundError | ConfigDecodeError | PlatformError,
206
+ CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path
207
+ > =>
208
+ Effect.gen(function*(_) {
209
+ const { resolved } = yield* _(resolveBaseDir(command.projectDir))
210
+ const config = yield* _(readProjectConfig(resolved))
211
+ const session = `dg-${deriveRepoSlug(config.template.repoUrl)}`
212
+ const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session]))
213
+ if (hasSessionCode !== 0) {
214
+ yield* _(Effect.logWarning(`tmux session ${session} not found. Run 'docker-git attach' first.`))
215
+ return
216
+ }
217
+ const raw = yield* _(
218
+ runTmuxCapture([
219
+ "list-panes",
220
+ "-s",
221
+ "-t",
222
+ session,
223
+ "-F",
224
+ "#{pane_id}\t#{window_name}\t#{pane_title}\t#{pane_current_command}"
225
+ ])
226
+ )
227
+ const lines = raw
228
+ .split(/\r?\n/)
229
+ .map((line) => line.trimEnd())
230
+ .filter((line) => line.length > 0)
231
+ const rows = lines.map((line) => parsePaneRow(line))
232
+ yield* _(Effect.log(`Project: ${resolved}`))
233
+ yield* _(Effect.log(`Session: ${session}`))
234
+ if (rows.length === 0) {
235
+ yield* _(Effect.log("No panes found."))
236
+ return
237
+ }
238
+ for (const row of rows) {
239
+ yield* _(Effect.log(renderPaneRow(row)))
240
+ }
241
+ })
242
+
243
+ // CHANGE: attach a tmux workspace for a docker-git project
244
+ // WHY: provide multi-pane terminal layout for sandbox work
245
+ // QUOTE(ТЗ): "окей Давай подключим tmux"
246
+ // REF: user-request-2026-02-02-tmux
247
+ // SOURCE: n/a
248
+ // FORMAT THEOREM: forall p: attach(p) -> tmux(p)
249
+ // PURITY: SHELL
250
+ // EFFECT: Effect<void, CommandFailedError | DockerCommandError | ConfigNotFoundError | ConfigDecodeError | FileExistsError | PortProbeError | PlatformError, CommandExecutor | FileSystem | Path>
251
+ // INVARIANT: tmux session name is deterministic from repo url
252
+ // COMPLEXITY: O(1)
253
+ export const attachTmux = (
254
+ command: AttachCommand
255
+ ): Effect.Effect<
256
+ void,
257
+ | CommandFailedError
258
+ | DockerCommandError
259
+ | ConfigNotFoundError
260
+ | ConfigDecodeError
261
+ | FileExistsError
262
+ | PortProbeError
263
+ | PlatformError,
264
+ CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path
265
+ > =>
266
+ Effect.gen(function*(_) {
267
+ const { fs, path, resolved } = yield* _(resolveBaseDir(command.projectDir))
268
+ const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
269
+ const template = yield* _(runDockerComposeUpWithPortCheck(resolved))
270
+ const sshCommand = buildSshCommand(template, sshKey)
271
+ const repoDisplayName = formatRepoDisplayName(template.repoUrl)
272
+ const refLabel = formatRepoRefLabel(template.repoRef)
273
+ const statusRight =
274
+ `SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running`
275
+ const session = `dg-${deriveRepoSlug(template.repoUrl)}`
276
+ const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session]))
277
+
278
+ if (hasSessionCode === 0) {
279
+ const existingLayout = yield* _(readLayoutVersion(session))
280
+ if (existingLayout === layoutVersion) {
281
+ yield* _(runTmux(["attach", "-t", session]))
282
+ return
283
+ }
284
+ yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`))
285
+ yield* _(runTmux(["kill-session", "-t", session]))
286
+ }
287
+
288
+ yield* _(createLayout(session))
289
+ yield* _(configureSession(session, repoDisplayName, statusRight))
290
+ yield* _(setupPanes(session, sshCommand, template.containerName))
291
+ yield* _(runTmux(["attach", "-t", session]))
292
+ })
@@ -0,0 +1,60 @@
1
+ import { NodeContext } from "@effect/platform-node"
2
+ import { describe, expect, it } from "@effect/vitest"
3
+ import { Effect, pipe } from "effect"
4
+ import { vi } from "vitest"
5
+
6
+ import { program } from "../../src/app/program.js"
7
+
8
+ const withLogSpy = Effect.acquireRelease(
9
+ Effect.sync(() => vi.spyOn(console, "log").mockImplementation(() => {})),
10
+ (spy) =>
11
+ Effect.sync(() => {
12
+ spy.mockRestore()
13
+ })
14
+ )
15
+
16
+ const withArgv = (nextArgv: ReadonlyArray<string>) =>
17
+ Effect.acquireRelease(
18
+ Effect.sync(() => {
19
+ const previous = process.argv
20
+ process.argv = [...nextArgv]
21
+ return previous
22
+ }),
23
+ (previous) =>
24
+ Effect.sync(() => {
25
+ process.argv = previous
26
+ })
27
+ )
28
+
29
+ const usageCases = [
30
+ { argv: ["node", "main"], needle: "pnpm docker-git" as const },
31
+ { argv: ["node", "main", "Alice"], needle: "Usage:" as const }
32
+ ] as const
33
+
34
+ const runUsageCase = ({
35
+ argv,
36
+ needle
37
+ }: (typeof usageCases)[number]) =>
38
+ Effect.scoped(
39
+ Effect.gen(function*(_) {
40
+ const logSpy = yield* _(withLogSpy)
41
+ yield* _(withArgv(argv))
42
+ yield* _(pipe(program, Effect.provide(NodeContext.layer)))
43
+ yield* _(
44
+ Effect.sync(() => {
45
+ expect(logSpy).toHaveBeenCalledTimes(1)
46
+ expect(logSpy).toHaveBeenLastCalledWith(
47
+ expect.stringContaining(needle)
48
+ )
49
+ })
50
+ )
51
+ })
52
+ )
53
+
54
+ describe("main program", () => {
55
+ it.effect("prints usage for invalid invocations", () =>
56
+ pipe(
57
+ Effect.forEach(usageCases, runUsageCase, { concurrency: 1 }),
58
+ Effect.asVoid
59
+ ))
60
+ })
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect } from "effect"
3
+
4
+ import { defaultTemplateConfig } from "@effect-template/lib/core/domain"
5
+ import { renderEntrypoint } from "@effect-template/lib/core/templates-entrypoint"
6
+
7
+ describe("renderEntrypoint auth bridge", () => {
8
+ it.effect("maps GH token fallback to git auth and sets git credential helper", () =>
9
+ Effect.sync(() => {
10
+ const entrypoint = renderEntrypoint({
11
+ ...defaultTemplateConfig,
12
+ repoUrl: "https://github.com/org/repo.git",
13
+ enableMcpPlaywright: false
14
+ })
15
+
16
+ expect(entrypoint).toContain(
17
+ "GIT_AUTH_TOKEN=\"${GIT_AUTH_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}\""
18
+ )
19
+ expect(entrypoint).toContain("GITHUB_TOKEN=\"${GITHUB_TOKEN:-${GH_TOKEN:-}}\"")
20
+ expect(entrypoint).toContain("if [[ -n \"$GH_TOKEN\" || -n \"$GITHUB_TOKEN\" ]]; then")
21
+ expect(entrypoint).toContain(String.raw`printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN"`)
22
+ expect(entrypoint).toContain(String.raw`printf "%s\n" "GITHUB_TOKEN=$EFFECTIVE_GITHUB_TOKEN" >> "$SSH_ENV_PATH"`)
23
+ expect(entrypoint).toContain("GIT_CREDENTIAL_HELPER_PATH=\"/usr/local/bin/docker-git-credential-helper\"")
24
+ expect(entrypoint).toContain("token=\"$GITHUB_TOKEN\"")
25
+ expect(entrypoint).toContain("token=\"$GH_TOKEN\"")
26
+ expect(entrypoint).toContain(String.raw`printf "%s\n" "password=$token"`)
27
+ expect(entrypoint).toContain("git config --global credential.helper")
28
+ }))
29
+ })
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Either } from "effect"
3
+
4
+ import { type Command, defaultTemplateConfig } from "@effect-template/lib/core/domain"
5
+ import { parseArgs } from "../../src/docker-git/cli/parser.js"
6
+
7
+ type CreateCommand = Extract<Command, { _tag: "Create" }>
8
+
9
+ const parseOrThrow = (args: ReadonlyArray<string>): Command => {
10
+ const parsed = parseArgs(args)
11
+ return Either.match(parsed, {
12
+ onLeft: (error) => {
13
+ throw new Error(`unexpected error ${error._tag}`)
14
+ },
15
+ onRight: (command) => command
16
+ })
17
+ }
18
+
19
+ const expectCreateCommand = (
20
+ args: ReadonlyArray<string>,
21
+ onRight: (command: CreateCommand) => void
22
+ ) =>
23
+ Effect.sync(() => {
24
+ const command = parseOrThrow(args)
25
+ if (command._tag !== "Create") {
26
+ throw new Error("expected Create command")
27
+ }
28
+ onRight(command)
29
+ })
30
+
31
+ const expectCreateDefaults = (command: CreateCommand) => {
32
+ expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
33
+ expect(command.config.repoRef).toBe(defaultTemplateConfig.repoRef)
34
+ expect(command.outDir).toBe(".docker-git/org/repo")
35
+ expect(command.runUp).toBe(true)
36
+ expect(command.forceEnv).toBe(false)
37
+ }
38
+
39
+ describe("parseArgs", () => {
40
+ it.effect("parses create command with defaults", () =>
41
+ expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git"], (command) => {
42
+ expectCreateDefaults(command)
43
+ expect(command.config.containerName).toBe("dg-repo")
44
+ expect(command.config.serviceName).toBe("dg-repo")
45
+ expect(command.config.volumeName).toBe("dg-repo-home")
46
+ expect(command.config.sshPort).toBe(defaultTemplateConfig.sshPort)
47
+ }))
48
+
49
+ it.effect("parses create command with issue url into isolated defaults", () =>
50
+ expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo/issues/9"], (command) => {
51
+ expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
52
+ expect(command.config.repoRef).toBe("issue-9")
53
+ expect(command.outDir).toBe(".docker-git/org/repo/issue-9")
54
+ expect(command.config.containerName).toBe("dg-repo-issue-9")
55
+ expect(command.config.serviceName).toBe("dg-repo-issue-9")
56
+ expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
57
+ }))
58
+
59
+ it.effect("fails on missing repo url", () =>
60
+ Effect.sync(() => {
61
+ Either.match(parseArgs(["create"]), {
62
+ onLeft: (error) => {
63
+ expect(error._tag).toBe("MissingRequiredOption")
64
+ },
65
+ onRight: () => {
66
+ throw new Error("expected parse error")
67
+ }
68
+ })
69
+ }))
70
+
71
+ it.effect("parses clone command with positional repo url", () =>
72
+ expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => {
73
+ expectCreateDefaults(command)
74
+ expect(command.config.targetDir).toBe("/home/dev/org/repo")
75
+ }))
76
+
77
+ it.effect("parses clone branch alias", () =>
78
+ expectCreateCommand(["clone", "https://github.com/org/repo.git", "--branch", "feature-x"], (command) => {
79
+ expect(command.config.repoRef).toBe("feature-x")
80
+ }))
81
+
82
+ it.effect("parses force-env flag for clone", () =>
83
+ expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => {
84
+ expect(command.force).toBe(false)
85
+ expect(command.forceEnv).toBe(true)
86
+ }))
87
+
88
+ it.effect("supports force + force-env together", () =>
89
+ expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force", "--force-env"], (command) => {
90
+ expect(command.force).toBe(true)
91
+ expect(command.forceEnv).toBe(true)
92
+ }))
93
+
94
+ it.effect("parses GitHub tree url as repo + ref", () =>
95
+ expectCreateCommand(["clone", "https://github.com/agiens/crm/tree/vova-fork"], (command) => {
96
+ expect(command.config.repoUrl).toBe("https://github.com/agiens/crm.git")
97
+ expect(command.config.repoRef).toBe("vova-fork")
98
+ expect(command.outDir).toBe(".docker-git/agiens/crm")
99
+ expect(command.config.targetDir).toBe("/home/dev/agiens/crm")
100
+ }))
101
+
102
+ it.effect("parses GitHub issue url as isolated project + issue branch", () =>
103
+ expectCreateCommand(["clone", "https://github.com/org/repo/issues/5"], (command) => {
104
+ expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
105
+ expect(command.config.repoRef).toBe("issue-5")
106
+ expect(command.outDir).toBe(".docker-git/org/repo/issue-5")
107
+ expect(command.config.targetDir).toBe("/home/dev/org/repo/issue-5")
108
+ expect(command.config.containerName).toBe("dg-repo-issue-5")
109
+ expect(command.config.serviceName).toBe("dg-repo-issue-5")
110
+ expect(command.config.volumeName).toBe("dg-repo-issue-5-home")
111
+ }))
112
+
113
+ it.effect("parses GitHub PR url as isolated project", () =>
114
+ expectCreateCommand(["clone", "https://github.com/org/repo/pull/42"], (command) => {
115
+ expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
116
+ expect(command.config.repoRef).toBe("refs/pull/42/head")
117
+ expect(command.outDir).toBe(".docker-git/org/repo/pr-42")
118
+ expect(command.config.targetDir).toBe("/home/dev/org/repo/pr-42")
119
+ expect(command.config.containerName).toBe("dg-repo-pr-42")
120
+ expect(command.config.serviceName).toBe("dg-repo-pr-42")
121
+ expect(command.config.volumeName).toBe("dg-repo-pr-42-home")
122
+ }))
123
+
124
+ it.effect("parses attach with GitHub issue url into issue workspace", () =>
125
+ Effect.sync(() => {
126
+ const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"])
127
+ if (command._tag !== "Attach") {
128
+ throw new Error("expected Attach command")
129
+ }
130
+ expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
131
+ }))
132
+
133
+ it.effect("parses down-all command", () =>
134
+ Effect.sync(() => {
135
+ const command = parseOrThrow(["down-all"])
136
+ expect(command._tag).toBe("DownAll")
137
+ }))
138
+
139
+ it.effect("parses state path command", () =>
140
+ Effect.sync(() => {
141
+ const command = parseOrThrow(["state", "path"])
142
+ expect(command._tag).toBe("StatePath")
143
+ }))
144
+
145
+ it.effect("parses state init command", () =>
146
+ Effect.sync(() => {
147
+ const command = parseOrThrow(["state", "init", "--repo-url", "https://github.com/org/state.git"])
148
+ if (command._tag !== "StateInit") {
149
+ throw new Error("expected StateInit command")
150
+ }
151
+ expect(command.repoUrl).toBe("https://github.com/org/state.git")
152
+ expect(command.repoRef).toBe("main")
153
+ }))
154
+
155
+ it.effect("parses state commit command", () =>
156
+ Effect.sync(() => {
157
+ const command = parseOrThrow(["state", "commit", "-m", "sync state"])
158
+ if (command._tag !== "StateCommit") {
159
+ throw new Error("expected StateCommit command")
160
+ }
161
+ expect(command.message).toBe("sync state")
162
+ }))
163
+
164
+ it.effect("parses state sync command", () =>
165
+ Effect.sync(() => {
166
+ const command = parseOrThrow(["state", "sync", "-m", "sync state"])
167
+ if (command._tag !== "StateSync") {
168
+ throw new Error("expected StateSync command")
169
+ }
170
+ expect(command.message).toBe("sync state")
171
+ }))
172
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": []
5
+ },
6
+ "include": ["src/**/*"],
7
+ "exclude": ["dist", "node_modules", "tests/**/*", "vite.config.ts", "vitest.config.ts"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "dist",
6
+ "types": ["vitest"],
7
+ "jsx": "react-jsx",
8
+ "baseUrl": ".",
9
+ "paths": {
10
+ "@/*": ["src/*"]
11
+ }
12
+ },
13
+ "include": [
14
+ "src/**/*",
15
+ "tests/**/*",
16
+ "vite.config.ts",
17
+ "vitest.config.ts"
18
+ ],
19
+ "exclude": ["dist", "node_modules"]
20
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,32 @@
1
+ import path from "node:path"
2
+ import { fileURLToPath } from "node:url"
3
+ import { defineConfig } from "vite"
4
+ import tsconfigPaths from "vite-tsconfig-paths"
5
+
6
+ const __filename = fileURLToPath(import.meta.url)
7
+ const __dirname = path.dirname(__filename)
8
+
9
+ export default defineConfig({
10
+ plugins: [tsconfigPaths()],
11
+ publicDir: false,
12
+ resolve: {
13
+ alias: {
14
+ "@": path.resolve(__dirname, "src")
15
+ }
16
+ },
17
+ build: {
18
+ target: "node20",
19
+ outDir: "dist",
20
+ sourcemap: true,
21
+ ssr: "src/app/main.ts",
22
+ rollupOptions: {
23
+ output: {
24
+ format: "es",
25
+ entryFileNames: "main.js"
26
+ }
27
+ }
28
+ },
29
+ ssr: {
30
+ target: "node"
31
+ }
32
+ })