@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.
- package/.jscpd.json +16 -0
- package/.package.json.release.bak +109 -0
- package/CHANGELOG.md +31 -0
- package/README.md +173 -0
- package/biome.json +34 -0
- package/dist/main.js +847 -0
- package/dist/main.js.map +1 -0
- package/dist/src/app/main.js +15 -0
- package/dist/src/app/program.js +61 -0
- package/dist/src/docker-git/cli/input.js +21 -0
- package/dist/src/docker-git/cli/parser-attach.js +19 -0
- package/dist/src/docker-git/cli/parser-auth.js +70 -0
- package/dist/src/docker-git/cli/parser-clone.js +40 -0
- package/dist/src/docker-git/cli/parser-create.js +1 -0
- package/dist/src/docker-git/cli/parser-options.js +101 -0
- package/dist/src/docker-git/cli/parser-panes.js +19 -0
- package/dist/src/docker-git/cli/parser-sessions.js +69 -0
- package/dist/src/docker-git/cli/parser-shared.js +26 -0
- package/dist/src/docker-git/cli/parser-state.js +62 -0
- package/dist/src/docker-git/cli/parser.js +42 -0
- package/dist/src/docker-git/cli/read-command.js +17 -0
- package/dist/src/docker-git/cli/usage.js +99 -0
- package/dist/src/docker-git/main.js +15 -0
- package/dist/src/docker-git/menu-actions.js +115 -0
- package/dist/src/docker-git/menu-create.js +203 -0
- package/dist/src/docker-git/menu-input.js +2 -0
- package/dist/src/docker-git/menu-menu.js +46 -0
- package/dist/src/docker-git/menu-render.js +151 -0
- package/dist/src/docker-git/menu-select.js +131 -0
- package/dist/src/docker-git/menu-shared.js +111 -0
- package/dist/src/docker-git/menu-types.js +19 -0
- package/dist/src/docker-git/menu.js +237 -0
- package/dist/src/docker-git/program.js +38 -0
- package/dist/src/docker-git/tmux.js +176 -0
- package/eslint.config.mts +305 -0
- package/eslint.effect-ts-check.config.mjs +220 -0
- package/linter.config.json +33 -0
- package/package.json +63 -0
- package/src/app/main.ts +18 -0
- package/src/app/program.ts +75 -0
- package/src/docker-git/cli/input.ts +29 -0
- package/src/docker-git/cli/parser-attach.ts +22 -0
- package/src/docker-git/cli/parser-auth.ts +124 -0
- package/src/docker-git/cli/parser-clone.ts +55 -0
- package/src/docker-git/cli/parser-create.ts +3 -0
- package/src/docker-git/cli/parser-options.ts +152 -0
- package/src/docker-git/cli/parser-panes.ts +22 -0
- package/src/docker-git/cli/parser-sessions.ts +101 -0
- package/src/docker-git/cli/parser-shared.ts +51 -0
- package/src/docker-git/cli/parser-state.ts +86 -0
- package/src/docker-git/cli/parser.ts +73 -0
- package/src/docker-git/cli/read-command.ts +26 -0
- package/src/docker-git/cli/usage.ts +112 -0
- package/src/docker-git/main.ts +18 -0
- package/src/docker-git/menu-actions.ts +246 -0
- package/src/docker-git/menu-create.ts +320 -0
- package/src/docker-git/menu-input.ts +2 -0
- package/src/docker-git/menu-menu.ts +58 -0
- package/src/docker-git/menu-render.ts +327 -0
- package/src/docker-git/menu-select.ts +250 -0
- package/src/docker-git/menu-shared.ts +141 -0
- package/src/docker-git/menu-types.ts +94 -0
- package/src/docker-git/menu.ts +339 -0
- package/src/docker-git/program.ts +134 -0
- package/src/docker-git/tmux.ts +292 -0
- package/tests/app/main.test.ts +60 -0
- package/tests/docker-git/entrypoint-auth.test.ts +29 -0
- package/tests/docker-git/parser.test.ts +172 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +32 -0
- 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,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
|
+
];
|