@prover-coder-ai/docker-git 1.0.15 → 1.0.16

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @prover-coder-ai/docker-git
2
2
 
3
+ ## 1.0.16
4
+
5
+ ### Patch Changes
6
+
7
+ - chore: automated version bump
8
+
3
9
  ## 1.0.15
4
10
 
5
11
  ### Patch Changes
@@ -6,7 +6,7 @@ import { downAllDockerGitProjects, listProjectItems, listProjectStatus, listRunn
6
6
  import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up";
7
7
  import { Effect, Match, pipe } from "effect";
8
8
  import { startCreateView } from "./menu-create.js";
9
- import { loadSelectView } from "./menu-select.js";
9
+ import { loadSelectView } from "./menu-select-load.js";
10
10
  import { resumeTui, suspendTui } from "./menu-shared.js";
11
11
  import {} from "./menu-types.js";
12
12
  // CHANGE: keep menu actions and input parsing in a dedicated module
@@ -10,17 +10,31 @@ const formatRepoRef = (repoRef) => {
10
10
  }
11
11
  return trimmed.length > 0 ? trimmed : "main";
12
12
  };
13
- const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
13
+ const stoppedRuntime = () => ({
14
+ running: false,
15
+ sshSessions: 0,
16
+ startedAtIso: null,
17
+ startedAtEpochMs: null
18
+ });
19
+ const pad2 = (value) => value.toString().padStart(2, "0");
20
+ const formatUtcTimestamp = (epochMs, withSeconds) => {
21
+ const date = new Date(epochMs);
22
+ const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : "";
23
+ return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}${seconds} UTC`;
24
+ };
25
+ const renderStartedAtCompact = (runtime) => runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false);
26
+ const renderStartedAtDetailed = (runtime) => runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true);
14
27
  const runtimeForProject = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? stoppedRuntime();
15
- const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`;
28
+ const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${renderStartedAtCompact(runtime)}`;
16
29
  export const selectTitle = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "docker-git / Select project"), Match.when("Down", () => "docker-git / Stop container"), Match.when("Info", () => "docker-git / Show connection info"), Match.when("Delete", () => "docker-git / Delete project"), Match.exhaustive);
17
30
  export const selectHint = (purpose, connectEnableMcpPlaywright) => Match.value(purpose).pipe(Match.when("Connect", () => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`), Match.when("Down", () => "Enter = stop container, Esc = back"), Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), Match.exhaustive);
