@prover-coder-ai/docker-git 1.0.21 → 1.0.23

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 (70) hide show
  1. package/.package.json.release.bak +5 -3
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +31 -1
  4. package/dist/src/docker-git/main.js +10259 -12
  5. package/dist/src/docker-git/main.js.map +1 -0
  6. package/package.json +4 -4
  7. package/src/app/program.ts +16 -13
  8. package/src/docker-git/cli/parser-options.ts +6 -0
  9. package/src/docker-git/cli/parser.ts +1 -0
  10. package/src/docker-git/cli/usage.ts +9 -4
  11. package/src/docker-git/menu-actions.ts +5 -2
  12. package/src/docker-git/menu-create.ts +9 -13
  13. package/src/docker-git/menu-render.ts +1 -1
  14. package/tests/docker-git/parser-helpers.ts +76 -0
  15. package/tests/docker-git/parser-network-options.test.ts +47 -0
  16. package/tests/docker-git/parser.test.ts +30 -71
  17. package/vite.docker-git.config.ts +34 -0
  18. package/dist/main.js +0 -930
  19. package/dist/main.js.map +0 -1
  20. package/dist/src/app/main.js +0 -15
  21. package/dist/src/app/program.js +0 -61
  22. package/dist/src/docker-git/cli/input.js +0 -21
  23. package/dist/src/docker-git/cli/parser-apply.js +0 -22
  24. package/dist/src/docker-git/cli/parser-attach.js +0 -19
  25. package/dist/src/docker-git/cli/parser-auth.js +0 -90
  26. package/dist/src/docker-git/cli/parser-clone.js +0 -40
  27. package/dist/src/docker-git/cli/parser-create.js +0 -1
  28. package/dist/src/docker-git/cli/parser-mcp-playwright.js +0 -18
  29. package/dist/src/docker-git/cli/parser-options.js +0 -134
  30. package/dist/src/docker-git/cli/parser-panes.js +0 -19
  31. package/dist/src/docker-git/cli/parser-scrap.js +0 -74
  32. package/dist/src/docker-git/cli/parser-sessions.js +0 -69
  33. package/dist/src/docker-git/cli/parser-shared.js +0 -26
  34. package/dist/src/docker-git/cli/parser-state.js +0 -62
  35. package/dist/src/docker-git/cli/parser.js +0 -47
  36. package/dist/src/docker-git/cli/read-command.js +0 -17
  37. package/dist/src/docker-git/cli/usage.js +0 -113
  38. package/dist/src/docker-git/menu-actions.js +0 -135
  39. package/dist/src/docker-git/menu-auth-data.js +0 -90
  40. package/dist/src/docker-git/menu-auth-helpers.js +0 -20
  41. package/dist/src/docker-git/menu-auth.js +0 -159
  42. package/dist/src/docker-git/menu-buffer-input.js +0 -9
  43. package/dist/src/docker-git/menu-create.js +0 -199
  44. package/dist/src/docker-git/menu-input-handler.js +0 -109
  45. package/dist/src/docker-git/menu-input-utils.js +0 -47
  46. package/dist/src/docker-git/menu-input.js +0 -2
  47. package/dist/src/docker-git/menu-labeled-env.js +0 -33
  48. package/dist/src/docker-git/menu-menu.js +0 -46
  49. package/dist/src/docker-git/menu-project-auth-claude.js +0 -43
  50. package/dist/src/docker-git/menu-project-auth-data.js +0 -165
  51. package/dist/src/docker-git/menu-project-auth.js +0 -124
  52. package/dist/src/docker-git/menu-render-auth.js +0 -45
  53. package/dist/src/docker-git/menu-render-common.js +0 -26
  54. package/dist/src/docker-git/menu-render-layout.js +0 -14
  55. package/dist/src/docker-git/menu-render-project-auth.js +0 -37
  56. package/dist/src/docker-git/menu-render-select.js +0 -129
  57. package/dist/src/docker-git/menu-render.js +0 -137
  58. package/dist/src/docker-git/menu-select-actions.js +0 -66
  59. package/dist/src/docker-git/menu-select-connect.js +0 -6
  60. package/dist/src/docker-git/menu-select-load.js +0 -12
  61. package/dist/src/docker-git/menu-select-order.js +0 -21
  62. package/dist/src/docker-git/menu-select-runtime.js +0 -82
  63. package/dist/src/docker-git/menu-select-view.js +0 -15
  64. package/dist/src/docker-git/menu-select.js +0 -98
  65. package/dist/src/docker-git/menu-shared.js +0 -180
  66. package/dist/src/docker-git/menu-startup.js +0 -57
  67. package/dist/src/docker-git/menu-types.js +0 -21
  68. package/dist/src/docker-git/menu.js +0 -226
  69. package/dist/src/docker-git/program.js +0 -43
  70. package/dist/src/docker-git/tmux.js +0 -176
