@prover-coder-ai/docker-git 1.0.15 → 1.0.17
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/.package.json.release.bak +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +5 -6
- package/dist/main.js +24 -7
- package/dist/main.js.map +1 -1
- package/dist/src/docker-git/cli/parser-auth.js +32 -12
- package/dist/src/docker-git/cli/parser.js +1 -1
- package/dist/src/docker-git/cli/usage.js +4 -3
- package/dist/src/docker-git/menu-actions.js +24 -8
- package/dist/src/docker-git/menu-auth-data.js +90 -0
- package/dist/src/docker-git/menu-auth-helpers.js +20 -0
- package/dist/src/docker-git/menu-auth.js +159 -0
- package/dist/src/docker-git/menu-buffer-input.js +9 -0
- package/dist/src/docker-git/menu-create.js +5 -9
- package/dist/src/docker-git/menu-input-handler.js +70 -28
- package/dist/src/docker-git/menu-input-utils.js +47 -0
- package/dist/src/docker-git/menu-labeled-env.js +33 -0
- package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
- package/dist/src/docker-git/menu-project-auth-data.js +165 -0
- package/dist/src/docker-git/menu-project-auth.js +124 -0
- package/dist/src/docker-git/menu-render-auth.js +45 -0
- package/dist/src/docker-git/menu-render-common.js +26 -0
- package/dist/src/docker-git/menu-render-layout.js +14 -0
- package/dist/src/docker-git/menu-render-project-auth.js +37 -0
- package/dist/src/docker-git/menu-render-select.js +29 -7
- package/dist/src/docker-git/menu-render.js +4 -13
- package/dist/src/docker-git/menu-select-actions.js +66 -0
- package/dist/src/docker-git/menu-select-load.js +12 -0
- package/dist/src/docker-git/menu-select-order.js +21 -0
- package/dist/src/docker-git/menu-select-runtime.js +41 -9
- package/dist/src/docker-git/menu-select-view.js +15 -0
- package/dist/src/docker-git/menu-select.js +11 -82
- package/dist/src/docker-git/menu-shared.js +86 -17
- package/dist/src/docker-git/menu-types.js +2 -0
- package/dist/src/docker-git/menu.js +13 -1
- package/dist/src/docker-git/program.js +3 -3
- package/package.json +1 -1
- package/src/docker-git/cli/parser-auth.ts +46 -16
- package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
- package/src/docker-git/cli/parser.ts +1 -1
- package/src/docker-git/cli/usage.ts +4 -3
- package/src/docker-git/menu-actions.ts +32 -13
- package/src/docker-git/menu-auth-data.ts +184 -0
- package/src/docker-git/menu-auth-helpers.ts +30 -0
- package/src/docker-git/menu-auth.ts +311 -0
- package/src/docker-git/menu-buffer-input.ts +18 -0
- package/src/docker-git/menu-create.ts +5 -11
- package/src/docker-git/menu-input-handler.ts +104 -28
- package/src/docker-git/menu-input-utils.ts +85 -0
- package/src/docker-git/menu-labeled-env.ts +37 -0
- package/src/docker-git/menu-project-auth-claude.ts +70 -0
- package/src/docker-git/menu-project-auth-data.ts +292 -0
- package/src/docker-git/menu-project-auth.ts +271 -0
- package/src/docker-git/menu-render-auth.ts +65 -0
- package/src/docker-git/menu-render-common.ts +67 -0
- package/src/docker-git/menu-render-layout.ts +30 -0
- package/src/docker-git/menu-render-project-auth.ts +70 -0
- package/src/docker-git/menu-render-select.ts +44 -5
- package/src/docker-git/menu-render.ts +5 -29
- package/src/docker-git/menu-select-actions.ts +150 -0
- package/src/docker-git/menu-select-load.ts +33 -0
- package/src/docker-git/menu-select-order.ts +37 -0
- package/src/docker-git/menu-select-runtime.ts +59 -10
- package/src/docker-git/menu-select-view.ts +25 -0
- package/src/docker-git/menu-select.ts +22 -195
- package/src/docker-git/menu-shared.ts +135 -20
- package/src/docker-git/menu-types.ts +71 -2
- package/src/docker-git/menu.ts +26 -1
- package/src/docker-git/program.ts +10 -4
- package/tests/docker-git/entrypoint-auth.test.ts +1 -1
- package/tests/docker-git/menu-select-order.test.ts +73 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Box, Text } from "ink"
|
|
2
|
+
import React from "react"
|
|
3
|
+
|
|
4
|
+
const renderMessage = (message: string | null): React.ReactElement | null => {
|
|
5
|
+
if (!message) {
|
|
6
|
+
return null
|
|
7
|
+
}
|
|
8
|
+
return React.createElement(
|
|
9
|
+
Box,
|
|
10
|
+
{ marginTop: 1 },
|
|
11
|
+
React.createElement(Text, { color: "magenta" }, message)
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const renderLayout = (
|
|
16
|
+
title: string,
|
|
17
|
+
body: ReadonlyArray<React.ReactElement>,
|
|
18
|
+
message: string | null
|
|
19
|
+
): React.ReactElement => {
|
|
20
|
+
const el = React.createElement
|
|
21
|
+
const messageView = renderMessage(message)
|
|
22
|
+
const tail = messageView ? [messageView] : []
|
|
23
|
+
return el(
|
|
24
|
+
Box,
|
|
25
|
+
{ flexDirection: "column", padding: 1, borderStyle: "round" },
|
|
26
|
+
el(Text, { color: "cyan", bold: true }, title),
|
|
27
|
+
...body,
|
|
28
|
+
...tail
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Box, Text } from "ink"
|
|
2
|
+
import React from "react"
|
|
3
|
+
|
|
4
|
+
import { projectAuthMenuLabels, projectAuthViewSteps } from "./menu-project-auth-data.js"
|
|
5
|
+
import {
|
|
6
|
+
renderMenuHelp,
|
|
7
|
+
renderPromptLayout,
|
|
8
|
+
renderSelectableMenuList,
|
|
9
|
+
resolvePromptState
|
|
10
|
+
} from "./menu-render-common.js"
|
|
11
|
+
import { renderLayout } from "./menu-render-layout.js"
|
|
12
|
+
import type { ProjectAuthSnapshot, ViewState } from "./menu-types.js"
|
|
13
|
+
|
|
14
|
+
const renderActiveLabel = (value: string | null): string => value ?? "(not set)"
|
|
15
|
+
|
|
16
|
+
const renderCountLine = (title: string, count: number): string => `${title}: ${count}`
|
|
17
|
+
|
|
18
|
+
export const renderProjectAuthMenu = (
|
|
19
|
+
snapshot: ProjectAuthSnapshot,
|
|
20
|
+
selected: number,
|
|
21
|
+
message: string | null
|
|
22
|
+
): React.ReactElement => {
|
|
23
|
+
const el = React.createElement
|
|
24
|
+
const list = renderSelectableMenuList(projectAuthMenuLabels(), selected)
|
|
25
|
+
|
|
26
|
+
return renderLayout(
|
|
27
|
+
"docker-git / Project auth",
|
|
28
|
+
[
|
|
29
|
+
el(Text, null, `Project: ${snapshot.projectName}`),
|
|
30
|
+
el(Text, { color: "gray" }, `Dir: ${snapshot.projectDir}`),
|
|
31
|
+
el(Text, { color: "gray" }, `Project env: ${snapshot.envProjectPath}`),
|
|
32
|
+
el(Text, { color: "gray" }, `Global env: ${snapshot.envGlobalPath}`),
|
|
33
|
+
el(Text, { color: "gray" }, `Claude auth: ${snapshot.claudeAuthPath}`),
|
|
34
|
+
el(
|
|
35
|
+
Box,
|
|
36
|
+
{ marginTop: 1, flexDirection: "column" },
|
|
37
|
+
el(Text, { color: "gray" }, `GitHub label: ${renderActiveLabel(snapshot.activeGithubLabel)}`),
|
|
38
|
+
el(Text, { color: "gray" }, renderCountLine("Available GitHub tokens", snapshot.githubTokenEntries)),
|
|
39
|
+
el(Text, { color: "gray" }, `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}`),
|
|
40
|
+
el(Text, { color: "gray" }, renderCountLine("Available Git tokens", snapshot.gitTokenEntries)),
|
|
41
|
+
el(Text, { color: "gray" }, `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}`),
|
|
42
|
+
el(Text, { color: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries))
|
|
43
|
+
),
|
|
44
|
+
el(Box, { flexDirection: "column", marginTop: 1 }, ...list),
|
|
45
|
+
renderMenuHelp("Use arrows + Enter, or type a number from the list.")
|
|
46
|
+
],
|
|
47
|
+
message
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const renderProjectAuthPrompt = (
|
|
52
|
+
view: Extract<ViewState, { readonly _tag: "ProjectAuthPrompt" }>,
|
|
53
|
+
message: string | null
|
|
54
|
+
): React.ReactElement => {
|
|
55
|
+
const el = React.createElement
|
|
56
|
+
const { prompt, visibleBuffer } = resolvePromptState(projectAuthViewSteps(view.flow), view.step, view.buffer)
|
|
57
|
+
|
|
58
|
+
return renderPromptLayout({
|
|
59
|
+
title: "docker-git / Project auth / Set label",
|
|
60
|
+
header: [
|
|
61
|
+
el(Text, { color: "gray" }, `Project: ${view.snapshot.projectName}`),
|
|
62
|
+
el(Text, { color: "gray" }, `Project env: ${view.snapshot.envProjectPath}`),
|
|
63
|
+
el(Text, { color: "gray" }, `Global env: ${view.snapshot.envGlobalPath}`)
|
|
64
|
+
],
|
|
65
|
+
prompt,
|
|
66
|
+
visibleBuffer,
|
|
67
|
+
helpLine: "Enter = apply, Esc = cancel.",
|
|
68
|
+
message
|
|
69
|
+
})
|
|
70
|
+
}
|
|
@@ -5,7 +5,7 @@ import type React from "react"
|
|
|
5
5
|
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
6
6
|
import type { SelectProjectRuntime } from "./menu-types.js"
|
|
7
7
|
|
|
8
|
-
export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete"
|
|
8
|
+
export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete" | "Auth"
|
|
9
9
|
|
|
10
10
|
const formatRepoRef = (repoRef: string): string => {
|
|
11
11
|
const trimmed = repoRef.trim()
|
|
@@ -18,7 +18,30 @@ const formatRepoRef = (repoRef: string): string => {
|
|
|
18
18
|
return trimmed.length > 0 ? trimmed : "main"
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
21
|
+
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
22
|
+
running: false,
|
|
23
|
+
sshSessions: 0,
|
|
24
|
+
startedAtIso: null,
|
|
25
|
+
startedAtEpochMs: null
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const pad2 = (value: number): string => value.toString().padStart(2, "0")
|
|
29
|
+
|
|
30
|
+
const formatUtcTimestamp = (epochMs: number, withSeconds: boolean): string => {
|
|
31
|
+
const date = new Date(epochMs)
|
|
32
|
+
const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : ""
|
|
33
|
+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${
|
|
34
|
+
pad2(
|
|
35
|
+
date.getUTCHours()
|
|
36
|
+
)
|
|
37
|
+
}:${pad2(date.getUTCMinutes())}${seconds} UTC`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const renderStartedAtCompact = (runtime: SelectProjectRuntime): string =>
|
|
41
|
+
runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false)
|
|
42
|
+
|
|
43
|
+
const renderStartedAtDetailed = (runtime: SelectProjectRuntime): string =>
|
|
44
|
+
runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true)
|
|
22
45
|
|
|
23
46
|
const runtimeForProject = (
|
|
24
47
|
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
|
|
@@ -26,11 +49,16 @@ const runtimeForProject = (
|
|
|
26
49
|
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime()
|
|
27
50
|
|
|
28
51
|
const renderRuntimeLabel = (runtime: SelectProjectRuntime): string =>
|
|
29
|
-
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}
|
|
52
|
+
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${
|
|
53
|
+
renderStartedAtCompact(
|
|
54
|
+
runtime
|
|
55
|
+
)
|
|
56
|
+
}`
|
|
30
57
|
|
|
31
58
|
export const selectTitle = (purpose: SelectPurpose): string =>
|
|
32
59
|
Match.value(purpose).pipe(
|
|
33
60
|
Match.when("Connect", () => "docker-git / Select project"),
|
|
61
|
+
Match.when("Auth", () => "docker-git / Project auth"),
|
|
34
62
|
Match.when("Down", () => "docker-git / Stop container"),
|
|
35
63
|
Match.when("Info", () => "docker-git / Show connection info"),
|
|
36
64
|
Match.when("Delete", () => "docker-git / Delete project"),
|
|
@@ -46,6 +74,7 @@ export const selectHint = (
|
|
|
46
74
|
"Connect",
|
|
47
75
|
() => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`
|
|
48
76
|
),
|
|
77
|
+
Match.when("Auth", () => "Enter = open project auth menu, Esc = back"),
|
|
49
78
|
Match.when("Down", () => "Enter = stop container, Esc = back"),
|
|
50
79
|
Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"),
|
|
51
80
|
Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"),
|
|
@@ -61,9 +90,10 @@ export const buildSelectLabels = (
|
|
|
61
90
|
items.map((item, index) => {
|
|
62
91
|
const prefix = index === selected ? ">" : " "
|
|
63
92
|
const refLabel = formatRepoRef(item.repoRef)
|
|
93
|
+
const runtime = runtimeForProject(runtimeByProject, item)
|
|
64
94
|
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
|
|
65
|
-
? ` [${renderRuntimeLabel(
|
|
66
|
-
:
|
|
95
|
+
? ` [${renderRuntimeLabel(runtime)}]`
|
|
96
|
+
: ` [started=${renderStartedAtCompact(runtime)}]`
|
|
67
97
|
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
|
|
68
98
|
})
|
|
69
99
|
|
|
@@ -101,6 +131,7 @@ const commonRows = (
|
|
|
101
131
|
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
|
|
102
132
|
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
|
|
103
133
|
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
|
|
134
|
+
el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
|
|
104
135
|
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
|
|
105
136
|
]
|
|
106
137
|
|
|
@@ -167,6 +198,14 @@ export const renderSelectDetails = (
|
|
|
167
198
|
|
|
168
199
|
return Match.value(purpose).pipe(
|
|
169
200
|
Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)),
|
|
201
|
+
Match.when("Auth", () => [
|
|
202
|
+
titleRow(el, "Project auth"),
|
|
203
|
+
...common,
|
|
204
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
205
|
+
el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
|
|
206
|
+
el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
|
|
207
|
+
el(Text, { color: "gray", wrap: "wrap" }, "Press Enter to manage labels for this project.")
|
|
208
|
+
]),
|
|
170
209
|
Match.when("Info", () => renderInfoDetails(el, context, common)),
|
|
171
210
|
Match.when("Down", () => [
|
|
172
211
|
titleRow(el, "Stop container"),
|
|
@@ -3,6 +3,7 @@ import { Box, Text } from "ink"
|
|
|
3
3
|
import React from "react"
|
|
4
4
|
|
|
5
5
|
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
6
|
+
import { renderLayout } from "./menu-render-layout.js"
|
|
6
7
|
import {
|
|
7
8
|
buildSelectLabels,
|
|
8
9
|
renderSelectDetails,
|
|
@@ -41,34 +42,6 @@ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): strin
|
|
|
41
42
|
Match.exhaustive
|
|
42
43
|
)
|
|
43
44
|
|
|
44
|
-
const renderMessage = (message: string | null): React.ReactElement | null => {
|
|
45
|
-
if (!message) {
|
|
46
|
-
return null
|
|
47
|
-
}
|
|
48
|
-
return React.createElement(
|
|
49
|
-
Box,
|
|
50
|
-
{ marginTop: 1 },
|
|
51
|
-
React.createElement(Text, { color: "magenta" }, message)
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const renderLayout = (
|
|
56
|
-
title: string,
|
|
57
|
-
body: ReadonlyArray<React.ReactElement>,
|
|
58
|
-
message: string | null
|
|
59
|
-
): React.ReactElement => {
|
|
60
|
-
const el = React.createElement
|
|
61
|
-
const messageView = renderMessage(message)
|
|
62
|
-
const tail = messageView ? [messageView] : []
|
|
63
|
-
return el(
|
|
64
|
-
Box,
|
|
65
|
-
{ flexDirection: "column", padding: 1, borderStyle: "round" },
|
|
66
|
-
el(Text, { color: "cyan", bold: true }, title),
|
|
67
|
-
...body,
|
|
68
|
-
...tail
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
45
|
const compactElements = (
|
|
73
46
|
items: ReadonlyArray<React.ReactElement | null>
|
|
74
47
|
): ReadonlyArray<React.ReactElement> => items.filter((item): item is React.ReactElement => item !== null)
|
|
@@ -82,7 +55,7 @@ const renderMenuHints = (el: typeof React.createElement): React.ReactElement =>
|
|
|
82
55
|
el(
|
|
83
56
|
Text,
|
|
84
57
|
{ color: "gray" },
|
|
85
|
-
" - Aliases: create/c, select/s, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q"
|
|
58
|
+
" - Aliases: create/c, select/s, auth/a, project-auth/pa, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q"
|
|
86
59
|
),
|
|
87
60
|
el(Text, { color: "gray" }, " - Use arrows and Enter to run.")
|
|
88
61
|
)
|
|
@@ -181,6 +154,9 @@ export const renderCreate = (
|
|
|
181
154
|
)
|
|
182
155
|
}
|
|
183
156
|
|
|
157
|
+
export { renderAuthMenu, renderAuthPrompt } from "./menu-render-auth.js"
|
|
158
|
+
export { renderProjectAuthMenu, renderProjectAuthPrompt } from "./menu-render-project-auth.js"
|
|
159
|
+
|
|
184
160
|
const computeListWidth = (labels: ReadonlyArray<string>): number => {
|
|
185
161
|
const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24
|
|
186
162
|
return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
|
|
2
|
+
import type { AppError } from "@effect-template/lib/usecases/errors"
|
|
3
|
+
import { renderError } from "@effect-template/lib/usecases/errors"
|
|
4
|
+
import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
|
|
5
|
+
import {
|
|
6
|
+
connectProjectSshWithUp,
|
|
7
|
+
deleteDockerGitProject,
|
|
8
|
+
listRunningProjectItems,
|
|
9
|
+
type ProjectItem
|
|
10
|
+
} from "@effect-template/lib/usecases/projects"
|
|
11
|
+
import { Effect, pipe } from "effect"
|
|
12
|
+
|
|
13
|
+
import { openProjectAuthMenu } from "./menu-project-auth.js"
|
|
14
|
+
import { buildConnectEffect } from "./menu-select-connect.js"
|
|
15
|
+
import { loadRuntimeByProject } from "./menu-select-runtime.js"
|
|
16
|
+
import { startSelectView } from "./menu-select-view.js"
|
|
17
|
+
import {
|
|
18
|
+
pauseOnError,
|
|
19
|
+
resetToMenu,
|
|
20
|
+
resumeSshWithSkipInputs,
|
|
21
|
+
resumeWithSkipInputs,
|
|
22
|
+
withSuspendedTui
|
|
23
|
+
} from "./menu-shared.js"
|
|
24
|
+
import type { MenuRunner, MenuViewContext } from "./menu-types.js"
|
|
25
|
+
|
|
26
|
+
export 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 runConnectSelection = (
|
|
34
|
+
selected: ProjectItem,
|
|
35
|
+
context: SelectContext,
|
|
36
|
+
enableMcpPlaywright: boolean
|
|
37
|
+
) => {
|
|
38
|
+
context.setMessage(
|
|
39
|
+
enableMcpPlaywright
|
|
40
|
+
? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
|
|
41
|
+
: `Connecting to ${selected.displayName}...`
|
|
42
|
+
)
|
|
43
|
+
context.setSshActive(true)
|
|
44
|
+
context.runner.runEffect(
|
|
45
|
+
pipe(
|
|
46
|
+
withSuspendedTui(
|
|
47
|
+
buildConnectEffect(selected, enableMcpPlaywright, {
|
|
48
|
+
connectWithUp: (item) =>
|
|
49
|
+
connectProjectSshWithUp(item).pipe(
|
|
50
|
+
Effect.mapError((error): AppError => error)
|
|
51
|
+
),
|
|
52
|
+
enableMcpPlaywright: (projectDir) =>
|
|
53
|
+
mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(
|
|
54
|
+
Effect.asVoid,
|
|
55
|
+
Effect.mapError((error): AppError => error)
|
|
56
|
+
)
|
|
57
|
+
}),
|
|
58
|
+
{
|
|
59
|
+
onError: pauseOnError(renderError),
|
|
60
|
+
onResume: resumeSshWithSkipInputs(context)
|
|
61
|
+
}
|
|
62
|
+
),
|
|
63
|
+
Effect.tap(() =>
|
|
64
|
+
Effect.sync(() => {
|
|
65
|
+
context.setMessage("SSH session ended. Press Esc to return to the menu.")
|
|
66
|
+
})
|
|
67
|
+
),
|
|
68
|
+
Effect.asVoid
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
|
|
74
|
+
context.setMessage(`Stopping ${selected.displayName}...`)
|
|
75
|
+
context.runner.runEffect(
|
|
76
|
+
withSuspendedTui(
|
|
77
|
+
pipe(
|
|
78
|
+
runDockerComposeDown(selected.projectDir),
|
|
79
|
+
Effect.zipRight(listRunningProjectItems),
|
|
80
|
+
Effect.flatMap((items) =>
|
|
81
|
+
pipe(
|
|
82
|
+
loadRuntimeByProject(items),
|
|
83
|
+
Effect.map((runtimeByProject) => ({ items, runtimeByProject }))
|
|
84
|
+
)
|
|
85
|
+
),
|
|
86
|
+
Effect.tap(({ items, runtimeByProject }) =>
|
|
87
|
+
Effect.sync(() => {
|
|
88
|
+
if (items.length === 0) {
|
|
89
|
+
resetToMenu(context)
|
|
90
|
+
context.setMessage("No running docker-git containers.")
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
startSelectView(items, "Down", context, runtimeByProject)
|
|
94
|
+
context.setMessage("Container stopped. Select another to stop, or Esc to return.")
|
|
95
|
+
})
|
|
96
|
+
),
|
|
97
|
+
Effect.asVoid
|
|
98
|
+
),
|
|
99
|
+
{
|
|
100
|
+
onError: pauseOnError(renderError),
|
|
101
|
+
onResume: resumeWithSkipInputs(context)
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const runInfoSelection = (selected: ProjectItem, context: SelectContext) => {
|
|
108
|
+
context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const runAuthSelection = (selected: ProjectItem, context: SelectContext) => {
|
|
112
|
+
openProjectAuthMenu({
|
|
113
|
+
project: selected,
|
|
114
|
+
runner: context.runner,
|
|
115
|
+
setView: context.setView,
|
|
116
|
+
setMessage: context.setMessage,
|
|
117
|
+
setActiveDir: context.setActiveDir
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => {
|
|
122
|
+
context.setMessage(`Deleting ${selected.displayName}...`)
|
|
123
|
+
context.runner.runEffect(
|
|
124
|
+
pipe(
|
|
125
|
+
withSuspendedTui(
|
|
126
|
+
deleteDockerGitProject(selected).pipe(
|
|
127
|
+
Effect.tap(() =>
|
|
128
|
+
Effect.sync(() => {
|
|
129
|
+
if (context.activeDir === selected.projectDir) {
|
|
130
|
+
context.setActiveDir(null)
|
|
131
|
+
}
|
|
132
|
+
context.setView({ _tag: "Menu" })
|
|
133
|
+
})
|
|
134
|
+
),
|
|
135
|
+
Effect.asVoid
|
|
136
|
+
),
|
|
137
|
+
{
|
|
138
|
+
onError: pauseOnError(renderError),
|
|
139
|
+
onResume: resumeWithSkipInputs(context)
|
|
140
|
+
}
|
|
141
|
+
),
|
|
142
|
+
Effect.tap(() =>
|
|
143
|
+
Effect.sync(() => {
|
|
144
|
+
context.setMessage("Project deleted.")
|
|
145
|
+
})
|
|
146
|
+
),
|
|
147
|
+
Effect.asVoid
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
2
|
+
import { Effect, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
import { loadRuntimeByProject } from "./menu-select-runtime.js"
|
|
5
|
+
import { startSelectView } from "./menu-select.js"
|
|
6
|
+
import type { MenuEnv, MenuViewContext } from "./menu-types.js"
|
|
7
|
+
|
|
8
|
+
export const loadSelectView = <E>(
|
|
9
|
+
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
|
|
10
|
+
purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth",
|
|
11
|
+
context: Pick<MenuViewContext, "setView" | "setMessage">
|
|
12
|
+
): Effect.Effect<void, E, MenuEnv> =>
|
|
13
|
+
pipe(
|
|
14
|
+
effect,
|
|
15
|
+
Effect.flatMap((items) =>
|
|
16
|
+
pipe(
|
|
17
|
+
loadRuntimeByProject(items),
|
|
18
|
+
Effect.flatMap((runtimeByProject) =>
|
|
19
|
+
Effect.sync(() => {
|
|
20
|
+
if (items.length === 0) {
|
|
21
|
+
context.setMessage(
|
|
22
|
+
purpose === "Down"
|
|
23
|
+
? "No running docker-git containers."
|
|
24
|
+
: "No docker-git projects found."
|
|
25
|
+
)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
startSelectView(items, purpose, context, runtimeByProject)
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
2
|
+
|
|
3
|
+
import type { SelectProjectRuntime } from "./menu-types.js"
|
|
4
|
+
|
|
5
|
+
const defaultRuntime = (): SelectProjectRuntime => ({
|
|
6
|
+
running: false,
|
|
7
|
+
sshSessions: 0,
|
|
8
|
+
startedAtIso: null,
|
|
9
|
+
startedAtEpochMs: null
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const runtimeForSort = (
|
|
13
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
|
|
14
|
+
item: ProjectItem
|
|
15
|
+
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? defaultRuntime()
|
|
16
|
+
|
|
17
|
+
const startedAtEpochForSort = (runtime: SelectProjectRuntime): number =>
|
|
18
|
+
runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY
|
|
19
|
+
|
|
20
|
+
export const sortItemsByLaunchTime = (
|
|
21
|
+
items: ReadonlyArray<ProjectItem>,
|
|
22
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
|
|
23
|
+
): ReadonlyArray<ProjectItem> =>
|
|
24
|
+
items.toSorted((left, right) => {
|
|
25
|
+
const leftRuntime = runtimeForSort(runtimeByProject, left)
|
|
26
|
+
const rightRuntime = runtimeForSort(runtimeByProject, right)
|
|
27
|
+
const leftStartedAt = startedAtEpochForSort(leftRuntime)
|
|
28
|
+
const rightStartedAt = startedAtEpochForSort(rightRuntime)
|
|
29
|
+
|
|
30
|
+
if (leftStartedAt !== rightStartedAt) {
|
|
31
|
+
return rightStartedAt - leftStartedAt
|
|
32
|
+
}
|
|
33
|
+
if (leftRuntime.running !== rightRuntime.running) {
|
|
34
|
+
return leftRuntime.running ? -1 : 1
|
|
35
|
+
}
|
|
36
|
+
return left.displayName.localeCompare(right.displayName)
|
|
37
|
+
})
|
|
@@ -7,9 +7,20 @@ import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js"
|
|
|
7
7
|
|
|
8
8
|
const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
|
|
9
9
|
|
|
10
|
-
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
10
|
+
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
11
|
+
running: false,
|
|
12
|
+
sshSessions: 0,
|
|
13
|
+
startedAtIso: null,
|
|
14
|
+
startedAtEpochMs: null
|
|
15
|
+
})
|
|
11
16
|
|
|
12
17
|
const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'"
|
|
18
|
+
const dockerZeroStartedAt = "0001-01-01T00:00:00Z"
|
|
19
|
+
|
|
20
|
+
type ContainerStartTime = {
|
|
21
|
+
readonly startedAtIso: string
|
|
22
|
+
readonly startedAtEpochMs: number
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
const parseSshSessionCount = (raw: string): number => {
|
|
15
26
|
const parsed = Number.parseInt(raw.trim(), 10)
|
|
@@ -19,6 +30,21 @@ const parseSshSessionCount = (raw: string): number => {
|
|
|
19
30
|
return parsed
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
const parseContainerStartedAt = (raw: string): ContainerStartTime | null => {
|
|
34
|
+
const trimmed = raw.trim()
|
|
35
|
+
if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
const startedAtEpochMs = Date.parse(trimmed)
|
|
39
|
+
if (Number.isNaN(startedAtEpochMs)) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
startedAtIso: trimmed,
|
|
44
|
+
startedAtEpochMs
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
const toRuntimeMap = (
|
|
23
49
|
entries: ReadonlyArray<readonly [string, SelectProjectRuntime]>
|
|
24
50
|
): Readonly<Record<string, SelectProjectRuntime>> => {
|
|
@@ -48,16 +74,35 @@ const countContainerSshSessions = (
|
|
|
48
74
|
})
|
|
49
75
|
)
|
|
50
76
|
|
|
77
|
+
const inspectContainerStartedAt = (
|
|
78
|
+
containerName: string
|
|
79
|
+
): Effect.Effect<ContainerStartTime | null, never, MenuEnv> =>
|
|
80
|
+
pipe(
|
|
81
|
+
runCommandCapture(
|
|
82
|
+
{
|
|
83
|
+
cwd: process.cwd(),
|
|
84
|
+
command: "docker",
|
|
85
|
+
args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
|
|
86
|
+
},
|
|
87
|
+
[0],
|
|
88
|
+
(exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })
|
|
89
|
+
),
|
|
90
|
+
Effect.match({
|
|
91
|
+
onFailure: () => null,
|
|
92
|
+
onSuccess: (raw) => parseContainerStartedAt(raw)
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
51
96
|
// CHANGE: enrich select items with runtime state and SSH session counts
|
|
52
97
|
// WHY: prevent stopping/deleting containers that are currently used via SSH
|
|
53
98
|
// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
|
|
54
99
|
// REF: issue-47
|
|
55
100
|
// SOURCE: n/a
|
|
56
|
-
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
|
|
101
|
+
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
|
|
57
102
|
// PURITY: SHELL
|
|
58
103
|
// EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
|
|
59
|
-
// INVARIANT:
|
|
60
|
-
// COMPLEXITY: O(n + docker_ps + docker_exec)
|
|
104
|
+
// INVARIANT: projects without a known container start have startedAt = null
|
|
105
|
+
// COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
|
|
61
106
|
export const loadRuntimeByProject = (
|
|
62
107
|
items: ReadonlyArray<ProjectItem>
|
|
63
108
|
): Effect.Effect<Readonly<Record<string, SelectProjectRuntime>>, never, MenuEnv> =>
|
|
@@ -68,13 +113,17 @@ export const loadRuntimeByProject = (
|
|
|
68
113
|
items,
|
|
69
114
|
(item) => {
|
|
70
115
|
const running = runningNames.includes(item.containerName)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
116
|
+
const sshSessionsEffect = running
|
|
117
|
+
? countContainerSshSessions(item.containerName)
|
|
118
|
+
: Effect.succeed(0)
|
|
75
119
|
return pipe(
|
|
76
|
-
|
|
77
|
-
Effect.map((sshSessions): SelectProjectRuntime => ({
|
|
120
|
+
Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]),
|
|
121
|
+
Effect.map(([sshSessions, startedAt]): SelectProjectRuntime => ({
|
|
122
|
+
running,
|
|
123
|
+
sshSessions,
|
|
124
|
+
startedAtIso: startedAt?.startedAtIso ?? null,
|
|
125
|
+
startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
|
|
126
|
+
})),
|
|
78
127
|
Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime])
|
|
79
128
|
)
|
|
80
129
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
2
|
+
|
|
3
|
+
import { sortItemsByLaunchTime } from "./menu-select-order.js"
|
|
4
|
+
import type { MenuViewContext, SelectProjectRuntime } from "./menu-types.js"
|
|
5
|
+
|
|
6
|
+
const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
|
|
7
|
+
|
|
8
|
+
export const startSelectView = (
|
|
9
|
+
items: ReadonlyArray<ProjectItem>,
|
|
10
|
+
purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth",
|
|
11
|
+
context: Pick<MenuViewContext, "setView" | "setMessage">,
|
|
12
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
|
|
13
|
+
) => {
|
|
14
|
+
const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
|
|
15
|
+
context.setMessage(null)
|
|
16
|
+
context.setView({
|
|
17
|
+
_tag: "SelectProject",
|
|
18
|
+
purpose,
|
|
19
|
+
items: sortedItems,
|
|
20
|
+
runtimeByProject,
|
|
21
|
+
selected: 0,
|
|
22
|
+
confirmDelete: false,
|
|
23
|
+
connectEnableMcpPlaywright: false
|
|
24
|
+
})
|
|
25
|
+
}
|