@prover-coder-ai/docker-git 1.0.12 → 1.0.14
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/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/src/docker-git/menu-render-select.js +93 -0
- package/dist/src/docker-git/menu-render.js +24 -60
- package/dist/src/docker-git/menu-select-connect.js +6 -0
- package/dist/src/docker-git/menu-select-runtime.js +50 -0
- package/dist/src/docker-git/menu-select.js +56 -18
- package/dist/src/docker-git/menu.js +9 -1
- package/package.json +1 -1
- package/src/docker-git/menu-render-select.ts +187 -0
- package/src/docker-git/menu-render.ts +53 -102
- package/src/docker-git/menu-select-connect.ts +27 -0
- package/src/docker-git/menu-select-runtime.ts +94 -0
- package/src/docker-git/menu-select.ts +107 -39
- package/src/docker-git/menu-types.ts +7 -0
- package/src/docker-git/menu.ts +9 -7
- package/tests/docker-git/menu-select-connect.test.ts +64 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Match } from "effect";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
const formatRepoRef = (repoRef) => {
|
|
4
|
+
const trimmed = repoRef.trim();
|
|
5
|
+
const prPrefix = "refs/pull/";
|
|
6
|
+
if (trimmed.startsWith(prPrefix)) {
|
|
7
|
+
const rest = trimmed.slice(prPrefix.length);
|
|
8
|
+
const number = rest.split("/")[0] ?? rest;
|
|
9
|
+
return `PR#${number}`;
|
|
10
|
+
}
|
|
11
|
+
return trimmed.length > 0 ? trimmed : "main";
|
|
12
|
+
};
|
|
13
|
+
const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
|
|
14
|
+
const runtimeForProject = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? stoppedRuntime();
|
|
15
|
+
const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`;
|
|
16
|
+
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
|
+
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
|
+
export const buildSelectLabels = (items, selected, purpose, runtimeByProject) => items.map((item, index) => {
|
|
19
|
+
const prefix = index === selected ? ">" : " ";
|
|
20
|
+
const refLabel = formatRepoRef(item.repoRef);
|
|
21
|
+
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
|
|
22
|
+
? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
|
|
23
|
+
: "";
|
|
24
|
+
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`;
|
|
25
|
+
});
|
|
26
|
+
const buildDetailsContext = (item, runtimeByProject) => {
|
|
27
|
+
const runtime = runtimeForProject(runtimeByProject, item);
|
|
28
|
+
return {
|
|
29
|
+
item,
|
|
30
|
+
refLabel: formatRepoRef(item.repoRef),
|
|
31
|
+
authSuffix: item.authorizedKeysExists ? "" : " (missing)",
|
|
32
|
+
runtime,
|
|
33
|
+
sshSessionsLabel: runtime.sshSessions === 1
|
|
34
|
+
? "1 active SSH session"
|
|
35
|
+
: `${runtime.sshSessions} active SSH sessions`
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const titleRow = (el, value) => el(Text, { color: "cyan", bold: true, wrap: "truncate" }, value);
|
|
39
|
+
const commonRows = (el, context) => [
|
|
40
|
+
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
|
|
41
|
+
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
|
|
42
|
+
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
|
|
43
|
+
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
|
|
44
|
+
];
|
|
45
|
+
const renderInfoDetails = (el, context, common) => [
|
|
46
|
+
titleRow(el, "Connection info"),
|
|
47
|
+
...common,
|
|
48
|
+
el(Text, { wrap: "wrap" }, `Service: ${context.item.serviceName}`),
|
|
49
|
+
el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`),
|
|
50
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
51
|
+
el(Text, { wrap: "wrap" }, `Workspace: ${context.item.targetDir}`),
|
|
52
|
+
el(Text, { wrap: "wrap" }, `Authorized keys: ${context.item.authorizedKeysPath}${context.authSuffix}`),
|
|
53
|
+
el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
|
|
54
|
+
el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
|
|
55
|
+
el(Text, { wrap: "wrap" }, `Codex auth: ${context.item.codexAuthPath} -> ${context.item.codexHome}`)
|
|
56
|
+
];
|
|
57
|
+
const renderDefaultDetails = (el, context) => [
|
|
58
|
+
titleRow(el, "Details"),
|
|
59
|
+
el(Text, { wrap: "truncate" }, `Repo: ${context.item.repoUrl}`),
|
|
60
|
+
el(Text, { wrap: "truncate" }, `Ref: ${context.item.repoRef}`),
|
|
61
|
+
el(Text, { wrap: "truncate" }, `Project dir: ${context.item.projectDir}`),
|
|
62
|
+
el(Text, { wrap: "truncate" }, `Workspace: ${context.item.targetDir}`),
|
|
63
|
+
el(Text, { wrap: "truncate" }, `SSH: ${context.item.sshCommand}`)
|
|
64
|
+
];
|
|
65
|
+
const renderConnectDetails = (el, context, common, connectEnableMcpPlaywright) => [
|
|
66
|
+
titleRow(el, "Connect + SSH"),
|
|
67
|
+
...common,
|
|
68
|
+
el(Text, { color: connectEnableMcpPlaywright ? "green" : "gray", wrap: "wrap" }, connectEnableMcpPlaywright
|
|
69
|
+
? "Playwright MCP: will be enabled before SSH (P to disable)."
|
|
70
|
+
: "Playwright MCP: keep current project setting (P to enable before SSH)."),
|
|
71
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
72
|
+
el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`)
|
|
73
|
+
];
|
|
74
|
+
export const renderSelectDetails = (el, purpose, item, runtimeByProject, connectEnableMcpPlaywright) => {
|
|
75
|
+
if (!item) {
|
|
76
|
+
return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")];
|
|
77
|
+
}
|
|
78
|
+
const context = buildDetailsContext(item, runtimeByProject);
|
|
79
|
+
const common = commonRows(el, context);
|
|
80
|
+
return Match.value(purpose).pipe(Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)), Match.when("Info", () => renderInfoDetails(el, context, common)), Match.when("Down", () => [
|
|
81
|
+
titleRow(el, "Stop container"),
|
|
82
|
+
...common,
|
|
83
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`)
|
|
84
|
+
]), Match.when("Delete", () => [
|
|
85
|
+
titleRow(el, "Delete project"),
|
|
86
|
+
...common,
|
|
87
|
+
context.runtime.sshSessions > 0
|
|
88
|
+
? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.")
|
|
89
|
+
: el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."),
|
|
90
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
91
|
+
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
|
|
92
|
+
]), Match.orElse(() => renderDefaultDetails(el, context)));
|
|
93
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Match } from "effect";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import React from "react";
|
|
4
|
+
import { buildSelectLabels, renderSelectDetails, selectHint, selectTitle } from "./menu-render-select.js";
|
|
4
5
|
import { createSteps, menuItems } from "./menu-types.js";
|
|
5
6
|
// CHANGE: render menu views with Ink without JSX
|
|
6
7
|
// WHY: keep UI logic separate from input/state reducers
|
|
@@ -67,56 +68,6 @@ export const renderCreate = (label, buffer, message, stepIndex, defaults) => {
|
|
|
67
68
|
el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
|
|
68
69
|
], message);
|
|
69
70
|
};
|
|
70
|
-
const formatRepoRef = (repoRef) => {
|
|
71
|
-
const trimmed = repoRef.trim();
|
|
72
|
-
const prPrefix = "refs/pull/";
|
|
73
|
-
if (trimmed.startsWith(prPrefix)) {
|
|
74
|
-
const rest = trimmed.slice(prPrefix.length);
|
|
75
|
-
const number = rest.split("/")[0] ?? rest;
|
|
76
|
-
return `PR#${number}`;
|
|
77
|
-
}
|
|
78
|
-
return trimmed.length > 0 ? trimmed : "main";
|
|
79
|
-
};
|
|
80
|
-
const renderSelectDetails = (el, purpose, item) => {
|
|
81
|
-
if (!item) {
|
|
82
|
-
return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")];
|
|
83
|
-
}
|
|
84
|
-
const refLabel = formatRepoRef(item.repoRef);
|
|
85
|
-
const authSuffix = item.authorizedKeysExists ? "" : " (missing)";
|
|
86
|
-
return Match.value(purpose).pipe(Match.when("Info", () => [
|
|
87
|
-
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Connection info"),
|
|
88
|
-
el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
|
|
89
|
-
el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
|
|
90
|
-
el(Text, { wrap: "wrap" }, `Service: ${item.serviceName}`),
|
|
91
|
-
el(Text, { wrap: "wrap" }, `SSH command: ${item.sshCommand}`),
|
|
92
|
-
el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
|
|
93
|
-
el(Text, { wrap: "wrap" }, `Workspace: ${item.targetDir}`),
|
|
94
|
-
el(Text, { wrap: "wrap" }, `Authorized keys: ${item.authorizedKeysPath}${authSuffix}`),
|
|
95
|
-
el(Text, { wrap: "wrap" }, `Env global: ${item.envGlobalPath}`),
|
|
96
|
-
el(Text, { wrap: "wrap" }, `Env project: ${item.envProjectPath}`),
|
|
97
|
-
el(Text, { wrap: "wrap" }, `Codex auth: ${item.codexAuthPath} -> ${item.codexHome}`)
|
|
98
|
-
]), Match.when("Delete", () => [
|
|
99
|
-
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Delete project"),
|
|
100
|
-
el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
|
|
101
|
-
el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
|
|
102
|
-
el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
|
|
103
|
-
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
|
|
104
|
-
]), Match.orElse(() => [
|
|
105
|
-
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Details"),
|
|
106
|
-
el(Text, { wrap: "truncate" }, `Repo: ${item.repoUrl}`),
|
|
107
|
-
el(Text, { wrap: "truncate" }, `Ref: ${item.repoRef}`),
|
|
108
|
-
el(Text, { wrap: "truncate" }, `Project dir: ${item.projectDir}`),
|
|
109
|
-
el(Text, { wrap: "truncate" }, `Workspace: ${item.targetDir}`),
|
|
110
|
-
el(Text, { wrap: "truncate" }, `SSH: ${item.sshCommand}`)
|
|
111
|
-
]));
|
|
112
|
-
};
|
|
113
|
-
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);
|
|
114
|
-
const selectHint = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "Enter = select + SSH, 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);
|
|
115
|
-
const buildSelectLabels = (items, selected) => items.map((item, index) => {
|
|
116
|
-
const prefix = index === selected ? ">" : " ";
|
|
117
|
-
const refLabel = formatRepoRef(item.repoRef);
|
|
118
|
-
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})`;
|
|
119
|
-
});
|
|
120
71
|
const computeListWidth = (labels) => {
|
|
121
72
|
const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24;
|
|
122
73
|
return Math.min(Math.max(maxLabelWidth + 2, 28), 54);
|
|
@@ -129,21 +80,34 @@ const renderSelectListBox = (el, items, selected, labels, width) => {
|
|
|
129
80
|
}, label));
|
|
130
81
|
return el(Box, { flexDirection: "column", width }, ...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")]));
|
|
131
82
|
};
|
|
132
|
-
const renderSelectDetailsBox = (el,
|
|
133
|
-
const details = renderSelectDetails(el, purpose, items[selected]);
|
|
83
|
+
const renderSelectDetailsBox = (el, input) => {
|
|
84
|
+
const details = renderSelectDetails(el, input.purpose, input.items[input.selected], input.runtimeByProject, input.connectEnableMcpPlaywright);
|
|
134
85
|
return el(Box, { flexDirection: "column", marginLeft: 2, flexGrow: 1 }, ...details);
|
|
135
86
|
};
|
|
136
|
-
export const renderSelect = (
|
|
87
|
+
export const renderSelect = (input) => {
|
|
88
|
+
const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, runtimeByProject, selected } = input;
|
|
137
89
|
const el = React.createElement;
|
|
138
|
-
const listLabels = buildSelectLabels(items, selected);
|
|
90
|
+
const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject);
|
|
139
91
|
const listWidth = computeListWidth(listLabels);
|
|
140
92
|
const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth);
|
|
141
|
-
const detailsBox = renderSelectDetailsBox(el,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
93
|
+
const detailsBox = renderSelectDetailsBox(el, {
|
|
94
|
+
purpose,
|
|
95
|
+
items,
|
|
96
|
+
selected,
|
|
97
|
+
runtimeByProject,
|
|
98
|
+
connectEnableMcpPlaywright
|
|
99
|
+
});
|
|
100
|
+
const baseHint = selectHint(purpose, connectEnableMcpPlaywright);
|
|
101
|
+
const confirmHint = (() => {
|
|
102
|
+
if (purpose === "Delete" && confirmDelete) {
|
|
103
|
+
return "Confirm mode: Enter = delete now, Esc = cancel";
|
|
104
|
+
}
|
|
105
|
+
if (purpose === "Down" && confirmDelete) {
|
|
106
|
+
return "Confirm mode: Enter = stop now, Esc = cancel";
|
|
107
|
+
}
|
|
108
|
+
return baseHint;
|
|
109
|
+
})();
|
|
110
|
+
const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, confirmHint));
|
|
147
111
|
return renderLayout(selectTitle(purpose), [
|
|
148
112
|
el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox),
|
|
149
113
|
hints
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
const normalizedInput = (input) => input.trim().toLowerCase();
|
|
3
|
+
export const isConnectMcpToggleInput = (input) => normalizedInput(input) === "p";
|
|
4
|
+
export const buildConnectEffect = (selected, enableMcpPlaywright, deps) => enableMcpPlaywright
|
|
5
|
+
? deps.enableMcpPlaywright(selected.projectDir).pipe(Effect.zipRight(deps.connectWithUp(selected)))
|
|
6
|
+
: deps.connectWithUp(selected);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { runCommandCapture } from "@effect-template/lib/shell/command-runner";
|
|
2
|
+
import { runDockerPsNames } from "@effect-template/lib/shell/docker";
|
|
3
|
+
import { Effect, pipe } from "effect";
|
|
4
|
+
const emptyRuntimeByProject = () => ({});
|
|
5
|
+
const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
|
|
6
|
+
const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'";
|
|
7
|
+
const parseSshSessionCount = (raw) => {
|
|
8
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
9
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
};
|
|
14
|
+
const toRuntimeMap = (entries) => {
|
|
15
|
+
const runtimeByProject = {};
|
|
16
|
+
for (const [projectDir, runtime] of entries) {
|
|
17
|
+
runtimeByProject[projectDir] = runtime;
|
|
18
|
+
}
|
|
19
|
+
return runtimeByProject;
|
|
20
|
+
};
|
|
21
|
+
const countContainerSshSessions = (containerName) => pipe(runCommandCapture({
|
|
22
|
+
cwd: process.cwd(),
|
|
23
|
+
command: "docker",
|
|
24
|
+
args: ["exec", containerName, "bash", "-lc", countSshSessionsScript]
|
|
25
|
+
}, [0], (exitCode) => ({ _tag: "CommandFailedError", command: "docker exec who -u", exitCode })), Effect.match({
|
|
26
|
+
onFailure: () => 0,
|
|
27
|
+
onSuccess: (raw) => parseSshSessionCount(raw)
|
|
28
|
+
}));
|
|
29
|
+
// CHANGE: enrich select items with runtime state and SSH session counts
|
|
30
|
+
// WHY: prevent stopping/deleting containers that are currently used via SSH
|
|
31
|
+
// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
|
|
32
|
+
// REF: issue-47
|
|
33
|
+
// SOURCE: n/a
|
|
34
|
+
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
|
|
35
|
+
// PURITY: SHELL
|
|
36
|
+
// EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
|
|
37
|
+
// INVARIANT: stopped containers always have sshSessions = 0
|
|
38
|
+
// COMPLEXITY: O(n + docker_ps + docker_exec)
|
|
39
|
+
export const loadRuntimeByProject = (items) => pipe(runDockerPsNames(process.cwd()), Effect.flatMap((runningNames) => Effect.forEach(items, (item) => {
|
|
40
|
+
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]));
|
|
46
|
+
}, { concurrency: 4 })), Effect.map((entries) => toRuntimeMap(entries)), Effect.match({
|
|
47
|
+
onFailure: () => emptyRuntimeByProject(),
|
|
48
|
+
onSuccess: (runtimeByProject) => runtimeByProject
|
|
49
|
+
}));
|
|
50
|
+
export const runtimeForSelection = (view, selected) => view.runtimeByProject[selected.projectDir] ?? stoppedRuntime();
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { runDockerComposeDown } from "@effect-template/lib/shell/docker";
|
|
2
|
+
import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
|
|
2
3
|
import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
|
|
3
4
|
import { Effect, Match, pipe } from "effect";
|
|
5
|
+
import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js";
|
|
6
|
+
import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js";
|
|
4
7
|
import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js";
|
|
5
|
-
|
|
8
|
+
const emptyRuntimeByProject = () => ({});
|
|
9
|
+
export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
|
|
6
10
|
context.setMessage(null);
|
|
7
|
-
context.setView({
|
|
11
|
+
context.setView({
|
|
12
|
+
_tag: "SelectProject",
|
|
13
|
+
purpose,
|
|
14
|
+
items,
|
|
15
|
+
runtimeByProject,
|
|
16
|
+
selected: 0,
|
|
17
|
+
confirmDelete: false,
|
|
18
|
+
connectEnableMcpPlaywright: false
|
|
19
|
+
});
|
|
8
20
|
};
|
|
9
21
|
const clampIndex = (value, size) => {
|
|
10
22
|
if (size <= 0) {
|
|
@@ -23,6 +35,9 @@ export const handleSelectInput = (input, key, view, context) => {
|
|
|
23
35
|
resetToMenu(context);
|
|
24
36
|
return;
|
|
25
37
|
}
|
|
38
|
+
if (handleConnectOptionToggle(input, view, context)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
26
41
|
if (handleSelectNavigation(key, view, context)) {
|
|
27
42
|
return;
|
|
28
43
|
}
|
|
@@ -30,7 +45,20 @@ export const handleSelectInput = (input, key, view, context) => {
|
|
|
30
45
|
handleSelectReturn(view, context);
|
|
31
46
|
return;
|
|
32
47
|
}
|
|
33
|
-
|
|
48
|
+
if (input.trim().length > 0) {
|
|
49
|
+
context.setMessage("Use arrows + Enter to select a project, Esc to cancel.");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const handleConnectOptionToggle = (input, view, context) => {
|
|
53
|
+
if (view.purpose !== "Connect" || !isConnectMcpToggleInput(input)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const nextValue = !view.connectEnableMcpPlaywright;
|
|
57
|
+
context.setView({ ...view, connectEnableMcpPlaywright: nextValue, confirmDelete: false });
|
|
58
|
+
context.setMessage(nextValue
|
|
59
|
+
? "Playwright MCP will be enabled before SSH (press Enter to connect)."
|
|
60
|
+
: "Playwright MCP toggle is OFF (press Enter to connect without changes).");
|
|
61
|
+
return true;
|
|
34
62
|
};
|
|
35
63
|
const handleSelectNavigation = (key, view, context) => {
|
|
36
64
|
if (key.upArrow) {
|
|
@@ -54,22 +82,27 @@ const runWithSuspendedTui = (context, effect, onResume, doneMessage) => {
|
|
|
54
82
|
context.setMessage(doneMessage);
|
|
55
83
|
}))));
|
|
56
84
|
};
|
|
57
|
-
const runConnectSelection = (selected, context) => {
|
|
58
|
-
context.setMessage(
|
|
85
|
+
const runConnectSelection = (selected, context, enableMcpPlaywright) => {
|
|
86
|
+
context.setMessage(enableMcpPlaywright
|
|
87
|
+
? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
|
|
88
|
+
: `Connecting to ${selected.displayName}...`);
|
|
59
89
|
context.setSshActive(true);
|
|
60
|
-
runWithSuspendedTui(context,
|
|
90
|
+
runWithSuspendedTui(context, buildConnectEffect(selected, enableMcpPlaywright, {
|
|
91
|
+
connectWithUp: (item) => connectProjectSshWithUp(item).pipe(Effect.mapError((error) => error)),
|
|
92
|
+
enableMcpPlaywright: (projectDir) => mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(Effect.asVoid, Effect.mapError((error) => error))
|
|
93
|
+
}), () => {
|
|
61
94
|
context.setSshActive(false);
|
|
62
95
|
}, "SSH session ended. Press Esc to return to the menu.");
|
|
63
96
|
};
|
|
64
97
|
const runDownSelection = (selected, context) => {
|
|
65
98
|
context.setMessage(`Stopping ${selected.displayName}...`);
|
|
66
|
-
context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(runDockerComposeDown(selected.projectDir)), Effect.zipRight(listRunningProjectItems), Effect.tap((items) => Effect.sync(() => {
|
|
99
|
+
context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(runDockerComposeDown(selected.projectDir)), Effect.zipRight(listRunningProjectItems), Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.map((runtimeByProject) => ({ items, runtimeByProject })))), Effect.tap(({ items, runtimeByProject }) => Effect.sync(() => {
|
|
67
100
|
if (items.length === 0) {
|
|
68
101
|
resetToMenu(context);
|
|
69
102
|
context.setMessage("No running docker-git containers.");
|
|
70
103
|
return;
|
|
71
104
|
}
|
|
72
|
-
startSelectView(items, "Down", context);
|
|
105
|
+
startSelectView(items, "Down", context, runtimeByProject);
|
|
73
106
|
context.setMessage("Container stopped. Select another to stop, or Esc to return.");
|
|
74
107
|
})), Effect.ensuring(Effect.sync(() => {
|
|
75
108
|
resumeTui();
|
|
@@ -97,10 +130,19 @@ const handleSelectReturn = (view, context) => {
|
|
|
97
130
|
resetToMenu(context);
|
|
98
131
|
return;
|
|
99
132
|
}
|
|
133
|
+
const selectedRuntime = runtimeForSelection(view, selected);
|
|
134
|
+
const sshSessionsLabel = selectedRuntime.sshSessions === 1
|
|
135
|
+
? "1 active SSH session"
|
|
136
|
+
: `${selectedRuntime.sshSessions} active SSH sessions`;
|
|
100
137
|
Match.value(view.purpose).pipe(Match.when("Connect", () => {
|
|
101
138
|
context.setActiveDir(selected.projectDir);
|
|
102
|
-
runConnectSelection(selected, context);
|
|
139
|
+
runConnectSelection(selected, context, view.connectEnableMcpPlaywright);
|
|
103
140
|
}), Match.when("Down", () => {
|
|
141
|
+
if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
|
|
142
|
+
context.setMessage(`${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.`);
|
|
143
|
+
context.setView({ ...view, confirmDelete: true });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
104
146
|
context.setActiveDir(selected.projectDir);
|
|
105
147
|
runDownSelection(selected, context);
|
|
106
148
|
}), Match.when("Info", () => {
|
|
@@ -108,24 +150,20 @@ const handleSelectReturn = (view, context) => {
|
|
|
108
150
|
runInfoSelection(selected, context);
|
|
109
151
|
}), Match.when("Delete", () => {
|
|
110
152
|
if (!view.confirmDelete) {
|
|
111
|
-
|
|
153
|
+
const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : "";
|
|
154
|
+
context.setMessage(`Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.`);
|
|
112
155
|
context.setView({ ...view, confirmDelete: true });
|
|
113
156
|
return;
|
|
114
157
|
}
|
|
115
158
|
runDeleteSelection(selected, context);
|
|
116
159
|
}), Match.exhaustive);
|
|
117
160
|
};
|
|
118
|
-
const
|
|
119
|
-
if (input.trim().length > 0) {
|
|
120
|
-
context.setMessage("Use arrows + Enter to select a project, Esc to cancel.");
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => Effect.sync(() => {
|
|
161
|
+
export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
|
|
124
162
|
if (items.length === 0) {
|
|
125
163
|
context.setMessage(purpose === "Down"
|
|
126
164
|
? "No running docker-git containers."
|
|
127
165
|
: "No docker-git projects found.");
|
|
128
166
|
return;
|
|
129
167
|
}
|
|
130
|
-
startSelectView(items, purpose, context);
|
|
131
|
-
})));
|
|
168
|
+
startSelectView(items, purpose, context, runtimeByProject);
|
|
169
|
+
})))));
|
|
@@ -112,7 +112,15 @@ const renderView = (context) => {
|
|
|
112
112
|
const label = renderStepLabel(step, currentDefaults);
|
|
113
113
|
return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults);
|
|
114
114
|
}
|
|
115
|
-
return renderSelect(
|
|
115
|
+
return renderSelect({
|
|
116
|
+
purpose: context.view.purpose,
|
|
117
|
+
items: context.view.items,
|
|
118
|
+
selected: context.view.selected,
|
|
119
|
+
runtimeByProject: context.view.runtimeByProject,
|
|
120
|
+
confirmDelete: context.view.confirmDelete,
|
|
121
|
+
connectEnableMcpPlaywright: context.view.connectEnableMcpPlaywright,
|
|
122
|
+
message: context.message
|
|
123
|
+
});
|
|
116
124
|
};
|
|
117
125
|
const useMenuState = () => {
|
|
118
126
|
const [activeDir, setActiveDir] = useState(null);
|
package/package.json
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Match } from "effect"
|
|
2
|
+
import { Text } from "ink"
|
|
3
|
+
import type React from "react"
|
|
4
|
+
|
|
5
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
6
|
+
import type { SelectProjectRuntime } from "./menu-types.js"
|
|
7
|
+
|
|
8
|
+
export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete"
|
|
9
|
+
|
|
10
|
+
const formatRepoRef = (repoRef: string): string => {
|
|
11
|
+
const trimmed = repoRef.trim()
|
|
12
|
+
const prPrefix = "refs/pull/"
|
|
13
|
+
if (trimmed.startsWith(prPrefix)) {
|
|
14
|
+
const rest = trimmed.slice(prPrefix.length)
|
|
15
|
+
const number = rest.split("/")[0] ?? rest
|
|
16
|
+
return `PR#${number}`
|
|
17
|
+
}
|
|
18
|
+
return trimmed.length > 0 ? trimmed : "main"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
|
|
22
|
+
|
|
23
|
+
const runtimeForProject = (
|
|
24
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
|
|
25
|
+
item: ProjectItem
|
|
26
|
+
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime()
|
|
27
|
+
|
|
28
|
+
const renderRuntimeLabel = (runtime: SelectProjectRuntime): string =>
|
|
29
|
+
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`
|
|
30
|
+
|
|
31
|
+
export const selectTitle = (purpose: SelectPurpose): string =>
|
|
32
|
+
Match.value(purpose).pipe(
|
|
33
|
+
Match.when("Connect", () => "docker-git / Select project"),
|
|
34
|
+
Match.when("Down", () => "docker-git / Stop container"),
|
|
35
|
+
Match.when("Info", () => "docker-git / Show connection info"),
|
|
36
|
+
Match.when("Delete", () => "docker-git / Delete project"),
|
|
37
|
+
Match.exhaustive
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
export const selectHint = (
|
|
41
|
+
purpose: SelectPurpose,
|
|
42
|
+
connectEnableMcpPlaywright: boolean
|
|
43
|
+
): string =>
|
|
44
|
+
Match.value(purpose).pipe(
|
|
45
|
+
Match.when(
|
|
46
|
+
"Connect",
|
|
47
|
+
() => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`
|
|
48
|
+
),
|
|
49
|
+
Match.when("Down", () => "Enter = stop container, Esc = back"),
|
|
50
|
+
Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"),
|
|
51
|
+
Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"),
|
|
52
|
+
Match.exhaustive
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
export const buildSelectLabels = (
|
|
56
|
+
items: ReadonlyArray<ProjectItem>,
|
|
57
|
+
selected: number,
|
|
58
|
+
purpose: SelectPurpose,
|
|
59
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
|
|
60
|
+
): ReadonlyArray<string> =>
|
|
61
|
+
items.map((item, index) => {
|
|
62
|
+
const prefix = index === selected ? ">" : " "
|
|
63
|
+
const refLabel = formatRepoRef(item.repoRef)
|
|
64
|
+
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
|
|
65
|
+
? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
|
|
66
|
+
: ""
|
|
67
|
+
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
type SelectDetailsContext = {
|
|
71
|
+
readonly item: ProjectItem
|
|
72
|
+
readonly refLabel: string
|
|
73
|
+
readonly authSuffix: string
|
|
74
|
+
readonly runtime: SelectProjectRuntime
|
|
75
|
+
readonly sshSessionsLabel: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const buildDetailsContext = (
|
|
79
|
+
item: ProjectItem,
|
|
80
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
|
|
81
|
+
): SelectDetailsContext => {
|
|
82
|
+
const runtime = runtimeForProject(runtimeByProject, item)
|
|
83
|
+
return {
|
|
84
|
+
item,
|
|
85
|
+
refLabel: formatRepoRef(item.repoRef),
|
|
86
|
+
authSuffix: item.authorizedKeysExists ? "" : " (missing)",
|
|
87
|
+
runtime,
|
|
88
|
+
sshSessionsLabel: runtime.sshSessions === 1
|
|
89
|
+
? "1 active SSH session"
|
|
90
|
+
: `${runtime.sshSessions} active SSH sessions`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const titleRow = (el: typeof React.createElement, value: string): React.ReactElement =>
|
|
95
|
+
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, value)
|
|
96
|
+
|
|
97
|
+
const commonRows = (
|
|
98
|
+
el: typeof React.createElement,
|
|
99
|
+
context: SelectDetailsContext
|
|
100
|
+
): ReadonlyArray<React.ReactElement> => [
|
|
101
|
+
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
|
|
102
|
+
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
|
|
103
|
+
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
|
|
104
|
+
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
const renderInfoDetails = (
|
|
108
|
+
el: typeof React.createElement,
|
|
109
|
+
context: SelectDetailsContext,
|
|
110
|
+
common: ReadonlyArray<React.ReactElement>
|
|
111
|
+
): ReadonlyArray<React.ReactElement> => [
|
|
112
|
+
titleRow(el, "Connection info"),
|
|
113
|
+
...common,
|
|
114
|
+
el(Text, { wrap: "wrap" }, `Service: ${context.item.serviceName}`),
|
|
115
|
+
el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`),
|
|
116
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
117
|
+
el(Text, { wrap: "wrap" }, `Workspace: ${context.item.targetDir}`),
|
|
118
|
+
el(Text, { wrap: "wrap" }, `Authorized keys: ${context.item.authorizedKeysPath}${context.authSuffix}`),
|
|
119
|
+
el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
|
|
120
|
+
el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
|
|
121
|
+
el(Text, { wrap: "wrap" }, `Codex auth: ${context.item.codexAuthPath} -> ${context.item.codexHome}`)
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const renderDefaultDetails = (
|
|
125
|
+
el: typeof React.createElement,
|
|
126
|
+
context: SelectDetailsContext
|
|
127
|
+
): ReadonlyArray<React.ReactElement> => [
|
|
128
|
+
titleRow(el, "Details"),
|
|
129
|
+
el(Text, { wrap: "truncate" }, `Repo: ${context.item.repoUrl}`),
|
|
130
|
+
el(Text, { wrap: "truncate" }, `Ref: ${context.item.repoRef}`),
|
|
131
|
+
el(Text, { wrap: "truncate" }, `Project dir: ${context.item.projectDir}`),
|
|
132
|
+
el(Text, { wrap: "truncate" }, `Workspace: ${context.item.targetDir}`),
|
|
133
|
+
el(Text, { wrap: "truncate" }, `SSH: ${context.item.sshCommand}`)
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
const renderConnectDetails = (
|
|
137
|
+
el: typeof React.createElement,
|
|
138
|
+
context: SelectDetailsContext,
|
|
139
|
+
common: ReadonlyArray<React.ReactElement>,
|
|
140
|
+
connectEnableMcpPlaywright: boolean
|
|
141
|
+
): ReadonlyArray<React.ReactElement> => [
|
|
142
|
+
titleRow(el, "Connect + SSH"),
|
|
143
|
+
...common,
|
|
144
|
+
el(
|
|
145
|
+
Text,
|
|
146
|
+
{ color: connectEnableMcpPlaywright ? "green" : "gray", wrap: "wrap" },
|
|
147
|
+
connectEnableMcpPlaywright
|
|
148
|
+
? "Playwright MCP: will be enabled before SSH (P to disable)."
|
|
149
|
+
: "Playwright MCP: keep current project setting (P to enable before SSH)."
|
|
150
|
+
),
|
|
151
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
152
|
+
el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`)
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
export const renderSelectDetails = (
|
|
156
|
+
el: typeof React.createElement,
|
|
157
|
+
purpose: SelectPurpose,
|
|
158
|
+
item: ProjectItem | undefined,
|
|
159
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
|
|
160
|
+
connectEnableMcpPlaywright: boolean
|
|
161
|
+
): ReadonlyArray<React.ReactElement> => {
|
|
162
|
+
if (!item) {
|
|
163
|
+
return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")]
|
|
164
|
+
}
|
|
165
|
+
const context = buildDetailsContext(item, runtimeByProject)
|
|
166
|
+
const common = commonRows(el, context)
|
|
167
|
+
|
|
168
|
+
return Match.value(purpose).pipe(
|
|
169
|
+
Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)),
|
|
170
|
+
Match.when("Info", () => renderInfoDetails(el, context, common)),
|
|
171
|
+
Match.when("Down", () => [
|
|
172
|
+
titleRow(el, "Stop container"),
|
|
173
|
+
...common,
|
|
174
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`)
|
|
175
|
+
]),
|
|
176
|
+
Match.when("Delete", () => [
|
|
177
|
+
titleRow(el, "Delete project"),
|
|
178
|
+
...common,
|
|
179
|
+
context.runtime.sshSessions > 0
|
|
180
|
+
? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.")
|
|
181
|
+
: el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."),
|
|
182
|
+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
|
|
183
|
+
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
|
|
184
|
+
]),
|
|
185
|
+
Match.orElse(() => renderDefaultDetails(el, context))
|
|
186
|
+
)
|
|
187
|
+
}
|