@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.
Files changed (71) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +5 -6
  4. package/dist/main.js +24 -7
  5. package/dist/main.js.map +1 -1
  6. package/dist/src/docker-git/cli/parser-auth.js +32 -12
  7. package/dist/src/docker-git/cli/parser.js +1 -1
  8. package/dist/src/docker-git/cli/usage.js +4 -3
  9. package/dist/src/docker-git/menu-actions.js +24 -8
  10. package/dist/src/docker-git/menu-auth-data.js +90 -0
  11. package/dist/src/docker-git/menu-auth-helpers.js +20 -0
  12. package/dist/src/docker-git/menu-auth.js +159 -0
  13. package/dist/src/docker-git/menu-buffer-input.js +9 -0
  14. package/dist/src/docker-git/menu-create.js +5 -9
  15. package/dist/src/docker-git/menu-input-handler.js +70 -28
  16. package/dist/src/docker-git/menu-input-utils.js +47 -0
  17. package/dist/src/docker-git/menu-labeled-env.js +33 -0
  18. package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
  19. package/dist/src/docker-git/menu-project-auth-data.js +165 -0
  20. package/dist/src/docker-git/menu-project-auth.js +124 -0
  21. package/dist/src/docker-git/menu-render-auth.js +45 -0
  22. package/dist/src/docker-git/menu-render-common.js +26 -0
  23. package/dist/src/docker-git/menu-render-layout.js +14 -0
  24. package/dist/src/docker-git/menu-render-project-auth.js +37 -0
  25. package/dist/src/docker-git/menu-render-select.js +29 -7
  26. package/dist/src/docker-git/menu-render.js +4 -13
  27. package/dist/src/docker-git/menu-select-actions.js +66 -0
  28. package/dist/src/docker-git/menu-select-load.js +12 -0
  29. package/dist/src/docker-git/menu-select-order.js +21 -0
  30. package/dist/src/docker-git/menu-select-runtime.js +41 -9
  31. package/dist/src/docker-git/menu-select-view.js +15 -0
  32. package/dist/src/docker-git/menu-select.js +11 -82
  33. package/dist/src/docker-git/menu-shared.js +86 -17
  34. package/dist/src/docker-git/menu-types.js +2 -0
  35. package/dist/src/docker-git/menu.js +13 -1
  36. package/dist/src/docker-git/program.js +3 -3
  37. package/package.json +1 -1
  38. package/src/docker-git/cli/parser-auth.ts +46 -16
  39. package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
  40. package/src/docker-git/cli/parser.ts +1 -1
  41. package/src/docker-git/cli/usage.ts +4 -3
  42. package/src/docker-git/menu-actions.ts +32 -13
  43. package/src/docker-git/menu-auth-data.ts +184 -0
  44. package/src/docker-git/menu-auth-helpers.ts +30 -0
  45. package/src/docker-git/menu-auth.ts +311 -0
  46. package/src/docker-git/menu-buffer-input.ts +18 -0
  47. package/src/docker-git/menu-create.ts +5 -11
  48. package/src/docker-git/menu-input-handler.ts +104 -28
  49. package/src/docker-git/menu-input-utils.ts +85 -0
  50. package/src/docker-git/menu-labeled-env.ts +37 -0
  51. package/src/docker-git/menu-project-auth-claude.ts +70 -0
  52. package/src/docker-git/menu-project-auth-data.ts +292 -0
  53. package/src/docker-git/menu-project-auth.ts +271 -0
  54. package/src/docker-git/menu-render-auth.ts +65 -0
  55. package/src/docker-git/menu-render-common.ts +67 -0
  56. package/src/docker-git/menu-render-layout.ts +30 -0
  57. package/src/docker-git/menu-render-project-auth.ts +70 -0
  58. package/src/docker-git/menu-render-select.ts +44 -5
  59. package/src/docker-git/menu-render.ts +5 -29
  60. package/src/docker-git/menu-select-actions.ts +150 -0
  61. package/src/docker-git/menu-select-load.ts +33 -0
  62. package/src/docker-git/menu-select-order.ts +37 -0
  63. package/src/docker-git/menu-select-runtime.ts +59 -10
  64. package/src/docker-git/menu-select-view.ts +25 -0
  65. package/src/docker-git/menu-select.ts +22 -195
  66. package/src/docker-git/menu-shared.ts +135 -20
  67. package/src/docker-git/menu-types.ts +71 -2
  68. package/src/docker-git/menu.ts +26 -1
  69. package/src/docker-git/program.ts +10 -4
  70. package/tests/docker-git/entrypoint-auth.test.ts +1 -1
  71. 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 => ({ running: false, sshSessions: 0 })
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(runtimeForProject(runtimeByProject, item))}]`
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 => ({ running: false, sshSessions: 0 })
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: stopped containers always have sshSessions = 0
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
- if (!running) {
72
- const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()]
73
- return Effect.succeed(entry)
74
- }
116
+ const sshSessionsEffect = running
117
+ ? countContainerSshSessions(item.containerName)
118
+ : Effect.succeed(0)
75
119
  return pipe(
76
- countContainerSshSessions(item.containerName),
77
- Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })),
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
+ }