@prover-coder-ai/docker-git 1.0.11 → 1.0.13

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.
@@ -1,5 +1,6 @@
1
1
  import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
2
2
  import type { AppError } from "@effect-template/lib/usecases/errors"
3
+ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
3
4
  import {
4
5
  connectProjectSshWithUp,
5
6
  deleteDockerGitProject,
@@ -9,19 +10,17 @@ import {
9
10
 
10
11
  import { Effect, Match, pipe } from "effect"
11
12
 
13
+ import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
14
+ import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
12
15
  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
16
+ import type {
17
+ MenuEnv,
18
+ MenuKeyInput,
19
+ MenuRunner,
20
+ MenuViewContext,
21
+ SelectProjectRuntime,
22
+ ViewState
23
+ } from "./menu-types.js"
25
24
 
26
25
  type SelectContext = MenuViewContext & {
27
26
  readonly activeDir: string | null
@@ -30,13 +29,24 @@ type SelectContext = MenuViewContext & {
30
29
  readonly setSkipInputs: (update: (value: number) => number) => void
31
30
  }
32
31
 
32
+ const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
33
+
33
34
  export const startSelectView = (
34
35
  items: ReadonlyArray<ProjectItem>,
35
36
  purpose: "Connect" | "Down" | "Info" | "Delete",
36
- context: Pick<SelectContext, "setView" | "setMessage">
37
+ context: Pick<SelectContext, "setView" | "setMessage">,
38
+ runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
37
39
  ) => {
38
40
  context.setMessage(null)
39
- context.setView({ _tag: "SelectProject", purpose, items, selected: 0, confirmDelete: false })
41
+ context.setView({
42
+ _tag: "SelectProject",
43
+ purpose,
44
+ items,
45
+ runtimeByProject,
46
+ selected: 0,
47
+ confirmDelete: false,
48
+ connectEnableMcpPlaywright: false
49
+ })
40
50
  }
41
51
 
42
52
  const clampIndex = (value: number, size: number): number => {
@@ -62,6 +72,9 @@ export const handleSelectInput = (
62
72
  resetToMenu(context)
63
73
  return
64
74
  }
75
+ if (handleConnectOptionToggle(input, view, context)) {
76
+ return
77
+ }
65
78
  if (handleSelectNavigation(key, view, context)) {
66
79
  return
67
80
  }
@@ -69,7 +82,27 @@ export const handleSelectInput = (
69
82
  handleSelectReturn(view, context)
70
83
  return
71
84
  }
72
- handleSelectHint(input, context)
85
+ if (input.trim().length > 0) {
86
+ context.setMessage("Use arrows + Enter to select a project, Esc to cancel.")
87
+ }
88
+ }
89
+
90
+ const handleConnectOptionToggle = (
91
+ input: string,
92
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
93
+ context: Pick<SelectContext, "setView" | "setMessage">
94
+ ): boolean => {
95
+ if (view.purpose !== "Connect" || !isConnectMcpToggleInput(input)) {
96
+ return false
97
+ }
98
+ const nextValue = !view.connectEnableMcpPlaywright
99
+ context.setView({ ...view, connectEnableMcpPlaywright: nextValue, confirmDelete: false })
100
+ context.setMessage(
101
+ nextValue
102
+ ? "Playwright MCP will be enabled before SSH (press Enter to connect)."
103
+ : "Playwright MCP toggle is OFF (press Enter to connect without changes)."
104
+ )
105
+ return true
73
106
  }
74
107
 
75
108
  const handleSelectNavigation = (
@@ -116,12 +149,30 @@ const runWithSuspendedTui = (
116
149
  )
117
150
  }
118
151
 
119
- const runConnectSelection = (selected: ProjectItem, context: SelectContext) => {
120
- context.setMessage(`Connecting to ${selected.displayName}...`)
152
+ const runConnectSelection = (
153
+ selected: ProjectItem,
154
+ context: SelectContext,
155
+ enableMcpPlaywright: boolean
156
+ ) => {
157
+ context.setMessage(
158
+ enableMcpPlaywright
159
+ ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
160
+ : `Connecting to ${selected.displayName}...`
161
+ )
121
162
  context.setSshActive(true)
122
163
  runWithSuspendedTui(
123
164
  context,
124
- connectProjectSshWithUp(selected),
165
+ buildConnectEffect(selected, enableMcpPlaywright, {
166
+ connectWithUp: (item) =>
167
+ connectProjectSshWithUp(item).pipe(
168
+ Effect.mapError((error): AppError => error)
169
+ ),
170
+ enableMcpPlaywright: (projectDir) =>
171
+ mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(
172
+ Effect.asVoid,
173
+ Effect.mapError((error): AppError => error)
174
+ )
175
+ }),
125
176
  () => {
126
177
  context.setSshActive(false)
127
178
  },
@@ -136,14 +187,20 @@ const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
136
187
  Effect.sync(suspendTui),
137
188
  Effect.zipRight(runDockerComposeDown(selected.projectDir)),
138
189
  Effect.zipRight(listRunningProjectItems),
139
- Effect.tap((items) =>
190
+ Effect.flatMap((items) =>
191
+ pipe(
192
+ loadRuntimeByProject(items),
193
+ Effect.map((runtimeByProject) => ({ items, runtimeByProject }))
194
+ )
195
+ ),
196
+ Effect.tap(({ items, runtimeByProject }) =>
140
197
  Effect.sync(() => {
141
198
  if (items.length === 0) {
142
199
  resetToMenu(context)
143
200
  context.setMessage("No running docker-git containers.")
144
201
  return
145
202
  }
146
- startSelectView(items, "Down", context)
203
+ startSelectView(items, "Down", context, runtimeByProject)
147
204
  context.setMessage("Container stopped. Select another to stop, or Esc to return.")
148
205
  })
149
206
  ),
@@ -193,13 +250,24 @@ const handleSelectReturn = (
193
250
  resetToMenu(context)
194
251
  return
195
252
  }
253
+ const selectedRuntime = runtimeForSelection(view, selected)
254
+ const sshSessionsLabel = selectedRuntime.sshSessions === 1
255
+ ? "1 active SSH session"
256
+ : `${selectedRuntime.sshSessions} active SSH sessions`
196
257
 
197
258
  Match.value(view.purpose).pipe(
198
259
  Match.when("Connect", () => {
199
260
  context.setActiveDir(selected.projectDir)
200
- runConnectSelection(selected, context)
261
+ runConnectSelection(selected, context, view.connectEnableMcpPlaywright)
201
262
  }),
202
263
  Match.when("Down", () => {
264
+ if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
265
+ context.setMessage(
266
+ `${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.`
267
+ )
268
+ context.setView({ ...view, confirmDelete: true })
269
+ return
270
+ }
203
271
  context.setActiveDir(selected.projectDir)
204
272
  runDownSelection(selected, context)
205
273
  }),
@@ -209,8 +277,9 @@ const handleSelectReturn = (
209
277
  }),
210
278
  Match.when("Delete", () => {
211
279
  if (!view.confirmDelete) {
280
+ const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : ""
212
281
  context.setMessage(
213
- `Really delete ${selected.displayName}? Press Enter again to confirm, Esc to cancel.`
282
+ `Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.`
214
283
  )
215
284
  context.setView({ ...view, confirmDelete: true })
216
285
  return
@@ -221,12 +290,6 @@ const handleSelectReturn = (
221
290
  )
222
291
  }
223
292
 
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
293
  export const loadSelectView = <E>(
231
294
  effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
232
295
  purpose: "Connect" | "Down" | "Info" | "Delete",
@@ -235,16 +298,21 @@ export const loadSelectView = <E>(
235
298
  pipe(
236
299
  effect,
237
300
  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
- })
301
+ pipe(
302
+ loadRuntimeByProject(items),
303
+ Effect.flatMap((runtimeByProject) =>
304
+ Effect.sync(() => {
305
+ if (items.length === 0) {
306
+ context.setMessage(
307
+ purpose === "Down"
308
+ ? "No running docker-git containers."
309
+ : "No docker-git projects found."
310
+ )
311
+ return
312
+ }
313
+ startSelectView(items, purpose, context, runtimeByProject)
314
+ })
315
+ )
316
+ )
249
317
  )
250
318
  )
@@ -77,10 +77,17 @@ export type ViewState =
77
77
  readonly _tag: "SelectProject"
78
78
  readonly purpose: "Connect" | "Down" | "Info" | "Delete"
79
79
  readonly items: ReadonlyArray<ProjectItem>
80
+ readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
80
81
  readonly selected: number
81
82
  readonly confirmDelete: boolean
83
+ readonly connectEnableMcpPlaywright: boolean
82
84
  }
83
85
 
86
+ export type SelectProjectRuntime = {
87
+ readonly running: boolean
88
+ readonly sshSessions: number
89
+ }
90
+
84
91
  export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
85
92
  { id: { _tag: "Create" }, label: "Create project" },
86
93
  { id: { _tag: "Select" }, label: "Select project" },
@@ -185,13 +185,15 @@ const renderView = (context: RenderContext) => {
185
185
  return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults)
186
186
  }
187
187
 
188
- return renderSelect(
189
- context.view.purpose,
190
- context.view.items,
191
- context.view.selected,
192
- context.view.confirmDelete,
193
- context.message
194
- )
188
+ return renderSelect({
189
+ purpose: context.view.purpose,
190
+ items: context.view.items,
191
+ selected: context.view.selected,
192
+ runtimeByProject: context.view.runtimeByProject,
193
+ confirmDelete: context.view.confirmDelete,
194
+ connectEnableMcpPlaywright: context.view.connectEnableMcpPlaywright,
195
+ message: context.message
196
+ })
195
197
  }
196
198
 
197
199
  const useMenuState = () => {
@@ -10,6 +10,7 @@ import {
10
10
  } from "@effect-template/lib/usecases/auth"
11
11
  import type { AppError } from "@effect-template/lib/usecases/errors"
12
12
  import { renderError } from "@effect-template/lib/usecases/errors"
13
+ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
13
14
  import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects"
14
15
  import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap"
15
16
  import {
@@ -92,7 +93,10 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
92
93
  Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
93
94
  Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd))
94
95
  )
95
- .pipe(Match.exhaustive)
96
+ .pipe(
97
+ Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)),
98
+ Match.exhaustive
99
+ )
96
100
 
97
101
  // CHANGE: compose CLI program with typed errors and shell effects
98
102
  // WHY: keep a thin entry layer over pure parsing and template generation
@@ -0,0 +1,64 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
5
+ import { selectHint } from "../../src/docker-git/menu-render-select.js"
6
+ import { buildConnectEffect, isConnectMcpToggleInput } from "../../src/docker-git/menu-select-connect.js"
7
+
8
+ const makeProjectItem = (): ProjectItem => ({
9
+ projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo",
10
+ displayName: "org/repo",
11
+ repoUrl: "https://github.com/org/repo.git",
12
+ repoRef: "main",
13
+ containerName: "dg-repo",
14
+ serviceName: "dg-repo",
15
+ sshUser: "dev",
16
+ sshPort: 2222,
17
+ targetDir: "/home/dev/org/repo",
18
+ sshCommand: "ssh -p 2222 dev@localhost",
19
+ sshKeyPath: null,
20
+ authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys",
21
+ authorizedKeysExists: true,
22
+ envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env",
23
+ envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env",
24
+ codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex",
25
+ codexHome: "/home/dev/.codex"
26
+ })
27
+
28
+ const record = (events: Array<string>, entry: string): Effect.Effect<void> =>
29
+ Effect.sync(() => {
30
+ events.push(entry)
31
+ })
32
+
33
+ const makeConnectDeps = (events: Array<string>) => ({
34
+ connectWithUp: (selected: ProjectItem) => record(events, `connect:${selected.projectDir}`),
35
+ enableMcpPlaywright: (projectDir: string) => record(events, `enable:${projectDir}`)
36
+ })
37
+
38
+ describe("menu-select-connect", () => {
39
+ it("runs Playwright enable before SSH when toggle is ON", () => {
40
+ const item = makeProjectItem()
41
+ const events: Array<string> = []
42
+ Effect.runSync(buildConnectEffect(item, true, makeConnectDeps(events)))
43
+ expect(events).toEqual([`enable:${item.projectDir}`, `connect:${item.projectDir}`])
44
+ })
45
+
46
+ it("skips Playwright enable when toggle is OFF", () => {
47
+ const item = makeProjectItem()
48
+ const events: Array<string> = []
49
+ Effect.runSync(buildConnectEffect(item, false, makeConnectDeps(events)))
50
+ expect(events).toEqual([`connect:${item.projectDir}`])
51
+ })
52
+
53
+ it("parses connect toggle key from user input", () => {
54
+ expect(isConnectMcpToggleInput("p")).toBe(true)
55
+ expect(isConnectMcpToggleInput(" P ")).toBe(true)
56
+ expect(isConnectMcpToggleInput("x")).toBe(false)
57
+ expect(isConnectMcpToggleInput("")).toBe(false)
58
+ })
59
+
60
+ it("renders connect hint with current Playwright toggle state", () => {
61
+ expect(selectHint("Connect", true)).toContain("toggle Playwright MCP (on)")
62
+ expect(selectHint("Connect", false)).toContain("toggle Playwright MCP (off)")
63
+ })
64
+ })
@@ -152,6 +152,34 @@ describe("parseArgs", () => {
152
152
  expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
153
153
  }))