@@ -1,37 +0,0 @@
1
- import { Box, Text } from "ink";
2
- import React from "react";
3
- import { projectAuthMenuLabels, projectAuthViewSteps } from "./menu-project-auth-data.js";
4
- import { renderMenuHelp, renderPromptLayout, renderSelectableMenuList, resolvePromptState } from "./menu-render-common.js";
5
- import { renderLayout } from "./menu-render-layout.js";
6
- const renderActiveLabel = (value) => value ?? "(not set)";
7
- const renderCountLine = (title, count) => `${title}: ${count}`;
8
- export const renderProjectAuthMenu = (snapshot, selected, message) => {
9
- const el = React.createElement;
10
- const list = renderSelectableMenuList(projectAuthMenuLabels(), selected);
11
- return renderLayout("docker-git / Project auth", [
12
- el(Text, null, `Project: ${snapshot.projectName}`),
13
- el(Text, { color: "gray" }, `Dir: ${snapshot.projectDir}`),
14
- el(Text, { color: "gray" }, `Project env: ${snapshot.envProjectPath}`),
15
- el(Text, { color: "gray" }, `Global env: ${snapshot.envGlobalPath}`),
16
- el(Text, { color: "gray" }, `Claude auth: ${snapshot.claudeAuthPath}`),
17
- el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, `GitHub label: ${renderActiveLabel(snapshot.activeGithubLabel)}`), el(Text, { color: "gray" }, renderCountLine("Available GitHub tokens", snapshot.githubTokenEntries)), el(Text, { color: "gray" }, `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}`), el(Text, { color: "gray" }, renderCountLine("Available Git tokens", snapshot.gitTokenEntries)), el(Text, { color: "gray" }, `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}`), el(Text, { color: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries))),
18
- el(Box, { flexDirection: "column", marginTop: 1 }, ...list),
19
- renderMenuHelp("Use arrows + Enter, or type a number from the list.")
20
- ], message);
21
- };
22
- export const renderProjectAuthPrompt = (view, message) => {
23
- const el = React.createElement;
24
- const { prompt, visibleBuffer } = resolvePromptState(projectAuthViewSteps(view.flow), view.step, view.buffer);
25
- return renderPromptLayout({
26
- title: "docker-git / Project auth / Set label",
27
- header: [
28
- el(Text, { color: "gray" }, `Project: ${view.snapshot.projectName}`),
29
- el(Text, { color: "gray" }, `Project env: ${view.snapshot.envProjectPath}`),
30
- el(Text, { color: "gray" }, `Global env: ${view.snapshot.envGlobalPath}`)
31
- ],
32
- prompt,
33
- visibleBuffer,
34
- helpLine: "Enter = apply, Esc = cancel.",
35
- message
36
- });
37
- };
@@ -1,129 +0,0 @@
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 = () => ({
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);
27
- const runtimeForProject = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? stoppedRuntime();
28
- const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${renderStartedAtCompact(runtime)}`;
29
- export const selectTitle = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "docker-git / Select project"), Match.when("Auth", () => "docker-git / Project auth"), Match.when("Down", () => "docker-git / Stop container"), Match.when("Info", () => "docker-git / Show connection info"), Match.when("Delete", () => "docker-git / Delete project"), Match.exhaustive);
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("Auth", () => "Enter = open project auth menu, 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);
31
- export const buildSelectLabels = (items, selected, purpose, runtimeByProject) => items.map((item, index) => {
32
- const prefix = index === selected ? ">" : " ";
33
- const refLabel = formatRepoRef(item.repoRef);
34
- const runtime = runtimeForProject(runtimeByProject, item);
35
- const runtimeSuffix = purpose === "Down" || purpose === "Delete"
36
- ? ` [${renderRuntimeLabel(runtime)}]`
37
- : ` [started=${renderStartedAtCompact(runtime)}]`;
38
- return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`;
39
- });
40
- export const buildSelectListWindow = (total, selected, maxVisible) => {
41
- if (total <= 0) {
42
- return { start: 0, end: 0 };
43
- }
44
- const visible = Math.max(1, maxVisible);
45
- if (total <= visible) {
46
- return { start: 0, end: total };
47
- }
48
- const boundedSelected = Math.min(Math.max(selected, 0), total - 1);
49
- const half = Math.floor(visible / 2);
50
- const maxStart = total - visible;
51
- const start = Math.min(Math.max(boundedSelected - half, 0), maxStart);
52
- return { start, end: start + visible };
53
- };
54
- const buildDetailsContext = (item, runtimeByProject) => {
55
- const runtime = runtimeForProject(runtimeByProject, item);
56
- return {
57
- item,
58
- refLabel: formatRepoRef(item.repoRef),
59
- authSuffix: item.authorizedKeysExists ? "" : " (missing)",
60
- runtime,
61
- sshSessionsLabel: runtime.sshSessions === 1
62
- ? "1 active SSH session"
63
- : `${runtime.sshSessions} active SSH sessions`
64
- };
65
- };
66
- const titleRow = (el, value) => el(Text, { color: "cyan", bold: true, wrap: "truncate" }, value);
67
- const commonRows = (el, context) => [
68
- el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
69
- el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
70
- el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
71
- el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
72
- el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
73
- ];
74
- const renderInfoDetails = (el, context, common) => [
75
- titleRow(el, "Connection info"),
76
- ...common,
77
- el(Text, { wrap: "wrap" }, `Service: ${context.item.serviceName}`),
78
- el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`),
79
- el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
80
- el(Text, { wrap: "wrap" }, `Workspace: ${context.item.targetDir}`),
81
- el(Text, { wrap: "wrap" }, `Authorized keys: ${context.item.authorizedKeysPath}${context.authSuffix}`),
82
- el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
83
- el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
84
- el(Text, { wrap: "wrap" }, `Codex auth: ${context.item.codexAuthPath} -> ${context.item.codexHome}`)
85
- ];
86
- const renderDefaultDetails = (el, context) => [
87
- titleRow(el, "Details"),
88
- el(Text, { wrap: "truncate" }, `Repo: ${context.item.repoUrl}`),
89
- el(Text, { wrap: "truncate" }, `Ref: ${context.item.repoRef}`),
90
- el(Text, { wrap: "truncate" }, `Project dir: ${context.item.projectDir}`),
91
- el(Text, { wrap: "truncate" }, `Workspace: ${context.item.targetDir}`),
92
- el(Text, { wrap: "truncate" }, `SSH: ${context.item.sshCommand}`)
93
- ];
94
- const renderConnectDetails = (el, context, common, connectEnableMcpPlaywright) => [
95
- titleRow(el, "Connect + SSH"),
96
- ...common,
97
- el(Text, { color: connectEnableMcpPlaywright ? "green" : "gray", wrap: "wrap" }, connectEnableMcpPlaywright
98
- ? "Playwright MCP: will be enabled before SSH (P to disable)."
99
- : "Playwright MCP: keep current project setting (P to enable before SSH)."),
100
- el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
101
- el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`)
102
- ];
103
- export const renderSelectDetails = (el, purpose, item, runtimeByProject, connectEnableMcpPlaywright) => {
104
- if (!item) {
105
- return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")];
106
- }
107
- const context = buildDetailsContext(item, runtimeByProject);
108
- const common = commonRows(el, context);
109
- return Match.value(purpose).pipe(Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)), Match.when("Auth", () => [
110
- titleRow(el, "Project auth"),
111
- ...common,
112
- el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
113
- el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
114
- el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
115
- el(Text, { color: "gray", wrap: "wrap" }, "Press Enter to manage labels for this project.")
116
- ]), Match.when("Info", () => renderInfoDetails(el, context, common)), Match.when("Down", () => [
117
- titleRow(el, "Stop container"),
118
- ...common,
119
- el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`)
120
- ]), Match.when("Delete", () => [
121
- titleRow(el, "Delete project"),
122
- ...common,
123
- context.runtime.sshSessions > 0
124
- ? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.")
125
- : el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."),
126
- el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
127
- el(Text, { wrap: "wrap" }, "Removes project folder and runs docker compose down -v.")
128
- ]), Match.orElse(() => renderDefaultDetails(el, context)));
129
- };
@@ -1,137 +0,0 @@
1
- import { Match } from "effect";
2
- import { Box, Text } from "ink";
3
- import React from "react";
4
- import { renderLayout } from "./menu-render-layout.js";
5
- import { buildSelectLabels, buildSelectListWindow, renderSelectDetails, selectHint, selectTitle } from "./menu-render-select.js";
6
- import { createSteps, menuItems } from "./menu-types.js";
7
- // CHANGE: render menu views with Ink without JSX
8
- // WHY: keep UI logic separate from input/state reducers
9
- // QUOTE(ТЗ): "TUI? Красивый, удобный"
10
- // REF: user-request-2026-02-01-tui
11
- // SOURCE: n/a
12
- // FORMAT THEOREM: forall v: view(v) -> render(v)
13
- // PURITY: SHELL
14
- // EFFECT: n/a
15
- // INVARIANT: menu renders all items once
16
- // COMPLEXITY: O(n)
17
- export const renderStepLabel = (step, defaults) => Match.value(step).pipe(Match.when("repoUrl", () => "Repo URL"), Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`), Match.when("outDir", () => `Output dir [${defaults.outDir}]`), Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`), Match.when("mcpPlaywright", () => `Enable Playwright MCP (Chromium sidecar)? [${defaults.enableMcpPlaywright ? "y" : "N"}]`), Match.when("force", () => `Force recreate (overwrite files + wipe volumes)? [${defaults.force ? "y" : "N"}]`), Match.exhaustive);
18
- const compactElements = (items) => items.filter((item) => item !== null);
19
- const renderMenuHints = (el) => el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, "Hints:"), el(Text, { color: "gray" }, " - Paste repo URL to create directly."), el(Text, { color: "gray" }, " - 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"), el(Text, { color: "gray" }, " - Use arrows and Enter to run."));
20
- const renderMenuMessage = (el, message) => {
21
- if (!message || message.length === 0) {
22
- return null;
23
- }
24
- return el(Box, { marginTop: 1, flexDirection: "column" }, ...message
25
- .split("\n")
26
- .map((line, index) => el(Text, { key: `${index}-${line}`, color: "magenta" }, line)));
27
- };
28
- export const renderMenu = (input) => {
29
- const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input;
30
- const el = React.createElement;
31
- const activeLabel = `Active: ${activeDir ?? "(none)"}`;
32
- const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}`;
33
- const cwdLabel = `CWD: ${cwd}`;
34
- const items = menuItems.map((item, index) => {
35
- const indexLabel = `${index + 1})`;
36
- const prefix = index === selected ? ">" : " ";
37
- return el(Text, { key: item.label, color: index === selected ? "green" : "white" }, `${prefix} ${indexLabel} ${item.label}`);
38
- });
39
- const busyView = busy
40
- ? el(Box, { marginTop: 1 }, el(Text, { color: "yellow" }, "Running..."))
41
- : null;
42
- const messageView = renderMenuMessage(el, message);
43
- const hints = renderMenuHints(el);
44
- return renderLayout("docker-git", compactElements([
45
- el(Text, null, activeLabel),
46
- el(Text, null, runningLabel),
47
- el(Text, null, cwdLabel),
48
- el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
49
- hints,
50
- busyView,
51
- messageView
52
- ]), null);
53
- };
54
- export const renderCreate = (label, buffer, message, stepIndex, defaults) => {
55
- const el = React.createElement;
56
- const steps = createSteps.map((step, index) => el(Text, { key: step, color: index === stepIndex ? "green" : "gray" }, `${index === stepIndex ? ">" : " "} ${renderStepLabel(step, defaults)}`));
57
- return renderLayout("docker-git / Create", [
58
- el(Box, { flexDirection: "column", marginTop: 1 }, ...steps),
59
- el(Box, { marginTop: 1 }, el(Text, null, `${label}: `), el(Text, { color: "green" }, buffer)),
60
- el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
61
- ], message);
62
- };
63
- export { renderAuthMenu, renderAuthPrompt } from "./menu-render-auth.js";
64
- export { renderProjectAuthMenu, renderProjectAuthPrompt } from "./menu-render-project-auth.js";
65
- const computeListWidth = (labels) => {
66
- const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24;
67
- return Math.min(Math.max(maxLabelWidth + 2, 28), 54);
68
- };
69
- const readStdoutRows = () => {
70
- const rows = process.stdout.rows;
71
- if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) {
72
- return null;
73
- }
74
- return rows;
75
- };
76
- const computeSelectListMaxRows = () => {
77
- const rows = readStdoutRows();
78
- if (rows === null) {
79
- return 12;
80
- }
81
- return Math.max(6, rows - 14);
82
- };
83
- const renderSelectListBox = (el, items, selected, labels, width) => {
84
- const window = buildSelectListWindow(labels.length, selected, computeSelectListMaxRows());
85
- const hiddenAbove = window.start;
86
- const hiddenBelow = labels.length - window.end;
87
- const visibleLabels = labels.slice(window.start, window.end);
88
- const list = visibleLabels.map((label, offset) => {
89
- const index = window.start + offset;
90
- return el(Text, {
91
- key: items[index]?.projectDir ?? String(index),
92
- color: index === selected ? "green" : "white",
93
- wrap: "truncate"
94
- }, label);
95
- });
96
- const before = hiddenAbove > 0
97
- ? [el(Text, { color: "gray", wrap: "truncate" }, `[scroll] ${hiddenAbove} more above`)]
98
- : [];
99
- const after = hiddenBelow > 0
100
- ? [el(Text, { color: "gray", wrap: "truncate" }, `[scroll] ${hiddenBelow} more below`)]
101
- : [];
102
- const listBody = list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")];
103
- return el(Box, { flexDirection: "column", width }, ...before, ...listBody, ...after);
104
- };
105
- const renderSelectDetailsBox = (el, input) => {
106
- const details = renderSelectDetails(el, input.purpose, input.items[input.selected], input.runtimeByProject, input.connectEnableMcpPlaywright);
107
- return el(Box, { flexDirection: "column", marginLeft: 2, flexGrow: 1 }, ...details);
108
- };
109
- export const renderSelect = (input) => {
110
- const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, runtimeByProject, selected } = input;
111
- const el = React.createElement;
112
- const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject);
113
- const listWidth = computeListWidth(listLabels);
114
- const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth);
115
- const detailsBox = renderSelectDetailsBox(el, {
116
- purpose,
117
- items,
118
- selected,
119
- runtimeByProject,
120
- connectEnableMcpPlaywright
121
- });
122
- const baseHint = selectHint(purpose, connectEnableMcpPlaywright);
123
- const confirmHint = (() => {
124
- if (purpose === "Delete" && confirmDelete) {
125
- return "Confirm mode: Enter = delete now, Esc = cancel";
126
- }
127
- if (purpose === "Down" && confirmDelete) {
128
- return "Confirm mode: Enter = stop now, Esc = cancel";
129
- }
130
- return baseHint;
131
- })();
132
- const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, confirmHint));
133
- return renderLayout(selectTitle(purpose), [
134
- el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox),
135
- hints
136
- ], message);
137
- };
@@ -1,66 +0,0 @@
1
- import { runDockerComposeDown } from "@effect-template/lib/shell/docker";
2
- import { renderError } from "@effect-template/lib/usecases/errors";
3
- import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
4
- import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
5
- import { Effect, pipe } from "effect";
6
- import { openProjectAuthMenu } from "./menu-project-auth.js";
7
- import { buildConnectEffect } from "./menu-select-connect.js";
8
- import { loadRuntimeByProject } from "./menu-select-runtime.js";
9
- import { startSelectView } from "./menu-select-view.js";
10
- import { pauseOnError, resetToMenu, resumeSshWithSkipInputs, resumeWithSkipInputs, withSuspendedTui } from "./menu-shared.js";
11
- export const runConnectSelection = (selected, context, enableMcpPlaywright) => {
12
- context.setMessage(enableMcpPlaywright
13
- ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
14
- : `Connecting to ${selected.displayName}...`);
15
- context.setSshActive(true);
16
- context.runner.runEffect(pipe(withSuspendedTui(buildConnectEffect(selected, enableMcpPlaywright, {
17
- connectWithUp: (item) => connectProjectSshWithUp(item).pipe(Effect.mapError((error) => error)),
18
- enableMcpPlaywright: (projectDir) => mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(Effect.asVoid, Effect.mapError((error) => error))
19
- }), {
20
- onError: pauseOnError(renderError),
21
- onResume: resumeSshWithSkipInputs(context)
22
- }), Effect.tap(() => Effect.sync(() => {
23
- context.setMessage("SSH session ended. Press Esc to return to the menu.");
24
- })), Effect.asVoid));
25
- };
26
- export const runDownSelection = (selected, context) => {
27
- context.setMessage(`Stopping ${selected.displayName}...`);
28
- context.runner.runEffect(withSuspendedTui(pipe(runDockerComposeDown(selected.projectDir), Effect.zipRight(listRunningProjectItems), Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.map((runtimeByProject) => ({ items, runtimeByProject })))), Effect.tap(({ items, runtimeByProject }) => Effect.sync(() => {
29
- if (items.length === 0) {
30
- resetToMenu(context);
31
- context.setMessage("No running docker-git containers.");
32
- return;
33
- }
34
- startSelectView(items, "Down", context, runtimeByProject);
35
- context.setMessage("Container stopped. Select another to stop, or Esc to return.");
36
- })), Effect.asVoid), {
37
- onError: pauseOnError(renderError),
38
- onResume: resumeWithSkipInputs(context)
39
- }));
40
- };
41
- export const runInfoSelection = (selected, context) => {
42
- context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`);
43
- };
44
- export const runAuthSelection = (selected, context) => {
45
- openProjectAuthMenu({
46
- project: selected,
47
- runner: context.runner,
48
- setView: context.setView,
49
- setMessage: context.setMessage,
50
- setActiveDir: context.setActiveDir
51
- });
52
- };
53
- export const runDeleteSelection = (selected, context) => {
54
- context.setMessage(`Deleting ${selected.displayName}...`);
55
- context.runner.runEffect(pipe(withSuspendedTui(deleteDockerGitProject(selected).pipe(Effect.tap(() => Effect.sync(() => {
56
- if (context.activeDir === selected.projectDir) {
57
- context.setActiveDir(null);
58
- }
59
- context.setView({ _tag: "Menu" });
60
- })), Effect.asVoid), {
61
- onError: pauseOnError(renderError),
62
- onResume: resumeWithSkipInputs(context)
63
- }), Effect.tap(() => Effect.sync(() => {
64
- context.setMessage("Project deleted.");
65
- })), Effect.asVoid));
66
- };
@@ -1,6 +0,0 @@
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);
@@ -1,12 +0,0 @@
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
- })))));
@@ -1,21 +0,0 @@
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
- });
@@ -1,82 +0,0 @@
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 = () => ({
6
- running: false,
7
- sshSessions: 0,
8
- startedAtIso: null,
9
- startedAtEpochMs: null
10
- });
11
- const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'";
12
- const dockerZeroStartedAt = "0001-01-01T00:00:00Z";
13
- const parseSshSessionCount = (raw) => {
14
- const parsed = Number.parseInt(raw.trim(), 10);
15
- if (Number.isNaN(parsed) || parsed < 0) {
16
- return 0;
17
- }
18
- return parsed;
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
- };
34
- const toRuntimeMap = (entries) => {
35
- const runtimeByProject = {};
36
- for (const [projectDir, runtime] of entries) {
37
- runtimeByProject[projectDir] = runtime;
38
- }
39
- return runtimeByProject;
40
- };
41
- const countContainerSshSessions = (containerName) => pipe(runCommandCapture({
42
- cwd: process.cwd(),
43
- command: "docker",
44
- args: ["exec", containerName, "bash", "-lc", countSshSessionsScript]
45
- }, [0], (exitCode) => ({ _tag: "CommandFailedError", command: "docker exec who -u", exitCode })), Effect.match({
46
- onFailure: () => 0,
47
- onSuccess: (raw) => parseSshSessionCount(raw)
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
- }));
57
- // CHANGE: enrich select items with runtime state and SSH session counts
58
- // WHY: prevent stopping/deleting containers that are currently used via SSH
59
- // QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
60
- // REF: issue-47
61
- // SOURCE: n/a
62
- // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
63
- // PURITY: SHELL
64
- // EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
65
- // INVARIANT: projects without a known container start have startedAt = null
66
- // COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
67
- export const loadRuntimeByProject = (items) => pipe(runDockerPsNames(process.cwd()), Effect.flatMap((runningNames) => Effect.forEach(items, (item) => {
68
- const running = runningNames.includes(item.containerName);
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]));
78
- }, { concurrency: 4 })), Effect.map((entries) => toRuntimeMap(entries)), Effect.match({
79
- onFailure: () => emptyRuntimeByProject(),
80
- onSuccess: (runtimeByProject) => runtimeByProject
81
- }));
82
- export const runtimeForSelection = (view, selected) => view.runtimeByProject[selected.projectDir] ?? stoppedRuntime();
@@ -1,15 +0,0 @@
1
- import { sortItemsByLaunchTime } from "./menu-select-order.js";
2
- const emptyRuntimeByProject = () => ({});
3
- export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
4
- const sortedItems = sortItemsByLaunchTime(items, runtimeByProject);
5
- context.setMessage(null);
6
- context.setView({
7
- _tag: "SelectProject",
8
- purpose,
9
- items: sortedItems,
10
- runtimeByProject,
11
- selected: 0,
12
- confirmDelete: false,
13
- connectEnableMcpPlaywright: false
14
- });
15
- };
@@ -1,98 +0,0 @@
1
- import { Match } from "effect";
2
- import { runAuthSelection, runConnectSelection, runDeleteSelection, runDownSelection, runInfoSelection } from "./menu-select-actions.js";
3
- import { isConnectMcpToggleInput } from "./menu-select-connect.js";
4
- import { runtimeForSelection } from "./menu-select-runtime.js";
5
- import { resetToMenu } from "./menu-shared.js";
6
- export { startSelectView } from "./menu-select-view.js";
7
- const clampIndex = (value, size) => {
8
- if (size <= 0) {
9
- return 0;
10
- }
11
- if (value < 0) {
12
- return 0;
13
- }
14
- if (value >= size) {
15
- return size - 1;
16
- }
17
- return value;
18
- };
19
- export const handleSelectInput = (input, key, view, context) => {
20
- if (key.escape) {
21
- resetToMenu(context);
22
- return;
23
- }
24
- if (handleConnectOptionToggle(input, view, context)) {
25
- return;
26
- }
27
- if (handleSelectNavigation(key, view, context)) {
28
- return;
29
- }
30
- if (key.return) {
31
- handleSelectReturn(view, context);
32
- return;
33
- }
34
- if (input.trim().length > 0) {
35
- context.setMessage("Use arrows + Enter to select a project, Esc to cancel.");
36
- }
37
- };
38
- const handleConnectOptionToggle = (input, view, context) => {
39
- if (view.purpose !== "Connect" || !isConnectMcpToggleInput(input)) {
40
- return false;
41
- }
42
- const nextValue = !view.connectEnableMcpPlaywright;
43
- context.setView({ ...view, connectEnableMcpPlaywright: nextValue, confirmDelete: false });
44
- context.setMessage(nextValue
45
- ? "Playwright MCP will be enabled before SSH (press Enter to connect)."
46
- : "Playwright MCP toggle is OFF (press Enter to connect without changes).");
47
- return true;
48
- };
49
- const handleSelectNavigation = (key, view, context) => {
50
- if (key.upArrow) {
51
- const next = clampIndex(view.selected - 1, view.items.length);
52
- context.setView({ ...view, selected: next, confirmDelete: false });
53
- return true;
54
- }
55
- if (key.downArrow) {
56
- const next = clampIndex(view.selected + 1, view.items.length);
57
- context.setView({ ...view, selected: next, confirmDelete: false });
58
- return true;
59
- }
60
- return false;
61
- };
62
- const formatSshSessionsLabel = (sshSessions) => sshSessions === 1 ? "1 active SSH session" : `${sshSessions} active SSH sessions`;
63
- const handleSelectReturn = (view, context) => {
64
- const selected = view.items[view.selected];
65
- if (!selected) {
66
- context.setMessage("No project selected.");
67
- resetToMenu(context);
68
- return;
69
- }
70
- const selectedRuntime = runtimeForSelection(view, selected);
71
- const sshSessionsLabel = formatSshSessionsLabel(selectedRuntime.sshSessions);
72
- Match.value(view.purpose).pipe(Match.when("Connect", () => {
73
- context.setActiveDir(selected.projectDir);
74
- runConnectSelection(selected, context, view.connectEnableMcpPlaywright);
75
- }), Match.when("Auth", () => {
76
- context.setActiveDir(selected.projectDir);
77
- runAuthSelection(selected, context);
78
- }), Match.when("Down", () => {
79
- if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
80
- context.setMessage(`${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.`);
81
- context.setView({ ...view, confirmDelete: true });
82
- return;
83
- }
84
- context.setActiveDir(selected.projectDir);
85
- runDownSelection(selected, context);
86
- }), Match.when("Info", () => {
87
- context.setActiveDir(selected.projectDir);
88
- runInfoSelection(selected, context);
89
- }), Match.when("Delete", () => {
90
- if (!view.confirmDelete) {
91
- const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : "";
92
- context.setMessage(`Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.`);
93
- context.setView({ ...view, confirmDelete: true });
94
- return;
95
- }
96
- runDeleteSelection(selected, context);
97
- }), Match.exhaustive);
98
- };