@prover-coder-ai/docker-git 1.0.5

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 (72) hide show
  1. package/.jscpd.json +16 -0
  2. package/.package.json.release.bak +109 -0
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +173 -0
  5. package/biome.json +34 -0
  6. package/dist/main.js +847 -0
  7. package/dist/main.js.map +1 -0
  8. package/dist/src/app/main.js +15 -0
  9. package/dist/src/app/program.js +61 -0
  10. package/dist/src/docker-git/cli/input.js +21 -0
  11. package/dist/src/docker-git/cli/parser-attach.js +19 -0
  12. package/dist/src/docker-git/cli/parser-auth.js +70 -0
  13. package/dist/src/docker-git/cli/parser-clone.js +40 -0
  14. package/dist/src/docker-git/cli/parser-create.js +1 -0
  15. package/dist/src/docker-git/cli/parser-options.js +101 -0
  16. package/dist/src/docker-git/cli/parser-panes.js +19 -0
  17. package/dist/src/docker-git/cli/parser-sessions.js +69 -0
  18. package/dist/src/docker-git/cli/parser-shared.js +26 -0
  19. package/dist/src/docker-git/cli/parser-state.js +62 -0
  20. package/dist/src/docker-git/cli/parser.js +42 -0
  21. package/dist/src/docker-git/cli/read-command.js +17 -0
  22. package/dist/src/docker-git/cli/usage.js +99 -0
  23. package/dist/src/docker-git/main.js +15 -0
  24. package/dist/src/docker-git/menu-actions.js +115 -0
  25. package/dist/src/docker-git/menu-create.js +203 -0
  26. package/dist/src/docker-git/menu-input.js +2 -0
  27. package/dist/src/docker-git/menu-menu.js +46 -0
  28. package/dist/src/docker-git/menu-render.js +151 -0
  29. package/dist/src/docker-git/menu-select.js +131 -0
  30. package/dist/src/docker-git/menu-shared.js +111 -0
  31. package/dist/src/docker-git/menu-types.js +19 -0
  32. package/dist/src/docker-git/menu.js +237 -0
  33. package/dist/src/docker-git/program.js +38 -0
  34. package/dist/src/docker-git/tmux.js +176 -0
  35. package/eslint.config.mts +305 -0
  36. package/eslint.effect-ts-check.config.mjs +220 -0
  37. package/linter.config.json +33 -0
  38. package/package.json +63 -0
  39. package/src/app/main.ts +18 -0
  40. package/src/app/program.ts +75 -0
  41. package/src/docker-git/cli/input.ts +29 -0
  42. package/src/docker-git/cli/parser-attach.ts +22 -0
  43. package/src/docker-git/cli/parser-auth.ts +124 -0
  44. package/src/docker-git/cli/parser-clone.ts +55 -0
  45. package/src/docker-git/cli/parser-create.ts +3 -0
  46. package/src/docker-git/cli/parser-options.ts +152 -0
  47. package/src/docker-git/cli/parser-panes.ts +22 -0
  48. package/src/docker-git/cli/parser-sessions.ts +101 -0
  49. package/src/docker-git/cli/parser-shared.ts +51 -0
  50. package/src/docker-git/cli/parser-state.ts +86 -0
  51. package/src/docker-git/cli/parser.ts +73 -0
  52. package/src/docker-git/cli/read-command.ts +26 -0
  53. package/src/docker-git/cli/usage.ts +112 -0
  54. package/src/docker-git/main.ts +18 -0
  55. package/src/docker-git/menu-actions.ts +246 -0
  56. package/src/docker-git/menu-create.ts +320 -0
  57. package/src/docker-git/menu-input.ts +2 -0
  58. package/src/docker-git/menu-menu.ts +58 -0
  59. package/src/docker-git/menu-render.ts +327 -0
  60. package/src/docker-git/menu-select.ts +250 -0
  61. package/src/docker-git/menu-shared.ts +141 -0
  62. package/src/docker-git/menu-types.ts +94 -0
  63. package/src/docker-git/menu.ts +339 -0
  64. package/src/docker-git/program.ts +134 -0
  65. package/src/docker-git/tmux.ts +292 -0
  66. package/tests/app/main.test.ts +60 -0
  67. package/tests/docker-git/entrypoint-auth.test.ts +29 -0
  68. package/tests/docker-git/parser.test.ts +172 -0
  69. package/tsconfig.build.json +8 -0
  70. package/tsconfig.json +20 -0
  71. package/vite.config.ts +32 -0
  72. package/vitest.config.ts +85 -0