154
154
 
155
+ it.effect("parses mcp-playwright command in current directory", () =>
156
+ Effect.sync(() => {
157
+ const command = parseOrThrow(["mcp-playwright"])
158
+ if (command._tag !== "McpPlaywrightUp") {
159
+ throw new Error("expected McpPlaywrightUp command")
160
+ }
161
+ expect(command.projectDir).toBe(".")
162
+ expect(command.runUp).toBe(true)
163
+ }))
164
+
165
+ it.effect("parses mcp-playwright command with --no-up", () =>
166
+ Effect.sync(() => {
167
+ const command = parseOrThrow(["mcp-playwright", "--no-up"])
168
+ if (command._tag !== "McpPlaywrightUp") {
169
+ throw new Error("expected McpPlaywrightUp command")
170
+ }
171
+ expect(command.runUp).toBe(false)
172
+ }))
173
+
174
+ it.effect("parses mcp-playwright with positional repo url into project dir", () =>
175
+ Effect.sync(() => {
176
+ const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"])
177
+ if (command._tag !== "McpPlaywrightUp") {
178
+ throw new Error("expected McpPlaywrightUp command")
179
+ }
180
+ expect(command.projectDir).toBe(".docker-git/org/repo")
181
+ }))
182
+
155
183
  it.effect("parses down-all command", () =>
156
184
  Effect.sync(() => {
157
185
  const command = parseOrThrow(["down-all"])