@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,250 @@
1
+ import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
2
+ import type { AppError } from "@effect-template/lib/usecases/errors"
3
+ import {
4
+ connectProjectSshWithUp,
5
+ deleteDockerGitProject,
6
+ listRunningProjectItems,
7
+ type ProjectItem
8
+ } from "@effect-template/lib/usecases/projects"
9
+
10
+ import { Effect, Match, pipe } from "effect"
11
+
12
+ import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
13
+ import type { MenuEnv, MenuKeyInput, MenuRunner, MenuViewContext, ViewState } from "./menu-types.js"
14
+
15
+ // CHANGE: handle project selection flow in TUI
16
+ // WHY: allow selecting active project without manual typing
17
+ // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?"
18
+ // REF: user-request-2026-02-02-select-project
19
+ // SOURCE: n/a
20
+ // FORMAT THEOREM: forall p: select(p) -> activeDir(p)
21
+ // PURITY: SHELL
22
+ // EFFECT: Effect<void, never, never>
23
+ // INVARIANT: selected index always within items length
24
+ // COMPLEXITY: O(1) per keypress
25
+
26
+ type SelectContext = MenuViewContext & {
27
+ readonly activeDir: string | null
28
+ readonly runner: MenuRunner
29
+ readonly setSshActive: (active: boolean) => void
30
+ readonly setSkipInputs: (update: (value: number) => number) => void
31
+ }
32
+
33
+ export const startSelectView = (
34
+ items: ReadonlyArray<ProjectItem>,
35
+ purpose: "Connect" | "Down" | "Info" | "Delete",
36
+ context: Pick<SelectContext, "setView" | "setMessage">
37
+ ) => {
38
+ context.setMessage(null)
39
+ context.setView({ _tag: "SelectProject", purpose, items, selected: 0, confirmDelete: false })
40
+ }
41
+
42
+ const clampIndex = (value: number, size: number): number => {
43
+ if (size <= 0) {
44
+ return 0
45
+ }
46
+ if (value < 0) {
47
+ return 0
48
+ }
49
+ if (value >= size) {
50
+ return size - 1
51
+ }
52
+ return value
53
+ }
54
+
55
+ export const handleSelectInput = (
56
+ input: string,
57
+ key: MenuKeyInput,
58
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
59
+ context: SelectContext
60
+ ) => {
61
+ if (key.escape) {
62
+ resetToMenu(context)
63
+ return
64
+ }
65
+ if (handleSelectNavigation(key, view, context)) {
66
+ return
67
+ }
68
+ if (key.return) {
69
+ handleSelectReturn(view, context)
70
+ return
71
+ }
72
+ handleSelectHint(input, context)
73
+ }
74
+
75
+ const handleSelectNavigation = (
76
+ key: MenuKeyInput,
77
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
78
+ context: SelectContext
79
+ ): boolean => {
80
+ if (key.upArrow) {
81
+ const next = clampIndex(view.selected - 1, view.items.length)
82
+ context.setView({ ...view, selected: next, confirmDelete: false })
83
+ return true
84
+ }
85
+ if (key.downArrow) {
86
+ const next = clampIndex(view.selected + 1, view.items.length)
87
+ context.setView({ ...view, selected: next, confirmDelete: false })
88
+ return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ const runWithSuspendedTui = (
94
+ context: Pick<SelectContext, "runner" | "setMessage" | "setSkipInputs">,
95
+ effect: Effect.Effect<void, AppError, MenuEnv>,
96
+ onResume: () => void,
97
+ doneMessage: string
98
+ ) => {
99
+ context.runner.runEffect(
100
+ pipe(
101
+ Effect.sync(suspendTui),
102
+ Effect.zipRight(effect),
103
+ Effect.ensuring(
104
+ Effect.sync(() => {
105
+ resumeTui()
106
+ onResume()
107
+ context.setSkipInputs(() => 2)
108
+ })
109
+ ),
110
+ Effect.tap(() =>
111
+ Effect.sync(() => {
112
+ context.setMessage(doneMessage)
113
+ })
114
+ )
115
+ )
116
+ )
117
+ }
118
+
119
+ const runConnectSelection = (selected: ProjectItem, context: SelectContext) => {
120
+ context.setMessage(`Connecting to ${selected.displayName}...`)
121
+ context.setSshActive(true)
122
+ runWithSuspendedTui(
123
+ context,
124
+ connectProjectSshWithUp(selected),
125
+ () => {
126
+ context.setSshActive(false)
127
+ },
128
+ "SSH session ended. Press Esc to return to the menu."
129
+ )
130
+ }
131
+
132
+ const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
133
+ context.setMessage(`Stopping ${selected.displayName}...`)
134
+ context.runner.runEffect(
135
+ pipe(
136
+ Effect.sync(suspendTui),
137
+ Effect.zipRight(runDockerComposeDown(selected.projectDir)),
138
+ Effect.zipRight(listRunningProjectItems),
139
+ Effect.tap((items) =>
140
+ Effect.sync(() => {
141
+ if (items.length === 0) {
142
+ resetToMenu(context)
143
+ context.setMessage("No running docker-git containers.")
144
+ return
145
+ }
146
+ startSelectView(items, "Down", context)
147
+ context.setMessage("Container stopped. Select another to stop, or Esc to return.")
148
+ })
149
+ ),
150
+ Effect.ensuring(
151
+ Effect.sync(() => {
152
+ resumeTui()
153
+ context.setSkipInputs(() => 2)
154
+ })
155
+ ),
156
+ Effect.asVoid
157
+ )
158
+ )
159
+ }
160
+
161
+ const runInfoSelection = (selected: ProjectItem, context: SelectContext) => {
162
+ context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`)
163
+ }
164
+
165
+ const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => {
166
+ context.setMessage(`Deleting ${selected.displayName}...`)
167
+ runWithSuspendedTui(
168
+ context,
169
+ deleteDockerGitProject(selected).pipe(
170
+ Effect.tap(() =>
171
+ Effect.sync(() => {
172
+ if (context.activeDir === selected.projectDir) {
173
+ context.setActiveDir(null)
174
+ }
175
+ context.setView({ _tag: "Menu" })
176
+ })
177
+ )
178
+ ),
179
+ () => {
180
+ // Only return to menu on success (see Effect.tap above).
181
+ },
182
+ "Project deleted."
183
+ )
184
+ }
185
+
186
+ const handleSelectReturn = (
187
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
188
+ context: SelectContext
189
+ ) => {
190
+ const selected = view.items[view.selected]
191
+ if (!selected) {
192
+ context.setMessage("No project selected.")
193
+ resetToMenu(context)
194
+ return
195
+ }
196
+
197
+ Match.value(view.purpose).pipe(
198
+ Match.when("Connect", () => {
199
+ context.setActiveDir(selected.projectDir)
200
+ runConnectSelection(selected, context)
201
+ }),
202
+ Match.when("Down", () => {
203
+ context.setActiveDir(selected.projectDir)
204
+ runDownSelection(selected, context)
205
+ }),
206
+ Match.when("Info", () => {
207
+ context.setActiveDir(selected.projectDir)
208
+ runInfoSelection(selected, context)
209
+ }),
210
+ Match.when("Delete", () => {
211
+ if (!view.confirmDelete) {
212
+ context.setMessage(
213
+ `Really delete ${selected.displayName}? Press Enter again to confirm, Esc to cancel.`
214
+ )
215
+ context.setView({ ...view, confirmDelete: true })
216
+ return
217
+ }
218
+ runDeleteSelection(selected, context)
219
+ }),
220
+ Match.exhaustive
221
+ )
222
+ }
223
+
224
+ const handleSelectHint = (input: string, context: SelectContext) => {
225
+ if (input.trim().length > 0) {
226
+ context.setMessage("Use arrows + Enter to select a project, Esc to cancel.")
227
+ }
228
+ }
229
+
230
+ export const loadSelectView = <E>(
231
+ effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
232
+ purpose: "Connect" | "Down" | "Info" | "Delete",
233
+ context: Pick<SelectContext, "setView" | "setMessage">
234
+ ): Effect.Effect<void, E, MenuEnv> =>
235
+ pipe(
236
+ effect,
237
+ Effect.flatMap((items) =>
238
+ Effect.sync(() => {
239
+ if (items.length === 0) {
240
+ context.setMessage(
241
+ purpose === "Down"
242
+ ? "No running docker-git containers."
243
+ : "No docker-git projects found."
244
+ )
245
+ return
246
+ }
247
+ startSelectView(items, purpose, context)
248
+ })
249
+ )
250
+ )
@@ -0,0 +1,141 @@
1
+ import type { MenuViewContext, ViewState } from "./menu-types.js"
2
+
3
+ // CHANGE: share menu escape handling across flows
4
+ // WHY: avoid duplicated logic in TUI handlers
5
+ // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?"
6
+ // REF: user-request-2026-02-02-select-project
7
+ // SOURCE: n/a
8
+ // FORMAT THEOREM: forall s: escape(s) -> menu(s)
9
+ // PURITY: SHELL
10
+ // EFFECT: n/a
11
+ // INVARIANT: always resets message on escape
12
+ // COMPLEXITY: O(1)
13
+
14
+ type MenuResetContext = Pick<MenuViewContext, "setView" | "setMessage">
15
+
16
+ type StdoutWrite = typeof process.stdout.write
17
+
18
+ let stdoutPatched = false
19
+ let stdoutMuted = false
20
+
21
+ const disableMouseModes = (): void => {
22
+ // Disable xterm/urxvt mouse tracking and "alternate scroll" mode (wheel -> arrow keys).
23
+ process.stdout.write(
24
+ "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l"
25
+ )
26
+ }
27
+
28
+ // CHANGE: mute Ink stdout writes while SSH is active
29
+ // WHY: prevent Ink resize re-renders from corrupting the SSH terminal buffer
30
+ // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
31
+ // REF: user-request-2026-02-05-ssh-resize
32
+ // SOURCE: n/a
33
+ // FORMAT THEOREM: ∀w: muted(w) → ¬writes(ink, stdout)
34
+ // PURITY: SHELL
35
+ // EFFECT: n/a
36
+ // INVARIANT: wrapper preserves original stdout write when not muted
37
+ // COMPLEXITY: O(1)
38
+ const ensureStdoutPatched = (): void => {
39
+ if (stdoutPatched) {
40
+ return
41
+ }
42
+ const baseWrite: StdoutWrite = process.stdout.write.bind(process.stdout)
43
+ const mutedWrite: StdoutWrite = (
44
+ chunk: string | Uint8Array,
45
+ encoding?: BufferEncoding | ((err?: Error | null) => void),
46
+ cb?: (err?: Error | null) => void
47
+ ) => {
48
+ if (stdoutMuted) {
49
+ const callback = typeof encoding === "function" ? encoding : cb
50
+ if (typeof callback === "function") {
51
+ callback()
52
+ }
53
+ return true
54
+ }
55
+ if (typeof encoding === "function") {
56
+ return baseWrite(chunk, encoding)
57
+ }
58
+ return baseWrite(chunk, encoding, cb)
59
+ }
60
+ process.stdout.write = mutedWrite
61
+ stdoutPatched = true
62
+ }
63
+
64
+ // CHANGE: toggle stdout write muting for Ink rendering
65
+ // WHY: allow SSH sessions to own the terminal without TUI redraws
66
+ // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
67
+ // REF: user-request-2026-02-05-ssh-resize
68
+ // SOURCE: n/a
69
+ // FORMAT THEOREM: ∀m ∈ {true,false}: muted = m
70
+ // PURITY: SHELL
71
+ // EFFECT: n/a
72
+ // INVARIANT: stdout wrapper is installed at most once
73
+ // COMPLEXITY: O(1)
74
+ const setStdoutMuted = (muted: boolean): void => {
75
+ ensureStdoutPatched()
76
+ stdoutMuted = muted
77
+ }
78
+
79
+ // CHANGE: temporarily suspend TUI rendering when running interactive commands
80
+ // WHY: avoid mixed output from docker/ssh and the Ink UI
81
+ // QUOTE(ТЗ): "Почему так кривокосо всё отображается?"
82
+ // REF: user-request-2026-02-02-tui-output
83
+ // SOURCE: n/a
84
+ // FORMAT THEOREM: forall cmd: suspend -> cleanOutput(cmd)
85
+ // PURITY: SHELL
86
+ // EFFECT: n/a
87
+ // INVARIANT: only toggles when TTY is available
88
+ // COMPLEXITY: O(1)
89
+ export const suspendTui = (): void => {
90
+ if (!process.stdout.isTTY) {
91
+ return
92
+ }
93
+ disableMouseModes()
94
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
95
+ process.stdin.setRawMode(false)
96
+ }
97
+ process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H")
98
+ setStdoutMuted(true)
99
+ }
100
+
101
+ // CHANGE: restore TUI rendering after interactive commands
102
+ // WHY: return to Ink UI without broken terminal state
103
+ // QUOTE(ТЗ): "Почему так кривокосо всё отображается?"
104
+ // REF: user-request-2026-02-02-tui-output
105
+ // SOURCE: n/a
106
+ // FORMAT THEOREM: forall cmd: resume -> tuiVisible(cmd)
107
+ // PURITY: SHELL
108
+ // EFFECT: n/a
109
+ // INVARIANT: only toggles when TTY is available
110
+ // COMPLEXITY: O(1)
111
+ export const resumeTui = (): void => {
112
+ if (!process.stdout.isTTY) {
113
+ return
114
+ }
115
+ setStdoutMuted(false)
116
+ disableMouseModes()
117
+ process.stdout.write("\u001B[?1049h\u001B[2J\u001B[H")
118
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
119
+ process.stdin.setRawMode(true)
120
+ }
121
+ disableMouseModes()
122
+ }
123
+
124
+ export const leaveTui = (): void => {
125
+ if (!process.stdout.isTTY) {
126
+ return
127
+ }
128
+ // Ensure we don't leave the terminal in a broken "mouse reporting" mode.
129
+ setStdoutMuted(false)
130
+ disableMouseModes()
131
+ process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H")
132
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
133
+ process.stdin.setRawMode(false)
134
+ }
135
+ }
136
+
137
+ export const resetToMenu = (context: MenuResetContext): void => {
138
+ const view: ViewState = { _tag: "Menu" }
139
+ context.setView(view)
140
+ context.setMessage(null)
141
+ }
@@ -0,0 +1,94 @@
1
+ import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2
+ import type * as FileSystem from "@effect/platform/FileSystem"
3
+ import type * as Path from "@effect/platform/Path"
4
+ import type * as Effect from "effect/Effect"
5
+
6
+ import type { MenuAction } from "@effect-template/lib/core/domain"
7
+ import type { AppError } from "@effect-template/lib/usecases/errors"
8
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
9
+
10
+ // CHANGE: isolate TUI types/constants into a shared module
11
+ // WHY: keep menu rendering and input handling small and focused
12
+ // QUOTE(ТЗ): "TUI? Красивый, удобный"
13
+ // REF: user-request-2026-02-01-tui
14
+ // SOURCE: n/a
15
+ // FORMAT THEOREM: forall s: state(s) -> wellTyped(s)
16
+ // PURITY: CORE
17
+ // EFFECT: n/a
18
+ // INVARIANT: createSteps is ordered and total over CreateStep
19
+ // COMPLEXITY: O(1)
20
+
21
+ export type MenuState = {
22
+ readonly cwd: string
23
+ readonly activeDir: string | null
24
+ }
25
+
26
+ export type MenuEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
27
+
28
+ export type MenuRunner = {
29
+ readonly runEffect: (effect: Effect.Effect<void, AppError, MenuEnv>) => void
30
+ }
31
+
32
+ export type MenuViewContext = {
33
+ readonly setView: (view: ViewState) => void
34
+ readonly setMessage: (message: string | null) => void
35
+ readonly setActiveDir: (dir: string | null) => void
36
+ }
37
+
38
+ export type MenuKeyInput = {
39
+ readonly upArrow?: boolean
40
+ readonly downArrow?: boolean
41
+ readonly return?: boolean
42
+ readonly escape?: boolean
43
+ }
44
+
45
+ export type CreateInputs = {
46
+ readonly repoUrl: string
47
+ readonly repoRef: string
48
+ readonly outDir: string
49
+ readonly secretsRoot: string
50
+ readonly runUp: boolean
51
+ readonly enableMcpPlaywright: boolean
52
+ readonly force: boolean
53
+ readonly forceEnv: boolean
54
+ }
55
+
56
+ export type CreateStep =
57
+ | "repoUrl"
58
+ | "repoRef"
59
+ | "outDir"
60
+ | "runUp"
61
+ | "mcpPlaywright"
62
+ | "force"
63
+
64
+ export const createSteps: ReadonlyArray<CreateStep> = [
65
+ "repoUrl",
66
+ "repoRef",
67
+ "outDir",
68
+ "runUp",
69
+ "mcpPlaywright",
70
+ "force"
71
+ ]
72
+
73
+ export type ViewState =
74
+ | { readonly _tag: "Menu" }
75
+ | { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial<CreateInputs> }
76
+ | {
77
+ readonly _tag: "SelectProject"
78
+ readonly purpose: "Connect" | "Down" | "Info" | "Delete"
79
+ readonly items: ReadonlyArray<ProjectItem>
80
+ readonly selected: number
81
+ readonly confirmDelete: boolean
82
+ }
83
+
84
+ export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
85
+ { id: { _tag: "Create" }, label: "Create project" },
86
+ { id: { _tag: "Select" }, label: "Select project" },
87
+ { id: { _tag: "Info" }, label: "Show connection info" },
88
+ { id: { _tag: "Status" }, label: "docker compose ps" },
89
+ { id: { _tag: "Logs" }, label: "docker compose logs --tail=200" },
90
+ { id: { _tag: "Down" }, label: "docker compose down" },
91
+ { id: { _tag: "DownAll" }, label: "docker compose down (ALL projects)" },
92
+ { id: { _tag: "Delete" }, label: "Delete project (remove folder)" },
93
+ { id: { _tag: "Quit" }, label: "Quit" }
94
+ ]