18
31
  export const buildSelectLabels = (items, selected, purpose, runtimeByProject) => items.map((item, index) => {
19
32
  const prefix = index === selected ? ">" : " ";
20
33
  const refLabel = formatRepoRef(item.repoRef);
34
+ const runtime = runtimeForProject(runtimeByProject, item);
21
35
  const runtimeSuffix = purpose === "Down" || purpose === "Delete"
22
- ? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
23
- : "";
36
+ ? ` [${renderRuntimeLabel(runtime)}]`
37
+ : ` [started=${renderStartedAtCompact(runtime)}]`;
24
38
  return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`;
25
39
  });
26
40
  const buildDetailsContext = (item, runtimeByProject) => {
@@ -40,6 +54,7 @@ const commonRows = (el, context) => [
40
54
  el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
41
55
  el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
42
56
  el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
57
+ el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
43
58
  el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
44
59
  ];
45
60
  const renderInfoDetails = (el, context, common) => [
@@ -0,0 +1,12 @@
1
+ import { Effect, pipe } from "effect";
2
+ import { loadRuntimeByProject } from "./menu-select-runtime.js";
3
+ import { startSelectView } from "./menu-select.js";
4
+ export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
5
+ if (items.length === 0) {
6
+ context.setMessage(purpose === "Down"
7
+ ? "No running docker-git containers."
8
+ : "No docker-git projects found.");
9
+ return;
10
+ }
11
+ startSelectView(items, purpose, context, runtimeByProject);
12
+ })))));
@@ -0,0 +1,21 @@
1
+ const defaultRuntime = () => ({
2
+ running: false,
3
+ sshSessions: 0,
4
+ startedAtIso: null,
5
+ startedAtEpochMs: null
6
+ });
7
+ const runtimeForSort = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? defaultRuntime();
8
+ const startedAtEpochForSort = (runtime) => runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY;
9
+ export const sortItemsByLaunchTime = (items, runtimeByProject) => items.toSorted((left, right) => {
10
+ const leftRuntime = runtimeForSort(runtimeByProject, left);
11
+ const rightRuntime = runtimeForSort(runtimeByProject, right);
12
+ const leftStartedAt = startedAtEpochForSort(leftRuntime);
13
+ const rightStartedAt = startedAtEpochForSort(rightRuntime);
14
+ if (leftStartedAt !== rightStartedAt) {
15
+ return rightStartedAt - leftStartedAt;
16
+ }
17
+ if (leftRuntime.running !== rightRuntime.running) {
18
+ return leftRuntime.running ? -1 : 1;
19
+ }
20
+ return left.displayName.localeCompare(right.displayName);
21
+ });
@@ -2,8 +2,14 @@ import { runCommandCapture } from "@effect-template/lib/shell/command-runner";
2
2
  import { runDockerPsNames } from "@effect-template/lib/shell/docker";
3
3
  import { Effect, pipe } from "effect";
4
4
  const emptyRuntimeByProject = () => ({});
5
- const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
5
+ const stoppedRuntime = () => ({
6
+ running: false,
7
+ sshSessions: 0,
8
+ startedAtIso: null,
9
+ startedAtEpochMs: null
10
+ });
6
11
  const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'";
12
+ const dockerZeroStartedAt = "0001-01-01T00:00:00Z";
7
13
  const parseSshSessionCount = (raw) => {
8
14
  const parsed = Number.parseInt(raw.trim(), 10);
9
15
  if (Number.isNaN(parsed) || parsed < 0) {
@@ -11,6 +17,20 @@ const parseSshSessionCount = (raw) => {
11
17
  }
12
18
  return parsed;
13
19
  };
20
+ const parseContainerStartedAt = (raw) => {
21
+ const trimmed = raw.trim();
22
+ if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
23
+ return null;
24
+ }
25
+ const startedAtEpochMs = Date.parse(trimmed);
26
+ if (Number.isNaN(startedAtEpochMs)) {
27
+ return null;
28
+ }
29
+ return {
30
+ startedAtIso: trimmed,
31
+ startedAtEpochMs
32
+ };
33
+ };
14
34
  const toRuntimeMap = (entries) => {
15
35
  const runtimeByProject = {};
16
36
  for (const [projectDir, runtime] of entries) {
@@ -26,23 +46,35 @@ const countContainerSshSessions = (containerName) => pipe(runCommandCapture({
26
46
  onFailure: () => 0,
27
47
  onSuccess: (raw) => parseSshSessionCount(raw)
28
48
  }));
49
+ const inspectContainerStartedAt = (containerName) => pipe(runCommandCapture({
50
+ cwd: process.cwd(),
51
+ command: "docker",
52
+ args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
53
+ }, [0], (exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })), Effect.match({
54
+ onFailure: () => null,
55
+ onSuccess: (raw) => parseContainerStartedAt(raw)
56
+ }));
29
57
  // CHANGE: enrich select items with runtime state and SSH session counts
30
58
  // WHY: prevent stopping/deleting containers that are currently used via SSH
31
59
  // QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
32
60
  // REF: issue-47
33
61
  // SOURCE: n/a
34
- // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
62
+ // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
35
63
  // PURITY: SHELL
36
64
  // EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
37
- // INVARIANT: stopped containers always have sshSessions = 0
38
- // COMPLEXITY: O(n + docker_ps + docker_exec)
65
+ // INVARIANT: projects without a known container start have startedAt = null
66
+ // COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
39
67
  export const loadRuntimeByProject = (items) => pipe(runDockerPsNames(process.cwd()), Effect.flatMap((runningNames) => Effect.forEach(items, (item) => {
40
68
  const running = runningNames.includes(item.containerName);
41
- if (!running) {
42
- const entry = [item.projectDir, stoppedRuntime()];
43
- return Effect.succeed(entry);
44
- }
45
- return pipe(countContainerSshSessions(item.containerName), Effect.map((sshSessions) => ({ running: true, sshSessions })), Effect.map((runtime) => [item.projectDir, runtime]));
69
+ const sshSessionsEffect = running
70
+ ? countContainerSshSessions(item.containerName)
71
+ : Effect.succeed(0);
72
+ return pipe(Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]), Effect.map(([sshSessions, startedAt]) => ({
73
+ running,
74
+ sshSessions,
75
+ startedAtIso: startedAt?.startedAtIso ?? null,
76
+ startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
77
+ })), Effect.map((runtime) => [item.projectDir, runtime]));
46
78
  }, { concurrency: 4 })), Effect.map((entries) => toRuntimeMap(entries)), Effect.match({
47
79
  onFailure: () => emptyRuntimeByProject(),
48
80
  onSuccess: (runtimeByProject) => runtimeByProject
@@ -3,15 +3,17 @@ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
3
3
  import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
4
4
  import { Effect, Match, pipe } from "effect";
5
5
  import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js";
6
+ import { sortItemsByLaunchTime } from "./menu-select-order.js";
6
7
  import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js";
7
8
  import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js";
8
9
  const emptyRuntimeByProject = () => ({});
9
10
  export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
11
+ const sortedItems = sortItemsByLaunchTime(items, runtimeByProject);
10
12
  context.setMessage(null);
11
13
  context.setView({
12
14
  _tag: "SelectProject",
13
15
  purpose,
14
- items,
16
+ items: sortedItems,
15
17
  runtimeByProject,
16
18
  selected: 0,
17
19
  confirmDelete: false,
@@ -158,12 +160,3 @@ const handleSelectReturn = (view, context) => {
158
160
  runDeleteSelection(selected, context);
159
161
  }), Match.exhaustive);
160
162
  };
161
- export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
162
- if (items.length === 0) {
163
- context.setMessage(purpose === "Down"
164
- ? "No running docker-git containers."
165
- : "No docker-git projects found.");
166
- return;
167
- }
168
- startSelectView(items, purpose, context, runtimeByProject);
169
- })))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/p
13
13
  import { Effect, Match, pipe } from "effect"
14
14
 
15
15
  import { startCreateView } from "./menu-create.js"
16
- import { loadSelectView } from "./menu-select.js"
16
+ import { loadSelectView } from "./menu-select-load.js"
17
17
  import { resumeTui, suspendTui } from "./menu-shared.js"
18
18
  import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"
19
19
 
@@ -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,7 +49,11 @@ 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(
@@ -61,9 +88,10 @@ export const buildSelectLabels = (
61
88
  items.map((item, index) => {
62
89
  const prefix = index === selected ? ">" : " "
63
90
  const refLabel = formatRepoRef(item.repoRef)
91
+ const runtime = runtimeForProject(runtimeByProject, item)
64
92
  const runtimeSuffix = purpose === "Down" || purpose === "Delete"
65
- ? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
66
- : ""
93
+ ? ` [${renderRuntimeLabel(runtime)}]`
94
+ : ` [started=${renderStartedAtCompact(runtime)}]`
67
95
  return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
68
96
  })
69
97
 
@@ -101,6 +129,7 @@ const commonRows = (
101
129
  el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
102
130
  el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
103
131
  el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
132
+ el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
104
133
  el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
105
134
  ]
106
135
 
@@ -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",
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
  },
@@ -7,10 +7,9 @@ import {
7
7
  listRunningProjectItems,
8
8
  type ProjectItem
9
9
  } from "@effect-template/lib/usecases/projects"
10
-
11
10
  import { Effect, Match, pipe } from "effect"
12
-
13
11
  import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
12
+ import { sortItemsByLaunchTime } from "./menu-select-order.js"
14
13
  import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
15
14
  import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
16
15
  import type {
@@ -37,11 +36,12 @@ export const startSelectView = (
37
36
  context: Pick<SelectContext, "setView" | "setMessage">,
38
37
  runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
39
38
  ) => {
39
+ const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
40
40
  context.setMessage(null)
41
41
  context.setView({
42
42
  _tag: "SelectProject",
43
43
  purpose,
44
- items,
44
+ items: sortedItems,
45
45
  runtimeByProject,
46
46
  selected: 0,
47
47
  confirmDelete: false,
@@ -289,30 +289,3 @@ const handleSelectReturn = (
289
289
  Match.exhaustive
290
290
  )
291
291
  }
292
-
293
- export const loadSelectView = <E>(
294
- effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
295
- purpose: "Connect" | "Down" | "Info" | "Delete",
296
- context: Pick<SelectContext, "setView" | "setMessage">
297
- ): Effect.Effect<void, E, MenuEnv> =>
298
- pipe(
299
- effect,
300
- Effect.flatMap((items) =>
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
- )
317
- )
318
- )
@@ -86,6 +86,8 @@ export type ViewState =
86
86
  export type SelectProjectRuntime = {
87
87
  readonly running: boolean
88
88
  readonly sshSessions: number
89
+ readonly startedAtIso: string | null
90
+ readonly startedAtEpochMs: number | null
89
91
  }
90
92
 
91
93
  export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js"
4
+ import { sortItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js"
5
+ import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js"
6
+ import { makeProjectItem } from "./fixtures/project-item.js"
7
+
8
+ const makeRuntime = (
9
+ overrides: Partial<SelectProjectRuntime> = {}
10
+ ): SelectProjectRuntime => ({
11
+ running: false,
12
+ sshSessions: 0,
13
+ startedAtIso: null,
14
+ startedAtEpochMs: null,
15
+ ...overrides
16
+ })
17
+
18
+ const emitProof = (message: string): void => {
19
+ process.stdout.write(`[issue-57-proof] ${message}\n`)
20
+ }
21
+
22
+ describe("menu-select order", () => {
23
+ it("sorts projects by last container start time (newest first)", () => {
24
+ const newest = makeProjectItem({ projectDir: "/home/dev/.docker-git/newest", displayName: "org/newest" })
25
+ const older = makeProjectItem({ projectDir: "/home/dev/.docker-git/older", displayName: "org/older" })
26
+ const neverStarted = makeProjectItem({ projectDir: "/home/dev/.docker-git/never", displayName: "org/never" })
27
+ const startedNewest = "2026-02-17T11:30:00Z"
28
+ const startedOlder = "2026-02-16T07:15:00Z"
29
+ const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
30
+ [newest.projectDir]: makeRuntime({
31
+ running: true,
32
+ sshSessions: 1,
33
+ startedAtIso: startedNewest,
34
+ startedAtEpochMs: Date.parse(startedNewest)
35
+ }),
36
+ [older.projectDir]: makeRuntime({
37
+ running: true,
38
+ sshSessions: 0,
39
+ startedAtIso: startedOlder,
40
+ startedAtEpochMs: Date.parse(startedOlder)
41
+ }),
42
+ [neverStarted.projectDir]: makeRuntime()
43
+ }
44
+
45
+ const sorted = sortItemsByLaunchTime([neverStarted, older, newest], runtimeByProject)
46
+ expect(sorted.map((item) => item.projectDir)).toEqual([
47
+ newest.projectDir,
48
+ older.projectDir,
49
+ neverStarted.projectDir
50
+ ])
51
+ emitProof("sorting by launch time works: newest container is selected first")
52
+ })
53
+
54
+ it("shows container launch timestamp in select labels", () => {
55
+ const item = makeProjectItem({ projectDir: "/home/dev/.docker-git/example", displayName: "org/example" })
56
+ const startedAtIso = "2026-02-17T09:45:00Z"
57
+ const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
58
+ [item.projectDir]: makeRuntime({
59
+ running: true,
60
+ sshSessions: 2,
61
+ startedAtIso,
62
+ startedAtEpochMs: Date.parse(startedAtIso)
63
+ })
64
+ }
65
+
66
+ const connectLabel = buildSelectLabels([item], 0, "Connect", runtimeByProject)[0]
67
+ const downLabel = buildSelectLabels([item], 0, "Down", runtimeByProject)[0]
68
+
69
+ expect(connectLabel).toContain("[started=2026-02-17 09:45 UTC]")
70
+ expect(downLabel).toContain("running, ssh=2, started=2026-02-17 09:45 UTC")
71
+ emitProof("UI labels show container start timestamp in Connect and Down views")
72
+ })
73
+ })