@@ -0,0 +1,203 @@
1
+ import { deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain";
2
+ import { createProject } from "@effect-template/lib/usecases/actions";
3
+ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers";
4
+ import * as Path from "@effect/platform/Path";
5
+ import { Effect, Either, Match, pipe } from "effect";
6
+ import { parseArgs } from "./cli/parser.js";
7
+ import { formatParseError, usageText } from "./cli/usage.js";
8
+ import { resetToMenu } from "./menu-shared.js";
9
+ import { createSteps } from "./menu-types.js";
10
+ export const buildCreateArgs = (input) => {
11
+ const args = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot];
12
+ if (input.repoRef.length > 0) {
13
+ args.push("--repo-ref", input.repoRef);
14
+ }
15
+ args.push("--out-dir", input.outDir);
16
+ if (!input.runUp) {
17
+ args.push("--no-up");
18
+ }
19
+ if (input.enableMcpPlaywright) {
20
+ args.push("--mcp-playwright");
21
+ }
22
+ if (input.force) {
23
+ args.push("--force");
24
+ }
25
+ if (input.forceEnv) {
26
+ args.push("--force-env");
27
+ }
28
+ return args;
29
+ };
30
+ const trimLeftSlash = (value) => {
31
+ let start = 0;
32
+ while (start < value.length && value[start] === "/") {
33
+ start += 1;
34
+ }
35
+ return value.slice(start);
36
+ };
37
+ const trimRightSlash = (value) => {
38
+ let end = value.length;
39
+ while (end > 0 && value[end - 1] === "/") {
40
+ end -= 1;
41
+ }
42
+ return value.slice(0, end);
43
+ };
44
+ const joinPath = (...parts) => {
45
+ const cleaned = parts
46
+ .filter((part) => part.length > 0)
47
+ .map((part, index) => {
48
+ if (index === 0) {
49
+ return trimRightSlash(part);
50
+ }
51
+ return trimRightSlash(trimLeftSlash(part));
52
+ });
53
+ return cleaned.join("/");
54
+ };
55
+ const resolveDefaultOutDir = (cwd, repoUrl) => {
56
+ const resolvedRepo = resolveRepoInput(repoUrl);
57
+ const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts;
58
+ const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts;
59
+ return joinPath(defaultProjectsRoot(cwd), ...projectParts);
60
+ };
61
+ export const resolveCreateInputs = (cwd, values) => {
62
+ const repoUrl = values.repoUrl ?? "";
63
+ const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined;
64
+ const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets");
65
+ const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "");
66
+ return {
67
+ repoUrl,
68
+ repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
69
+ outDir,
70
+ secretsRoot,
71
+ runUp: values.runUp !== false,
72
+ enableMcpPlaywright: values.enableMcpPlaywright === true,
73
+ force: values.force === true,
74
+ forceEnv: values.forceEnv === true
75
+ };
76
+ };
77
+ const parseYesDefault = (input, fallback) => {
78
+ const normalized = input.trim().toLowerCase();
79
+ if (normalized === "y" || normalized === "yes") {
80
+ return true;
81
+ }
82
+ if (normalized === "n" || normalized === "no") {
83
+ return false;
84
+ }
85
+ return fallback;
86
+ };
87
+ const applyCreateCommand = (state, create) => Effect.gen(function* (_) {
88
+ const path = yield* _(Path.Path);
89
+ const resolvedOutDir = path.resolve(create.outDir);
90
+ yield* _(createProject(create));
91
+ return { _tag: "Continue", state: { ...state, activeDir: resolvedOutDir } };
92
+ });
93
+ const isCreateCommand = (command) => command._tag === "Create";
94
+ const buildCreateEffect = (command, state, setActiveDir, setMessage) => {
95
+ if (isCreateCommand(command)) {
96
+ return pipe(applyCreateCommand(state, command), Effect.tap((outcome) => Effect.sync(() => {
97
+ setActiveDir(outcome.state.activeDir);
98
+ })), Effect.asVoid);
99
+ }
100
+ if (command._tag === "Help") {
101
+ return Effect.sync(() => {
102
+ setMessage(usageText);
103
+ });
104
+ }
105
+ return Effect.void;
106
+ };
107
+ const applyCreateStep = (input) => Match.value(input.step).pipe(Match.when("repoUrl", () => {
108
+ if (input.buffer.length === 0) {
109
+ input.setMessage("Repo URL is required.");
110
+ return false;
111
+ }
112
+ input.nextValues.repoUrl = input.buffer;
113
+ input.nextValues.outDir = resolveDefaultOutDir(input.cwd, input.buffer);
114
+ return true;
115
+ }), Match.when("repoRef", () => {
116
+ input.nextValues.repoRef = input.buffer.length > 0 ? input.buffer : input.currentDefaults.repoRef;
117
+ return true;
118
+ }), Match.when("outDir", () => {
119
+ input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir;
120
+ return true;
121
+ }), Match.when("runUp", () => {
122
+ input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp);
123
+ return true;
124
+ }), Match.when("mcpPlaywright", () => {
125
+ input.nextValues.enableMcpPlaywright = parseYesDefault(input.buffer, input.currentDefaults.enableMcpPlaywright);
126
+ return true;
127
+ }), Match.when("force", () => {
128
+ input.nextValues.force = parseYesDefault(input.buffer, input.currentDefaults.force);
129
+ return true;
130
+ }), Match.exhaustive);
131
+ const finalizeCreateFlow = (input) => {
132
+ const inputs = resolveCreateInputs(input.state.cwd, input.nextValues);
133
+ if (inputs.repoUrl.length === 0) {
134
+ input.setMessage("Repo URL is required.");
135
+ return;
136
+ }
137
+ const parsed = parseArgs(buildCreateArgs(inputs));
138
+ if (Either.isLeft(parsed)) {
139
+ input.setMessage(formatParseError(parsed.left));
140
+ input.setView({ _tag: "Menu" });
141
+ return;
142
+ }
143
+ const effect = buildCreateEffect(parsed.right, input.state, input.setActiveDir, input.setMessage);
144
+ input.runner.runEffect(effect);
145
+ input.setView({ _tag: "Menu" });
146
+ input.setMessage(null);
147
+ };
148
+ const handleCreateReturn = (context) => {
149
+ const step = createSteps[context.view.step];
150
+ if (!step) {
151
+ context.setView({ _tag: "Menu" });
152
+ return;
153
+ }
154
+ const buffer = context.view.buffer.trim();
155
+ const currentDefaults = resolveCreateInputs(context.state.cwd, context.view.values);
156
+ const nextValues = { ...context.view.values };
157
+ const updated = applyCreateStep({
158
+ step,
159
+ buffer,
160
+ currentDefaults,
161
+ nextValues,
162
+ cwd: context.state.cwd,
163
+ setMessage: context.setMessage
164
+ });
165
+ if (!updated) {
166
+ return;
167
+ }
168
+ const nextStep = context.view.step + 1;
169
+ if (nextStep < createSteps.length) {
170
+ context.setView({ _tag: "Create", step: nextStep, buffer: "", values: nextValues });
171
+ context.setMessage(null);
172
+ return;
173
+ }
174
+ finalizeCreateFlow({
175
+ state: context.state,
176
+ nextValues,
177
+ setView: context.setView,
178
+ setMessage: context.setMessage,
179
+ runner: context.runner,
180
+ setActiveDir: context.setActiveDir
181
+ });
182
+ };
183
+ export const startCreateView = (setView, setMessage, buffer = "") => {
184
+ setView({ _tag: "Create", step: 0, buffer, values: {} });
185
+ setMessage(null);
186
+ };
187
+ export const handleCreateInput = (input, key, view, context) => {
188
+ if (key.escape) {
189
+ resetToMenu(context);
190
+ return;
191
+ }
192
+ if (key.return) {
193
+ handleCreateReturn({ ...context, view });
194
+ return;
195
+ }
196
+ if (key.backspace || key.delete) {
197
+ context.setView({ ...view, buffer: view.buffer.slice(0, -1) });
198
+ return;
199
+ }
200
+ if (input.length > 0) {
201
+ context.setView({ ...view, buffer: view.buffer + input });
202
+ }
203
+ };
@@ -0,0 +1,2 @@
1
+ export { buildCreateArgs, handleCreateInput, resolveCreateInputs, startCreateView } from "./menu-create.js";
2
+ export { handleMenuInput } from "./menu-menu.js";
@@ -0,0 +1,46 @@
1
+ import { parseMenuSelection } from "@effect-template/lib/core/domain";
2
+ import { isRepoUrlInput } from "@effect-template/lib/usecases/menu-helpers";
3
+ import { Either } from "effect";
4
+ import { handleMenuActionSelection } from "./menu-actions.js";
5
+ import { startCreateView } from "./menu-create.js";
6
+ import { menuItems } from "./menu-types.js";
7
+ const handleMenuNavigation = (key, setSelected) => {
8
+ if (key.upArrow) {
9
+ setSelected((prev) => (prev === 0 ? menuItems.length - 1 : prev - 1));
10
+ return;
11
+ }
12
+ if (key.downArrow) {
13
+ setSelected((prev) => (prev === menuItems.length - 1 ? 0 : prev + 1));
14
+ }
15
+ };
16
+ const handleMenuEnter = (context) => {
17
+ const action = menuItems[context.selected]?.id;
18
+ if (!action) {
19
+ return;
20
+ }
21
+ handleMenuActionSelection(action, context);
22
+ };
23
+ const handleMenuTextInput = (input, context) => {
24
+ const trimmed = input.trim();
25
+ if (trimmed.length > 0 && isRepoUrlInput(trimmed)) {
26
+ startCreateView(context.setView, context.setMessage, trimmed);
27
+ return true;
28
+ }
29
+ const selection = parseMenuSelection(input);
30
+ if (Either.isRight(selection)) {
31
+ handleMenuActionSelection(selection.right, context);
32
+ return true;
33
+ }
34
+ return false;
35
+ };
36
+ export const handleMenuInput = (input, key, context) => {
37
+ if (key.upArrow || key.downArrow) {
38
+ handleMenuNavigation(key, context.setSelected);
39
+ return;
40
+ }
41
+ if (key.return) {
42
+ handleMenuEnter(context);
43
+ return;
44
+ }
45
+ handleMenuTextInput(input, context);
46
+ };
@@ -0,0 +1,151 @@
1
+ import { Match } from "effect";
2
+ import { Box, Text } from "ink";
3
+ import React from "react";
4
+ import { createSteps, menuItems } from "./menu-types.js";
5
+ // CHANGE: render menu views with Ink without JSX
6
+ // WHY: keep UI logic separate from input/state reducers
7
+ // QUOTE(ТЗ): "TUI? Красивый, удобный"
8
+ // REF: user-request-2026-02-01-tui
9
+ // SOURCE: n/a
10
+ // FORMAT THEOREM: forall v: view(v) -> render(v)
11
+ // PURITY: SHELL
12
+ // EFFECT: n/a
13
+ // INVARIANT: menu renders all items once
14
+ // COMPLEXITY: O(n)
15
+ 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);
16
+ const renderMessage = (message) => {
17
+ if (!message) {
18
+ return null;
19
+ }
20
+ return React.createElement(Box, { marginTop: 1 }, React.createElement(Text, { color: "magenta" }, message));
21
+ };
22
+ const renderLayout = (title, body, message) => {
23
+ const el = React.createElement;
24
+ const messageView = renderMessage(message);
25
+ const tail = messageView ? [messageView] : [];
26
+ return el(Box, { flexDirection: "column", padding: 1, borderStyle: "round" }, el(Text, { color: "cyan", bold: true }, title), ...body, ...tail);
27
+ };
28
+ const compactElements = (items) => items.filter((item) => item !== null);
29
+ 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, 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."));
30
+ const renderMenuMessage = (el, message) => {
31
+ if (!message || message.length === 0) {
32
+ return null;
33
+ }
34
+ return el(Box, { marginTop: 1, flexDirection: "column" }, ...message
35
+ .split("\n")
36
+ .map((line, index) => el(Text, { key: `${index}-${line}`, color: "magenta" }, line)));
37
+ };
38
+ export const renderMenu = (cwd, activeDir, selected, busy, message) => {
39
+ const el = React.createElement;
40
+ const activeLabel = `Active: ${activeDir ?? "(none)"}`;
41
+ const cwdLabel = `CWD: ${cwd}`;
42
+ const items = menuItems.map((item, index) => {
43
+ const indexLabel = `${index + 1})`;
44
+ const prefix = index === selected ? ">" : " ";
45
+ return el(Text, { key: item.label, color: index === selected ? "green" : "white" }, `${prefix} ${indexLabel} ${item.label}`);
46
+ });
47
+ const busyView = busy
48
+ ? el(Box, { marginTop: 1 }, el(Text, { color: "yellow" }, "Running..."))
49
+ : null;
50
+ const messageView = renderMenuMessage(el, message);
51
+ const hints = renderMenuHints(el);
52
+ return renderLayout("docker-git", compactElements([
53
+ el(Text, null, activeLabel),
54
+ el(Text, null, cwdLabel),
55
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
56
+ hints,
57
+ busyView,
58
+ messageView
59
+ ]), null);
60
+ };
61
+ export const renderCreate = (label, buffer, message, stepIndex, defaults) => {
62
+ const el = React.createElement;
63
+ const steps = createSteps.map((step, index) => el(Text, { key: step, color: index === stepIndex ? "green" : "gray" }, `${index === stepIndex ? ">" : " "} ${renderStepLabel(step, defaults)}`));
64
+ return renderLayout("docker-git / Create", [
65
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...steps),
66
+ el(Box, { marginTop: 1 }, el(Text, null, `${label}: `), el(Text, { color: "green" }, buffer)),
67
+ el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
68
+ ], message);
69
+ };
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
+ const computeListWidth = (labels) => {
121
+ const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24;
122
+ return Math.min(Math.max(maxLabelWidth + 2, 28), 54);
123
+ };
124
+ const renderSelectListBox = (el, items, selected, labels, width) => {
125
+ const list = labels.map((label, index) => el(Text, {
126
+ key: items[index]?.projectDir ?? String(index),
127
+ color: index === selected ? "green" : "white",
128
+ wrap: "truncate"
129
+ }, label));
130
+ return el(Box, { flexDirection: "column", width }, ...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")]));
131
+ };
132
+ const renderSelectDetailsBox = (el, purpose, items, selected) => {
133
+ const details = renderSelectDetails(el, purpose, items[selected]);
134
+ return el(Box, { flexDirection: "column", marginLeft: 2, flexGrow: 1 }, ...details);
135
+ };
136
+ export const renderSelect = (purpose, items, selected, confirmDelete, message) => {
137
+ const el = React.createElement;
138
+ const listLabels = buildSelectLabels(items, selected);
139
+ const listWidth = computeListWidth(listLabels);
140
+ const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth);
141
+ const detailsBox = renderSelectDetailsBox(el, purpose, items, selected);
142
+ const baseHint = selectHint(purpose);
143
+ const deleteHint = purpose === "Delete" && confirmDelete
144
+ ? "Confirm mode: Enter = delete now, Esc = cancel"
145
+ : baseHint;
146
+ const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, deleteHint));
147
+ return renderLayout(selectTitle(purpose), [
148
+ el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox),
149
+ hints
150
+ ], message);
151
+ };
@@ -0,0 +1,131 @@
1
+ import { runDockerComposeDown } from "@effect-template/lib/shell/docker";
2
+ import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
3
+ import { Effect, Match, pipe } from "effect";
4
+ import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js";
5
+ export const startSelectView = (items, purpose, context) => {
6
+ context.setMessage(null);
7
+ context.setView({ _tag: "SelectProject", purpose, items, selected: 0, confirmDelete: false });
8
+ };
9
+ const clampIndex = (value, size) => {
10
+ if (size <= 0) {
11
+ return 0;
12
+ }
13
+ if (value < 0) {
14
+ return 0;
15
+ }
16
+ if (value >= size) {
17
+ return size - 1;
18
+ }
19
+ return value;
20
+ };
21
+ export const handleSelectInput = (input, key, view, context) => {
22
+ if (key.escape) {
23
+ resetToMenu(context);
24
+ return;
25
+ }
26
+ if (handleSelectNavigation(key, view, context)) {
27
+ return;
28
+ }
29
+ if (key.return) {
30
+ handleSelectReturn(view, context);
31
+ return;
32
+ }
33
+ handleSelectHint(input, context);
34
+ };
35
+ const handleSelectNavigation = (key, view, context) => {
36
+ if (key.upArrow) {
37
+ const next = clampIndex(view.selected - 1, view.items.length);
38
+ context.setView({ ...view, selected: next, confirmDelete: false });
39
+ return true;
40
+ }
41
+ if (key.downArrow) {
42
+ const next = clampIndex(view.selected + 1, view.items.length);
43
+ context.setView({ ...view, selected: next, confirmDelete: false });
44
+ return true;
45
+ }
46
+ return false;
47
+ };
48
+ const runWithSuspendedTui = (context, effect, onResume, doneMessage) => {
49
+ context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(effect), Effect.ensuring(Effect.sync(() => {
50
+ resumeTui();
51
+ onResume();
52
+ context.setSkipInputs(() => 2);
53
+ })), Effect.tap(() => Effect.sync(() => {
54
+ context.setMessage(doneMessage);
55
+ }))));
56
+ };
57
+ const runConnectSelection = (selected, context) => {
58
+ context.setMessage(`Connecting to ${selected.displayName}...`);
59
+ context.setSshActive(true);
60
+ runWithSuspendedTui(context, connectProjectSshWithUp(selected), () => {
61
+ context.setSshActive(false);
62
+ }, "SSH session ended. Press Esc to return to the menu.");
63
+ };
64
+ const runDownSelection = (selected, context) => {
65
+ 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(() => {
67
+ if (items.length === 0) {
68
+ resetToMenu(context);
69
+ context.setMessage("No running docker-git containers.");
70
+ return;
71
+ }
72
+ startSelectView(items, "Down", context);
73
+ context.setMessage("Container stopped. Select another to stop, or Esc to return.");
74
+ })), Effect.ensuring(Effect.sync(() => {
75
+ resumeTui();
76
+ context.setSkipInputs(() => 2);
77
+ })), Effect.asVoid));
78
+ };
79
+ const runInfoSelection = (selected, context) => {
80
+ context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`);
81
+ };
82
+ const runDeleteSelection = (selected, context) => {
83
+ context.setMessage(`Deleting ${selected.displayName}...`);
84
+ runWithSuspendedTui(context, deleteDockerGitProject(selected).pipe(Effect.tap(() => Effect.sync(() => {
85
+ if (context.activeDir === selected.projectDir) {
86
+ context.setActiveDir(null);
87
+ }
88
+ context.setView({ _tag: "Menu" });
89
+ }))), () => {
90
+ // Only return to menu on success (see Effect.tap above).
91
+ }, "Project deleted.");
92
+ };
93
+ const handleSelectReturn = (view, context) => {
94
+ const selected = view.items[view.selected];
95
+ if (!selected) {
96
+ context.setMessage("No project selected.");
97
+ resetToMenu(context);
98
+ return;
99
+ }
100
+ Match.value(view.purpose).pipe(Match.when("Connect", () => {
101
+ context.setActiveDir(selected.projectDir);
102
+ runConnectSelection(selected, context);
103
+ }), Match.when("Down", () => {
104
+ context.setActiveDir(selected.projectDir);
105
+ runDownSelection(selected, context);
106
+ }), Match.when("Info", () => {
107
+ context.setActiveDir(selected.projectDir);
108
+ runInfoSelection(selected, context);
109
+ }), Match.when("Delete", () => {
110
+ if (!view.confirmDelete) {
111
+ context.setMessage(`Really delete ${selected.displayName}? Press Enter again to confirm, Esc to cancel.`);
112
+ context.setView({ ...view, confirmDelete: true });
113
+ return;
114
+ }
115
+ runDeleteSelection(selected, context);
116
+ }), Match.exhaustive);
117
+ };
118
+ const handleSelectHint = (input, context) => {
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(() => {
124
+ if (items.length === 0) {
125
+ context.setMessage(purpose === "Down"
126
+ ? "No running docker-git containers."
127
+ : "No docker-git projects found.");
128
+ return;
129
+ }
130
+ startSelectView(items, purpose, context);
131
+ })));
@@ -0,0 +1,111 @@
1
+ let stdoutPatched = false;
2
+ let stdoutMuted = false;
3
+ const disableMouseModes = () => {
4
+ // Disable xterm/urxvt mouse tracking and "alternate scroll" mode (wheel -> arrow keys).
5
+ process.stdout.write("\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l");
6
+ };
7
+ // CHANGE: mute Ink stdout writes while SSH is active
8
+ // WHY: prevent Ink resize re-renders from corrupting the SSH terminal buffer
9
+ // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
10
+ // REF: user-request-2026-02-05-ssh-resize
11
+ // SOURCE: n/a
12
+ // FORMAT THEOREM: ∀w: muted(w) → ¬writes(ink, stdout)
13
+ // PURITY: SHELL
14
+ // EFFECT: n/a
15
+ // INVARIANT: wrapper preserves original stdout write when not muted
16
+ // COMPLEXITY: O(1)
17
+ const ensureStdoutPatched = () => {
18
+ if (stdoutPatched) {
19
+ return;
20
+ }
21
+ const baseWrite = process.stdout.write.bind(process.stdout);
22
+ const mutedWrite = (chunk, encoding, cb) => {
23
+ if (stdoutMuted) {
24
+ const callback = typeof encoding === "function" ? encoding : cb;
25
+ if (typeof callback === "function") {
26
+ callback();
27
+ }
28
+ return true;
29
+ }
30
+ if (typeof encoding === "function") {
31
+ return baseWrite(chunk, encoding);
32
+ }
33
+ return baseWrite(chunk, encoding, cb);
34
+ };
35
+ process.stdout.write = mutedWrite;
36
+ stdoutPatched = true;
37
+ };
38
+ // CHANGE: toggle stdout write muting for Ink rendering
39
+ // WHY: allow SSH sessions to own the terminal without TUI redraws
40
+ // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
41
+ // REF: user-request-2026-02-05-ssh-resize
42
+ // SOURCE: n/a
43
+ // FORMAT THEOREM: ∀m ∈ {true,false}: muted = m
44
+ // PURITY: SHELL
45
+ // EFFECT: n/a
46
+ // INVARIANT: stdout wrapper is installed at most once
47
+ // COMPLEXITY: O(1)
48
+ const setStdoutMuted = (muted) => {
49
+ ensureStdoutPatched();
50
+ stdoutMuted = muted;
51
+ };
52
+ // CHANGE: temporarily suspend TUI rendering when running interactive commands
53
+ // WHY: avoid mixed output from docker/ssh and the Ink UI
54
+ // QUOTE(ТЗ): "Почему так кривокосо всё отображается?"
55
+ // REF: user-request-2026-02-02-tui-output
56
+ // SOURCE: n/a
57
+ // FORMAT THEOREM: forall cmd: suspend -> cleanOutput(cmd)
58
+ // PURITY: SHELL
59
+ // EFFECT: n/a
60
+ // INVARIANT: only toggles when TTY is available
61
+ // COMPLEXITY: O(1)
62
+ export const suspendTui = () => {
63
+ if (!process.stdout.isTTY) {
64
+ return;
65
+ }
66
+ disableMouseModes();
67
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
68
+ process.stdin.setRawMode(false);
69
+ }
70
+ process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H");
71
+ setStdoutMuted(true);
72
+ };
73
+ // CHANGE: restore TUI rendering after interactive commands
74
+ // WHY: return to Ink UI without broken terminal state
75
+ // QUOTE(ТЗ): "Почему так кривокосо всё отображается?"
76
+ // REF: user-request-2026-02-02-tui-output
77
+ // SOURCE: n/a
78
+ // FORMAT THEOREM: forall cmd: resume -> tuiVisible(cmd)
79
+ // PURITY: SHELL
80
+ // EFFECT: n/a
81
+ // INVARIANT: only toggles when TTY is available
82
+ // COMPLEXITY: O(1)
83
+ export const resumeTui = () => {
84
+ if (!process.stdout.isTTY) {
85
+ return;
86
+ }
87
+ setStdoutMuted(false);
88
+ disableMouseModes();
89
+ process.stdout.write("\u001B[?1049h\u001B[2J\u001B[H");
90
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
91
+ process.stdin.setRawMode(true);
92
+ }
93
+ disableMouseModes();
94
+ };
95
+ export const leaveTui = () => {
96
+ if (!process.stdout.isTTY) {
97
+ return;
98
+ }
99
+ // Ensure we don't leave the terminal in a broken "mouse reporting" mode.
100
+ setStdoutMuted(false);
101
+ disableMouseModes();
102
+ process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H");
103
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
104
+ process.stdin.setRawMode(false);
105
+ }
106
+ };
107
+ export const resetToMenu = (context) => {
108
+ const view = { _tag: "Menu" };
109
+ context.setView(view);
110
+ context.setMessage(null);
111
+ };
@@ -0,0 +1,19 @@
1
+ export const createSteps = [
2
+ "repoUrl",
3
+ "repoRef",
4
+ "outDir",
5
+ "runUp",
6
+ "mcpPlaywright",
7
+ "force"
8
+ ];
9
+ export const menuItems = [
10
+ { id: { _tag: "Create" }, label: "Create project" },
11
+ { id: { _tag: "Select" }, label: "Select project" },
12
+ { id: { _tag: "Info" }, label: "Show connection info" },
13
+ { id: { _tag: "Status" }, label: "docker compose ps" },
14
+ { id: { _tag: "Logs" }, label: "docker compose logs --tail=200" },
15
+ { id: { _tag: "Down" }, label: "docker compose down" },
16
+ { id: { _tag: "DownAll" }, label: "docker compose down (ALL projects)" },
17
+ { id: { _tag: "Delete" }, label: "Delete project (remove folder)" },
18
+ { id: { _tag: "Quit" }, label: "Quit" }
19
+ ];