@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.
- package/.jscpd.json +16 -0
- package/.package.json.release.bak +109 -0
- package/CHANGELOG.md +31 -0
- package/README.md +173 -0
- package/biome.json +34 -0
- package/dist/main.js +847 -0
- package/dist/main.js.map +1 -0
- package/dist/src/app/main.js +15 -0
- package/dist/src/app/program.js +61 -0
- package/dist/src/docker-git/cli/input.js +21 -0
- package/dist/src/docker-git/cli/parser-attach.js +19 -0
- package/dist/src/docker-git/cli/parser-auth.js +70 -0
- package/dist/src/docker-git/cli/parser-clone.js +40 -0
- package/dist/src/docker-git/cli/parser-create.js +1 -0
- package/dist/src/docker-git/cli/parser-options.js +101 -0
- package/dist/src/docker-git/cli/parser-panes.js +19 -0
- package/dist/src/docker-git/cli/parser-sessions.js +69 -0
- package/dist/src/docker-git/cli/parser-shared.js +26 -0
- package/dist/src/docker-git/cli/parser-state.js +62 -0
- package/dist/src/docker-git/cli/parser.js +42 -0
- package/dist/src/docker-git/cli/read-command.js +17 -0
- package/dist/src/docker-git/cli/usage.js +99 -0
- package/dist/src/docker-git/main.js +15 -0
- package/dist/src/docker-git/menu-actions.js +115 -0
- package/dist/src/docker-git/menu-create.js +203 -0
- package/dist/src/docker-git/menu-input.js +2 -0
- package/dist/src/docker-git/menu-menu.js +46 -0
- package/dist/src/docker-git/menu-render.js +151 -0
- package/dist/src/docker-git/menu-select.js +131 -0
- package/dist/src/docker-git/menu-shared.js +111 -0
- package/dist/src/docker-git/menu-types.js +19 -0
- package/dist/src/docker-git/menu.js +237 -0
- package/dist/src/docker-git/program.js +38 -0
- package/dist/src/docker-git/tmux.js +176 -0
- package/eslint.config.mts +305 -0
- package/eslint.effect-ts-check.config.mjs +220 -0
- package/linter.config.json +33 -0
- package/package.json +63 -0
- package/src/app/main.ts +18 -0
- package/src/app/program.ts +75 -0
- package/src/docker-git/cli/input.ts +29 -0
- package/src/docker-git/cli/parser-attach.ts +22 -0
- package/src/docker-git/cli/parser-auth.ts +124 -0
- package/src/docker-git/cli/parser-clone.ts +55 -0
- package/src/docker-git/cli/parser-create.ts +3 -0
- package/src/docker-git/cli/parser-options.ts +152 -0
- package/src/docker-git/cli/parser-panes.ts +22 -0
- package/src/docker-git/cli/parser-sessions.ts +101 -0
- package/src/docker-git/cli/parser-shared.ts +51 -0
- package/src/docker-git/cli/parser-state.ts +86 -0
- package/src/docker-git/cli/parser.ts +73 -0
- package/src/docker-git/cli/read-command.ts +26 -0
- package/src/docker-git/cli/usage.ts +112 -0
- package/src/docker-git/main.ts +18 -0
- package/src/docker-git/menu-actions.ts +246 -0
- package/src/docker-git/menu-create.ts +320 -0
- package/src/docker-git/menu-input.ts +2 -0
- package/src/docker-git/menu-menu.ts +58 -0
- package/src/docker-git/menu-render.ts +327 -0
- package/src/docker-git/menu-select.ts +250 -0
- package/src/docker-git/menu-shared.ts +141 -0
- package/src/docker-git/menu-types.ts +94 -0
- package/src/docker-git/menu.ts +339 -0
- package/src/docker-git/program.ts +134 -0
- package/src/docker-git/tmux.ts +292 -0
- package/tests/app/main.test.ts +60 -0
- package/tests/docker-git/entrypoint-auth.test.ts +29 -0
- package/tests/docker-git/parser.test.ts +172 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +32 -0
- 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
|
+
})
|
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
|
+
})
|