@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,237 @@
|
|
|
1
|
+
import { InputReadError } from "@effect-template/lib/shell/errors";
|
|
2
|
+
import { renderError } from "@effect-template/lib/usecases/errors";
|
|
3
|
+
import { NodeContext } from "@effect/platform-node";
|
|
4
|
+
import { Effect, pipe } from "effect";
|
|
5
|
+
import { render, useApp, useInput } from "ink";
|
|
6
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { handleCreateInput, resolveCreateInputs } from "./menu-create.js";
|
|
8
|
+
import { handleMenuInput } from "./menu-menu.js";
|
|
9
|
+
import { renderCreate, renderMenu, renderSelect, renderStepLabel } from "./menu-render.js";
|
|
10
|
+
import { handleSelectInput } from "./menu-select.js";
|
|
11
|
+
import { leaveTui, resumeTui } from "./menu-shared.js";
|
|
12
|
+
import { createSteps } from "./menu-types.js";
|
|
13
|
+
// CHANGE: keep menu state in the TUI layer
|
|
14
|
+
// WHY: provide a dynamic interface with live selection and inputs
|
|
15
|
+
// QUOTE(ТЗ): "TUI? Красивый, удобный"
|
|
16
|
+
// REF: user-request-2026-02-01-tui
|
|
17
|
+
// SOURCE: n/a
|
|
18
|
+
// FORMAT THEOREM: forall s: input(s) -> state'(s)
|
|
19
|
+
// PURITY: SHELL
|
|
20
|
+
// EFFECT: Effect<void, AppError, FileSystem | Path | CommandExecutor>
|
|
21
|
+
// INVARIANT: activeDir updated only after successful create
|
|
22
|
+
// COMPLEXITY: O(1) per keypress
|
|
23
|
+
const useRunner = (setBusy, setMessage) => {
|
|
24
|
+
const runEffect = function (effect) {
|
|
25
|
+
setBusy(true);
|
|
26
|
+
const program = pipe(effect, Effect.matchEffect({
|
|
27
|
+
onFailure: (error) => Effect.sync(() => {
|
|
28
|
+
setMessage(renderError(error));
|
|
29
|
+
}),
|
|
30
|
+
onSuccess: () => Effect.void
|
|
31
|
+
}), Effect.ensuring(Effect.sync(() => {
|
|
32
|
+
setBusy(false);
|
|
33
|
+
})));
|
|
34
|
+
void Effect.runPromise(Effect.provide(program, NodeContext.layer));
|
|
35
|
+
};
|
|
36
|
+
return { runEffect };
|
|
37
|
+
};
|
|
38
|
+
const activateInput = (input, key, context) => {
|
|
39
|
+
if (context.inputStage === "active") {
|
|
40
|
+
return { activated: false, allowProcessing: true };
|
|
41
|
+
}
|
|
42
|
+
if (input.trim().length > 0) {
|
|
43
|
+
context.setInputStage("active");
|
|
44
|
+
return { activated: true, allowProcessing: true };
|
|
45
|
+
}
|
|
46
|
+
if (key.upArrow || key.downArrow || key.return) {
|
|
47
|
+
context.setInputStage("active");
|
|
48
|
+
return { activated: true, allowProcessing: false };
|
|
49
|
+
}
|
|
50
|
+
if (input.length > 0) {
|
|
51
|
+
context.setInputStage("active");
|
|
52
|
+
return { activated: true, allowProcessing: true };
|
|
53
|
+
}
|
|
54
|
+
return { activated: false, allowProcessing: false };
|
|
55
|
+
};
|
|
56
|
+
const shouldHandleMenuInput = (input, key, context) => {
|
|
57
|
+
const activation = activateInput(input, key, context);
|
|
58
|
+
if (activation.activated && !activation.allowProcessing) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return activation.allowProcessing;
|
|
62
|
+
};
|
|
63
|
+
const handleUserInput = (input, key, context) => {
|
|
64
|
+
if (context.busy) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (context.sshActive) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (context.view._tag === "Menu") {
|
|
71
|
+
if (!shouldHandleMenuInput(input, key, context)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
handleMenuInput(input, key, {
|
|
75
|
+
selected: context.selected,
|
|
76
|
+
setSelected: context.setSelected,
|
|
77
|
+
state: context.state,
|
|
78
|
+
runner: context.runner,
|
|
79
|
+
exit: context.exit,
|
|
80
|
+
setView: context.setView,
|
|
81
|
+
setMessage: context.setMessage
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (context.view._tag === "Create") {
|
|
86
|
+
handleCreateInput(input, key, context.view, {
|
|
87
|
+
state: context.state,
|
|
88
|
+
setView: context.setView,
|
|
89
|
+
setMessage: context.setMessage,
|
|
90
|
+
runner: context.runner,
|
|
91
|
+
setActiveDir: context.setActiveDir
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
handleSelectInput(input, key, context.view, {
|
|
96
|
+
setView: context.setView,
|
|
97
|
+
setMessage: context.setMessage,
|
|
98
|
+
setActiveDir: context.setActiveDir,
|
|
99
|
+
activeDir: context.state.activeDir,
|
|
100
|
+
runner: context.runner,
|
|
101
|
+
setSshActive: context.setSshActive,
|
|
102
|
+
setSkipInputs: context.setSkipInputs
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
const renderView = (context) => {
|
|
106
|
+
if (context.view._tag === "Menu") {
|
|
107
|
+
return renderMenu(context.state.cwd, context.activeDir, context.selected, context.busy, context.message);
|
|
108
|
+
}
|
|
109
|
+
if (context.view._tag === "Create") {
|
|
110
|
+
const currentDefaults = resolveCreateInputs(context.state.cwd, context.view.values);
|
|
111
|
+
const step = createSteps[context.view.step] ?? "repoUrl";
|
|
112
|
+
const label = renderStepLabel(step, currentDefaults);
|
|
113
|
+
return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults);
|
|
114
|
+
}
|
|
115
|
+
return renderSelect(context.view.purpose, context.view.items, context.view.selected, context.view.confirmDelete, context.message);
|
|
116
|
+
};
|
|
117
|
+
const useMenuState = () => {
|
|
118
|
+
const [activeDir, setActiveDir] = useState(null);
|
|
119
|
+
const [selected, setSelected] = useState(0);
|
|
120
|
+
const [busy, setBusy] = useState(false);
|
|
121
|
+
const [message, setMessage] = useState(null);
|
|
122
|
+
const [view, setView] = useState({ _tag: "Menu" });
|
|
123
|
+
const [inputStage, setInputStage] = useState("cold");
|
|
124
|
+
const [ready, setReady] = useState(false);
|
|
125
|
+
const [skipInputs, setSkipInputs] = useState(2);
|
|
126
|
+
const [sshActive, setSshActive] = useState(false);
|
|
127
|
+
const ignoreUntil = useMemo(() => Date.now() + 400, []);
|
|
128
|
+
const state = useMemo(() => ({ cwd: process.cwd(), activeDir }), [activeDir]);
|
|
129
|
+
const runner = useRunner(setBusy, setMessage);
|
|
130
|
+
return {
|
|
131
|
+
activeDir,
|
|
132
|
+
setActiveDir,
|
|
133
|
+
selected,
|
|
134
|
+
setSelected,
|
|
135
|
+
busy,
|
|
136
|
+
message,
|
|
137
|
+
setMessage,
|
|
138
|
+
view,
|
|
139
|
+
setView,
|
|
140
|
+
inputStage,
|
|
141
|
+
setInputStage,
|
|
142
|
+
ready,
|
|
143
|
+
setReady,
|
|
144
|
+
skipInputs,
|
|
145
|
+
setSkipInputs,
|
|
146
|
+
sshActive,
|
|
147
|
+
setSshActive,
|
|
148
|
+
ignoreUntil,
|
|
149
|
+
state,
|
|
150
|
+
runner
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
const useReadyGate = (setReady) => {
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
setReady(true);
|
|
157
|
+
}, 150);
|
|
158
|
+
return () => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
};
|
|
161
|
+
}, [setReady]);
|
|
162
|
+
};
|
|
163
|
+
const useSigintGuard = (exit, sshActive) => {
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const handleSigint = () => {
|
|
166
|
+
if (sshActive) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
exit();
|
|
170
|
+
};
|
|
171
|
+
process.on("SIGINT", handleSigint);
|
|
172
|
+
return () => {
|
|
173
|
+
process.off("SIGINT", handleSigint);
|
|
174
|
+
};
|
|
175
|
+
}, [exit, sshActive]);
|
|
176
|
+
};
|
|
177
|
+
const TuiApp = () => {
|
|
178
|
+
const { exit } = useApp();
|
|
179
|
+
const menu = useMenuState();
|
|
180
|
+
useReadyGate(menu.setReady);
|
|
181
|
+
useSigintGuard(exit, menu.sshActive);
|
|
182
|
+
useInput((input, key) => {
|
|
183
|
+
if (!menu.ready) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (Date.now() < menu.ignoreUntil) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (menu.skipInputs > 0) {
|
|
190
|
+
menu.setSkipInputs((value) => (value > 0 ? value - 1 : 0));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
handleUserInput(input, key, {
|
|
194
|
+
busy: menu.busy,
|
|
195
|
+
view: menu.view,
|
|
196
|
+
inputStage: menu.inputStage,
|
|
197
|
+
setInputStage: menu.setInputStage,
|
|
198
|
+
selected: menu.selected,
|
|
199
|
+
setSelected: menu.setSelected,
|
|
200
|
+
setSkipInputs: menu.setSkipInputs,
|
|
201
|
+
sshActive: menu.sshActive,
|
|
202
|
+
setSshActive: menu.setSshActive,
|
|
203
|
+
state: menu.state,
|
|
204
|
+
runner: menu.runner,
|
|
205
|
+
exit,
|
|
206
|
+
setView: menu.setView,
|
|
207
|
+
setMessage: menu.setMessage,
|
|
208
|
+
setActiveDir: menu.setActiveDir
|
|
209
|
+
});
|
|
210
|
+
}, { isActive: !menu.sshActive });
|
|
211
|
+
return renderView({
|
|
212
|
+
state: menu.state,
|
|
213
|
+
view: menu.view,
|
|
214
|
+
activeDir: menu.activeDir,
|
|
215
|
+
selected: menu.selected,
|
|
216
|
+
busy: menu.busy,
|
|
217
|
+
message: menu.message
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
// CHANGE: provide an interactive TUI menu for docker-git
|
|
221
|
+
// WHY: allow dynamic selection and inline create flow without raw prompts
|
|
222
|
+
// QUOTE(ТЗ): "TUI? Красивый, удобный"
|
|
223
|
+
// REF: user-request-2026-02-01-tui
|
|
224
|
+
// SOURCE: n/a
|
|
225
|
+
// FORMAT THEOREM: forall s: tui(s) -> state transitions
|
|
226
|
+
// PURITY: SHELL
|
|
227
|
+
// EFFECT: Effect<void, AppError, FileSystem | Path | CommandExecutor>
|
|
228
|
+
// INVARIANT: app exits only on Quit or ctrl+c
|
|
229
|
+
// COMPLEXITY: O(1) per input
|
|
230
|
+
export const runMenu = pipe(Effect.sync(() => {
|
|
231
|
+
resumeTui();
|
|
232
|
+
}), Effect.zipRight(Effect.tryPromise({
|
|
233
|
+
try: () => render(React.createElement(TuiApp)).waitUntilExit(),
|
|
234
|
+
catch: (error) => new InputReadError({ message: error instanceof Error ? error.message : String(error) })
|
|
235
|
+
})), Effect.ensuring(Effect.sync(() => {
|
|
236
|
+
leaveTui();
|
|
237
|
+
})), Effect.asVoid);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createProject } from "@effect-template/lib/usecases/actions";
|
|
2
|
+
import { authCodexLogin, authCodexLogout, authCodexStatus, authGithubLogin, authGithubLogout, authGithubStatus } from "@effect-template/lib/usecases/auth";
|
|
3
|
+
import { renderError } from "@effect-template/lib/usecases/errors";
|
|
4
|
+
import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects";
|
|
5
|
+
import { stateCommit, stateInit, statePath, statePull, statePush, stateStatus, stateSync } from "@effect-template/lib/usecases/state-repo";
|
|
6
|
+
import { killTerminalProcess, listTerminalSessions, tailTerminalLogs } from "@effect-template/lib/usecases/terminal-sessions";
|
|
7
|
+
import { Effect, Match, pipe } from "effect";
|
|
8
|
+
import { readCommand } from "./cli/read-command.js";
|
|
9
|
+
import { attachTmux, listTmuxPanes } from "./tmux.js";
|
|
10
|
+
import { runMenu } from "./menu.js";
|
|
11
|
+
const isParseError = (error) => error._tag === "UnknownCommand" ||
|
|
12
|
+
error._tag === "UnknownOption" ||
|
|
13
|
+
error._tag === "MissingOptionValue" ||
|
|
14
|
+
error._tag === "MissingRequiredOption" ||
|
|
15
|
+
error._tag === "InvalidOption" ||
|
|
16
|
+
error._tag === "UnexpectedArgument";
|
|
17
|
+
const setExitCode = (code) => Effect.sync(() => {
|
|
18
|
+
process.exitCode = code;
|
|
19
|
+
});
|
|
20
|
+
const logWarningAndExit = (error) => pipe(Effect.logWarning(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid);
|
|
21
|
+
const logErrorAndExit = (error) => pipe(Effect.logError(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid);
|
|
22
|
+
const handleNonBaseCommand = (command) => Match.value(command).pipe(Match.when({ _tag: "StatePath" }, () => statePath), Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)), Match.when({ _tag: "StateStatus" }, () => stateStatus), Match.when({ _tag: "StatePull" }, () => statePull), Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)), Match.when({ _tag: "StatePush" }, () => statePush), Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)), Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)), Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)), Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)), Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)), Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)), Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)), Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)), Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)), Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)), Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)), Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), Match.exhaustive);
|
|
23
|
+
// CHANGE: compose CLI program with typed errors and shell effects
|
|
24
|
+
// WHY: keep a thin entry layer over pure parsing and template generation
|
|
25
|
+
// QUOTE(ТЗ): "CLI команду... создавать докер образы"
|
|
26
|
+
// REF: user-request-2026-01-07
|
|
27
|
+
// SOURCE: n/a
|
|
28
|
+
// FORMAT THEOREM: forall cmd: handle(cmd) terminates with typed outcome
|
|
29
|
+
// PURITY: SHELL
|
|
30
|
+
// EFFECT: Effect<void, AppError, FileSystem | Path | CommandExecutor>
|
|
31
|
+
// INVARIANT: help is printed without side effects beyond logs
|
|
32
|
+
// COMPLEXITY: O(n) where n = |files|
|
|
33
|
+
export const program = pipe(readCommand, Effect.flatMap((command) => Match.value(command).pipe(Match.when({ _tag: "Help" }, ({ message }) => Effect.log(message)), Match.when({ _tag: "Create" }, (create) => createProject(create)), Match.when({ _tag: "Status" }, () => listProjectStatus), Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), Match.when({ _tag: "Menu" }, () => runMenu), Match.orElse((cmd) => handleNonBaseCommand(cmd)))), Effect.catchTag("FileExistsError", (error) => pipe(Effect.logWarning(renderError(error)), Effect.asVoid)), Effect.catchTag("DockerCommandError", logWarningAndExit), Effect.catchTag("AuthError", logWarningAndExit), Effect.catchTag("CommandFailedError", logWarningAndExit), Effect.matchEffect({
|
|
34
|
+
onFailure: (error) => isParseError(error)
|
|
35
|
+
? logErrorAndExit(error)
|
|
36
|
+
: pipe(Effect.logError(renderError(error)), Effect.flatMap(() => Effect.fail(error))),
|
|
37
|
+
onSuccess: () => Effect.void
|
|
38
|
+
}), Effect.asVoid);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Effect, pipe } from "effect";
|
|
2
|
+
import { deriveRepoPathParts, deriveRepoSlug } from "@effect-template/lib/core/domain";
|
|
3
|
+
import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "@effect-template/lib/shell/command-runner";
|
|
4
|
+
import { readProjectConfig } from "@effect-template/lib/shell/config";
|
|
5
|
+
import { CommandFailedError } from "@effect-template/lib/shell/errors";
|
|
6
|
+
import { resolveBaseDir } from "@effect-template/lib/shell/paths";
|
|
7
|
+
import { findSshPrivateKey } from "@effect-template/lib/usecases/path-helpers";
|
|
8
|
+
import { buildSshCommand } from "@effect-template/lib/usecases/projects";
|
|
9
|
+
import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up";
|
|
10
|
+
const tmuxOk = [0];
|
|
11
|
+
const layoutVersion = "v14";
|
|
12
|
+
const makeTmuxSpec = (args) => ({
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
command: "tmux",
|
|
15
|
+
args
|
|
16
|
+
});
|
|
17
|
+
const runTmux = (args) => runCommandWithExitCodes(makeTmuxSpec(args), tmuxOk, (exitCode) => new CommandFailedError({ command: "tmux", exitCode }));
|
|
18
|
+
const runTmuxExitCode = (args) => runCommandExitCode(makeTmuxSpec(args));
|
|
19
|
+
const runTmuxCapture = (args) => runCommandCapture(makeTmuxSpec(args), tmuxOk, (exitCode) => new CommandFailedError({ command: "tmux", exitCode }));
|
|
20
|
+
const sendKeys = (session, pane, text) => pipe(runTmux(["send-keys", "-t", `${session}:0.${pane}`, "-l", text]), Effect.zipRight(runTmux(["send-keys", "-t", `${session}:0.${pane}`, "C-m"])));
|
|
21
|
+
const shellEscape = (value) => {
|
|
22
|
+
if (value.length === 0) {
|
|
23
|
+
return "''";
|
|
24
|
+
}
|
|
25
|
+
if (!/[^\w@%+=:,./-]/.test(value)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
const escaped = value.replaceAll("'", "'\"'\"'");
|
|
29
|
+
return `'${escaped}'`;
|
|
30
|
+
};
|
|
31
|
+
const wrapBash = (command) => `bash -lc ${shellEscape(command)}`;
|
|
32
|
+
const buildJobsCommand = (containerName) => [
|
|
33
|
+
"while true; do",
|
|
34
|
+
"clear",
|
|
35
|
+
"echo \"LIVE TERMINALS / JOBS (container, refresh 1s)\"",
|
|
36
|
+
"echo \"\"",
|
|
37
|
+
`docker exec ${containerName} ps -eo pid,tty,cmd,etime --sort=start_time 2>/dev/null | awk 'NR==1 {print; next} $2 != "?" && $3 !~ /(sshd|^-?bash$|^bash$|^sh$|^zsh$|^fish$)/ {print; found=1} END { if (!found) print "(no interactive jobs)" }'`,
|
|
38
|
+
"|| echo \"container not running\"",
|
|
39
|
+
"sleep 1",
|
|
40
|
+
"done"
|
|
41
|
+
].join("; ");
|
|
42
|
+
const readLayoutVersion = (session) => runTmuxCapture(["show-options", "-t", session, "-v", "@docker-git-layout"]).pipe(Effect.map((value) => value.trim()), Effect.catchTag("CommandFailedError", () => Effect.succeed(null)));
|
|
43
|
+
const buildBottomBarCommand = () => [
|
|
44
|
+
"clear",
|
|
45
|
+
"echo \"[Focus: Alt+1/2/3] [Select: Alt+s] [Detach: Alt+d]\"",
|
|
46
|
+
"echo \"Tip: Mouse click = focus pane, Ctrl+a z = zoom\"",
|
|
47
|
+
"while true; do sleep 3600; done"
|
|
48
|
+
].join("; ");
|
|
49
|
+
const formatRepoRefLabel = (repoRef) => {
|
|
50
|
+
const match = /refs\/pull\/(\d+)\/head/.exec(repoRef);
|
|
51
|
+
const pr = match?.[1];
|
|
52
|
+
return pr ? `PR#${pr}` : repoRef;
|
|
53
|
+
};
|
|
54
|
+
const formatRepoDisplayName = (repoUrl) => {
|
|
55
|
+
const parts = deriveRepoPathParts(repoUrl);
|
|
56
|
+
return parts.pathParts.length > 0 ? parts.pathParts.join("/") : repoUrl;
|
|
57
|
+
};
|
|
58
|
+
const normalizePaneCell = (value) => value?.trim() ?? "-";
|
|
59
|
+
const parsePaneRow = (line) => {
|
|
60
|
+
const [id, window, title, command] = line.split("\t");
|
|
61
|
+
return {
|
|
62
|
+
id: normalizePaneCell(id),
|
|
63
|
+
window: normalizePaneCell(window),
|
|
64
|
+
title: normalizePaneCell(title),
|
|
65
|
+
command: normalizePaneCell(command)
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const renderPaneRow = (row) => `- ${row.id} ${row.window} ${row.title === "-" ? row.command : row.title} ${row.command}`;
|
|
69
|
+
const configureSession = (session, repoDisplayName, statusRight) => Effect.gen(function* (_) {
|
|
70
|
+
yield* _(runTmux(["set-option", "-t", session, "@docker-git-layout", layoutVersion]));
|
|
71
|
+
yield* _(runTmux(["set-option", "-t", session, "window-size", "largest"]));
|
|
72
|
+
yield* _(runTmux(["set-option", "-t", session, "aggressive-resize", "on"]));
|
|
73
|
+
yield* _(runTmux(["set-option", "-t", session, "mouse", "on"]));
|
|
74
|
+
yield* _(runTmux(["set-option", "-t", session, "focus-events", "on"]));
|
|
75
|
+
yield* _(runTmux(["set-option", "-t", session, "prefix", "C-a"]));
|
|
76
|
+
yield* _(runTmux(["unbind-key", "C-b"]));
|
|
77
|
+
yield* _(runTmux(["set-option", "-t", session, "status", "on"]));
|
|
78
|
+
yield* _(runTmux(["set-option", "-t", session, "status-position", "top"]));
|
|
79
|
+
yield* _(runTmux(["set-option", "-t", session, "status-left", ` docker-git :: ${repoDisplayName} `]));
|
|
80
|
+
yield* _(runTmux(["set-option", "-t", session, "status-right", ` ${statusRight} `]));
|
|
81
|
+
});
|
|
82
|
+
const createLayout = (session) => Effect.gen(function* (_) {
|
|
83
|
+
yield* _(runTmux(["new-session", "-d", "-s", session, "-n", "main"]));
|
|
84
|
+
yield* _(runTmux(["split-window", "-v", "-p", "12", "-t", `${session}:0`]));
|
|
85
|
+
yield* _(runTmux(["split-window", "-h", "-p", "35", "-t", `${session}:0.0`]));
|
|
86
|
+
});
|
|
87
|
+
const setupPanes = (session, sshCommand, containerName) => Effect.gen(function* (_) {
|
|
88
|
+
const leftPane = "0";
|
|
89
|
+
const bottomPane = "1";
|
|
90
|
+
const rightPane = "2";
|
|
91
|
+
yield* _(sendKeys(session, leftPane, sshCommand));
|
|
92
|
+
yield* _(sendKeys(session, rightPane, wrapBash(buildJobsCommand(containerName))));
|
|
93
|
+
yield* _(sendKeys(session, bottomPane, wrapBash(buildBottomBarCommand())));
|
|
94
|
+
yield* _(runTmux(["bind-key", "-n", "M-1", "select-pane", "-t", `${session}:0.${leftPane}`]));
|
|
95
|
+
yield* _(runTmux(["bind-key", "-n", "M-2", "select-pane", "-t", `${session}:0.${rightPane}`]));
|
|
96
|
+
yield* _(runTmux(["bind-key", "-n", "M-3", "select-pane", "-t", `${session}:0.${bottomPane}`]));
|
|
97
|
+
yield* _(runTmux(["bind-key", "-n", "M-d", "detach-client"]));
|
|
98
|
+
yield* _(runTmux(["bind-key", "-n", "M-s", "choose-tree", "-Z"]));
|
|
99
|
+
yield* _(runTmux(["select-pane", "-t", `${session}:0.${leftPane}`]));
|
|
100
|
+
});
|
|
101
|
+
// CHANGE: list tmux panes for a docker-git project
|
|
102
|
+
// WHY: allow non-interactive inspection of terminal panes (CI/automation friendly)
|
|
103
|
+
// QUOTE(ТЗ): "сделай команду ... которая отобразит терминалы в докере"
|
|
104
|
+
// REF: user-request-2026-02-02-panes
|
|
105
|
+
// SOURCE: n/a
|
|
106
|
+
// FORMAT THEOREM: forall p: panes(p) -> deterministic output
|
|
107
|
+
// PURITY: SHELL
|
|
108
|
+
// EFFECT: Effect<void, CommandFailedError | ConfigNotFoundError | ConfigDecodeError | PlatformError, CommandExecutor | FileSystem | Path>
|
|
109
|
+
// INVARIANT: session name is deterministic from repo url
|
|
110
|
+
// COMPLEXITY: O(n) where n = number of panes
|
|
111
|
+
export const listTmuxPanes = (command) => Effect.gen(function* (_) {
|
|
112
|
+
const { resolved } = yield* _(resolveBaseDir(command.projectDir));
|
|
113
|
+
const config = yield* _(readProjectConfig(resolved));
|
|
114
|
+
const session = `dg-${deriveRepoSlug(config.template.repoUrl)}`;
|
|
115
|
+
const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session]));
|
|
116
|
+
if (hasSessionCode !== 0) {
|
|
117
|
+
yield* _(Effect.logWarning(`tmux session ${session} not found. Run 'docker-git attach' first.`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const raw = yield* _(runTmuxCapture([
|
|
121
|
+
"list-panes",
|
|
122
|
+
"-s",
|
|
123
|
+
"-t",
|
|
124
|
+
session,
|
|
125
|
+
"-F",
|
|
126
|
+
"#{pane_id}\t#{window_name}\t#{pane_title}\t#{pane_current_command}"
|
|
127
|
+
]));
|
|
128
|
+
const lines = raw
|
|
129
|
+
.split(/\r?\n/)
|
|
130
|
+
.map((line) => line.trimEnd())
|
|
131
|
+
.filter((line) => line.length > 0);
|
|
132
|
+
const rows = lines.map((line) => parsePaneRow(line));
|
|
133
|
+
yield* _(Effect.log(`Project: ${resolved}`));
|
|
134
|
+
yield* _(Effect.log(`Session: ${session}`));
|
|
135
|
+
if (rows.length === 0) {
|
|
136
|
+
yield* _(Effect.log("No panes found."));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
yield* _(Effect.log(renderPaneRow(row)));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// CHANGE: attach a tmux workspace for a docker-git project
|
|
144
|
+
// WHY: provide multi-pane terminal layout for sandbox work
|
|
145
|
+
// QUOTE(ТЗ): "окей Давай подключим tmux"
|
|
146
|
+
// REF: user-request-2026-02-02-tmux
|
|
147
|
+
// SOURCE: n/a
|
|
148
|
+
// FORMAT THEOREM: forall p: attach(p) -> tmux(p)
|
|
149
|
+
// PURITY: SHELL
|
|
150
|
+
// EFFECT: Effect<void, CommandFailedError | DockerCommandError | ConfigNotFoundError | ConfigDecodeError | FileExistsError | PortProbeError | PlatformError, CommandExecutor | FileSystem | Path>
|
|
151
|
+
// INVARIANT: tmux session name is deterministic from repo url
|
|
152
|
+
// COMPLEXITY: O(1)
|
|
153
|
+
export const attachTmux = (command) => Effect.gen(function* (_) {
|
|
154
|
+
const { fs, path, resolved } = yield* _(resolveBaseDir(command.projectDir));
|
|
155
|
+
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()));
|
|
156
|
+
const template = yield* _(runDockerComposeUpWithPortCheck(resolved));
|
|
157
|
+
const sshCommand = buildSshCommand(template, sshKey);
|
|
158
|
+
const repoDisplayName = formatRepoDisplayName(template.repoUrl);
|
|
159
|
+
const refLabel = formatRepoRefLabel(template.repoRef);
|
|
160
|
+
const statusRight = `SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running`;
|
|
161
|
+
const session = `dg-${deriveRepoSlug(template.repoUrl)}`;
|
|
162
|
+
const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session]));
|
|
163
|
+
if (hasSessionCode === 0) {
|
|
164
|
+
const existingLayout = yield* _(readLayoutVersion(session));
|
|
165
|
+
if (existingLayout === layoutVersion) {
|
|
166
|
+
yield* _(runTmux(["attach", "-t", session]));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`));
|
|
170
|
+
yield* _(runTmux(["kill-session", "-t", session]));
|
|
171
|
+
}
|
|
172
|
+
yield* _(createLayout(session));
|
|
173
|
+
yield* _(configureSession(session, repoDisplayName, statusRight));
|
|
174
|
+
yield* _(setupPanes(session, sshCommand, template.containerName));
|
|
175
|
+
yield* _(runTmux(["attach", "-t", session]));
|
|
176
|
+
});
|