@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,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
+ });