@prover-coder-ai/docker-git 1.0.15 → 1.0.17

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 (71) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +5 -6
  4. package/dist/main.js +24 -7
  5. package/dist/main.js.map +1 -1
  6. package/dist/src/docker-git/cli/parser-auth.js +32 -12
  7. package/dist/src/docker-git/cli/parser.js +1 -1
  8. package/dist/src/docker-git/cli/usage.js +4 -3
  9. package/dist/src/docker-git/menu-actions.js +24 -8
  10. package/dist/src/docker-git/menu-auth-data.js +90 -0
  11. package/dist/src/docker-git/menu-auth-helpers.js +20 -0
  12. package/dist/src/docker-git/menu-auth.js +159 -0
  13. package/dist/src/docker-git/menu-buffer-input.js +9 -0
  14. package/dist/src/docker-git/menu-create.js +5 -9
  15. package/dist/src/docker-git/menu-input-handler.js +70 -28
  16. package/dist/src/docker-git/menu-input-utils.js +47 -0
  17. package/dist/src/docker-git/menu-labeled-env.js +33 -0
  18. package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
  19. package/dist/src/docker-git/menu-project-auth-data.js +165 -0
  20. package/dist/src/docker-git/menu-project-auth.js +124 -0
  21. package/dist/src/docker-git/menu-render-auth.js +45 -0
  22. package/dist/src/docker-git/menu-render-common.js +26 -0
  23. package/dist/src/docker-git/menu-render-layout.js +14 -0
  24. package/dist/src/docker-git/menu-render-project-auth.js +37 -0
  25. package/dist/src/docker-git/menu-render-select.js +29 -7
  26. package/dist/src/docker-git/menu-render.js +4 -13
  27. package/dist/src/docker-git/menu-select-actions.js +66 -0
  28. package/dist/src/docker-git/menu-select-load.js +12 -0
  29. package/dist/src/docker-git/menu-select-order.js +21 -0
  30. package/dist/src/docker-git/menu-select-runtime.js +41 -9
  31. package/dist/src/docker-git/menu-select-view.js +15 -0
  32. package/dist/src/docker-git/menu-select.js +11 -82
  33. package/dist/src/docker-git/menu-shared.js +86 -17
  34. package/dist/src/docker-git/menu-types.js +2 -0
  35. package/dist/src/docker-git/menu.js +13 -1
  36. package/dist/src/docker-git/program.js +3 -3
  37. package/package.json +1 -1
  38. package/src/docker-git/cli/parser-auth.ts +46 -16
  39. package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
  40. package/src/docker-git/cli/parser.ts +1 -1
  41. package/src/docker-git/cli/usage.ts +4 -3
  42. package/src/docker-git/menu-actions.ts +32 -13
  43. package/src/docker-git/menu-auth-data.ts +184 -0
  44. package/src/docker-git/menu-auth-helpers.ts +30 -0
  45. package/src/docker-git/menu-auth.ts +311 -0
  46. package/src/docker-git/menu-buffer-input.ts +18 -0
  47. package/src/docker-git/menu-create.ts +5 -11
  48. package/src/docker-git/menu-input-handler.ts +104 -28
  49. package/src/docker-git/menu-input-utils.ts +85 -0
  50. package/src/docker-git/menu-labeled-env.ts +37 -0
  51. package/src/docker-git/menu-project-auth-claude.ts +70 -0
  52. package/src/docker-git/menu-project-auth-data.ts +292 -0
  53. package/src/docker-git/menu-project-auth.ts +271 -0
  54. package/src/docker-git/menu-render-auth.ts +65 -0
  55. package/src/docker-git/menu-render-common.ts +67 -0
  56. package/src/docker-git/menu-render-layout.ts +30 -0
  57. package/src/docker-git/menu-render-project-auth.ts +70 -0
  58. package/src/docker-git/menu-render-select.ts +44 -5
  59. package/src/docker-git/menu-render.ts +5 -29
  60. package/src/docker-git/menu-select-actions.ts +150 -0
  61. package/src/docker-git/menu-select-load.ts +33 -0
  62. package/src/docker-git/menu-select-order.ts +37 -0
  63. package/src/docker-git/menu-select-runtime.ts +59 -10
  64. package/src/docker-git/menu-select-view.ts +25 -0
  65. package/src/docker-git/menu-select.ts +22 -195
  66. package/src/docker-git/menu-shared.ts +135 -20
  67. package/src/docker-git/menu-types.ts +71 -2
  68. package/src/docker-git/menu.ts +26 -1
  69. package/src/docker-git/program.ts +10 -4
  70. package/tests/docker-git/entrypoint-auth.test.ts +1 -1
  71. package/tests/docker-git/menu-select-order.test.ts +73 -0
@@ -0,0 +1,66 @@
1
+ import { runDockerComposeDown } from "@effect-template/lib/shell/docker";
2
+ import { renderError } from "@effect-template/lib/usecases/errors";
3
+ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
4
+ import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
5
+ import { Effect, pipe } from "effect";
6
+ import { openProjectAuthMenu } from "./menu-project-auth.js";
7
+ import { buildConnectEffect } from "./menu-select-connect.js";
8
+ import { loadRuntimeByProject } from "./menu-select-runtime.js";
9
+ import { startSelectView } from "./menu-select-view.js";
10
+ import { pauseOnError, resetToMenu, resumeSshWithSkipInputs, resumeWithSkipInputs, withSuspendedTui } from "./menu-shared.js";
11
+ export const runConnectSelection = (selected, context, enableMcpPlaywright) => {
12
+ context.setMessage(enableMcpPlaywright
13
+ ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
14
+ : `Connecting to ${selected.displayName}...`);
15
+ context.setSshActive(true);
16
+ context.runner.runEffect(pipe(withSuspendedTui(buildConnectEffect(selected, enableMcpPlaywright, {
17
+ connectWithUp: (item) => connectProjectSshWithUp(item).pipe(Effect.mapError((error) => error)),
18
+ enableMcpPlaywright: (projectDir) => mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(Effect.asVoid, Effect.mapError((error) => error))
19
+ }), {
20
+ onError: pauseOnError(renderError),
21
+ onResume: resumeSshWithSkipInputs(context)
22
+ }), Effect.tap(() => Effect.sync(() => {
23
+ context.setMessage("SSH session ended. Press Esc to return to the menu.");
24
+ })), Effect.asVoid));
25
+ };
26
+ export const runDownSelection = (selected, context) => {
27
+ context.setMessage(`Stopping ${selected.displayName}...`);
28
+ context.runner.runEffect(withSuspendedTui(pipe(runDockerComposeDown(selected.projectDir), Effect.zipRight(listRunningProjectItems), Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.map((runtimeByProject) => ({ items, runtimeByProject })))), Effect.tap(({ items, runtimeByProject }) => Effect.sync(() => {
29
+ if (items.length === 0) {
30
+ resetToMenu(context);
31
+ context.setMessage("No running docker-git containers.");
32
+ return;
33
+ }
34
+ startSelectView(items, "Down", context, runtimeByProject);
35
+ context.setMessage("Container stopped. Select another to stop, or Esc to return.");
36
+ })), Effect.asVoid), {
37
+ onError: pauseOnError(renderError),
38
+ onResume: resumeWithSkipInputs(context)
39
+ }));
40
+ };
41
+ export const runInfoSelection = (selected, context) => {
42
+ context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`);
43
+ };
44
+ export const runAuthSelection = (selected, context) => {
45
+ openProjectAuthMenu({
46
+ project: selected,
47
+ runner: context.runner,
48
+ setView: context.setView,
49
+ setMessage: context.setMessage,
50
+ setActiveDir: context.setActiveDir
51
+ });
52
+ };
53
+ export const runDeleteSelection = (selected, context) => {
54
+ context.setMessage(`Deleting ${selected.displayName}...`);
55
+ context.runner.runEffect(pipe(withSuspendedTui(deleteDockerGitProject(selected).pipe(Effect.tap(() => Effect.sync(() => {
56
+ if (context.activeDir === selected.projectDir) {
57
+ context.setActiveDir(null);
58
+ }
59
+ context.setView({ _tag: "Menu" });
60
+ })), Effect.asVoid), {
61
+ onError: pauseOnError(renderError),
62
+ onResume: resumeWithSkipInputs(context)
63
+ }), Effect.tap(() => Effect.sync(() => {
64
+ context.setMessage("Project deleted.");
65
+ })), Effect.asVoid));
66
+ };
@@ -0,0 +1,12 @@
1
+ import { Effect, pipe } from "effect";
2
+ import { loadRuntimeByProject } from "./menu-select-runtime.js";
3
+ import { startSelectView } from "./menu-select.js";
4
+ export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
5
+ if (items.length === 0) {
6
+ context.setMessage(purpose === "Down"
7
+ ? "No running docker-git containers."
8
+ : "No docker-git projects found.");
9
+ return;
10
+ }
11
+ startSelectView(items, purpose, context, runtimeByProject);
12
+ })))));
@@ -0,0 +1,21 @@
1
+ const defaultRuntime = () => ({
2
+ running: false,
3
+ sshSessions: 0,
4
+ startedAtIso: null,
5
+ startedAtEpochMs: null
6
+ });
7
+ const runtimeForSort = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? defaultRuntime();
8
+ const startedAtEpochForSort = (runtime) => runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY;
9
+ export const sortItemsByLaunchTime = (items, runtimeByProject) => items.toSorted((left, right) => {
10
+ const leftRuntime = runtimeForSort(runtimeByProject, left);
11
+ const rightRuntime = runtimeForSort(runtimeByProject, right);
12
+ const leftStartedAt = startedAtEpochForSort(leftRuntime);
13
+ const rightStartedAt = startedAtEpochForSort(rightRuntime);
14
+ if (leftStartedAt !== rightStartedAt) {
15
+ return rightStartedAt - leftStartedAt;
16
+ }
17
+ if (leftRuntime.running !== rightRuntime.running) {
18
+ return leftRuntime.running ? -1 : 1;
19
+ }
20
+ return left.displayName.localeCompare(right.displayName);
21
+ });
@@ -2,8 +2,14 @@ import { runCommandCapture } from "@effect-template/lib/shell/command-runner";
2
2
  import { runDockerPsNames } from "@effect-template/lib/shell/docker";
3
3
  import { Effect, pipe } from "effect";
4
4
  const emptyRuntimeByProject = () => ({});
5
- const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
5
+ const stoppedRuntime = () => ({
6
+ running: false,
7
+ sshSessions: 0,
8
+ startedAtIso: null,
9
+ startedAtEpochMs: null
10
+ });
6
11
  const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'";
12
+ const dockerZeroStartedAt = "0001-01-01T00:00:00Z";
7
13
  const parseSshSessionCount = (raw) => {
8
14
  const parsed = Number.parseInt(raw.trim(), 10);
9
15
  if (Number.isNaN(parsed) || parsed < 0) {
@@ -11,6 +17,20 @@ const parseSshSessionCount = (raw) => {
11
17
  }
12
18
  return parsed;
13
19
  };
20
+ const parseContainerStartedAt = (raw) => {
21
+ const trimmed = raw.trim();
22
+ if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
23
+ return null;
24
+ }
25
+ const startedAtEpochMs = Date.parse(trimmed);
26
+ if (Number.isNaN(startedAtEpochMs)) {
27
+ return null;
28
+ }
29
+ return {
30
+ startedAtIso: trimmed,
31
+ startedAtEpochMs
32
+ };
33
+ };
14
34
  const toRuntimeMap = (entries) => {
15
35
  const runtimeByProject = {};
16
36
  for (const [projectDir, runtime] of entries) {
@@ -26,23 +46,35 @@ const countContainerSshSessions = (containerName) => pipe(runCommandCapture({
26
46
  onFailure: () => 0,
27
47
  onSuccess: (raw) => parseSshSessionCount(raw)
28
48
  }));
49
+ const inspectContainerStartedAt = (containerName) => pipe(runCommandCapture({
50
+ cwd: process.cwd(),
51
+ command: "docker",
52
+ args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
53
+ }, [0], (exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })), Effect.match({
54
+ onFailure: () => null,
55
+ onSuccess: (raw) => parseContainerStartedAt(raw)
56
+ }));
29
57
  // CHANGE: enrich select items with runtime state and SSH session counts
30
58
  // WHY: prevent stopping/deleting containers that are currently used via SSH
31
59
  // QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
32
60
  // REF: issue-47
33
61
  // SOURCE: n/a
34
- // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
62
+ // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
35
63
  // PURITY: SHELL
36
64
  // EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
37
- // INVARIANT: stopped containers always have sshSessions = 0
38
- // COMPLEXITY: O(n + docker_ps + docker_exec)
65
+ // INVARIANT: projects without a known container start have startedAt = null
66
+ // COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
39
67
  export const loadRuntimeByProject = (items) => pipe(runDockerPsNames(process.cwd()), Effect.flatMap((runningNames) => Effect.forEach(items, (item) => {
40
68
  const running = runningNames.includes(item.containerName);
41
- if (!running) {
42
- const entry = [item.projectDir, stoppedRuntime()];
43
- return Effect.succeed(entry);
44
- }
45
- return pipe(countContainerSshSessions(item.containerName), Effect.map((sshSessions) => ({ running: true, sshSessions })), Effect.map((runtime) => [item.projectDir, runtime]));
69
+ const sshSessionsEffect = running
70
+ ? countContainerSshSessions(item.containerName)
71
+ : Effect.succeed(0);
72
+ return pipe(Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]), Effect.map(([sshSessions, startedAt]) => ({
73
+ running,
74
+ sshSessions,
75
+ startedAtIso: startedAt?.startedAtIso ?? null,
76
+ startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
77
+ })), Effect.map((runtime) => [item.projectDir, runtime]));
46
78
  }, { concurrency: 4 })), Effect.map((entries) => toRuntimeMap(entries)), Effect.match({
47
79
  onFailure: () => emptyRuntimeByProject(),
48
80
  onSuccess: (runtimeByProject) => runtimeByProject
@@ -0,0 +1,15 @@
1
+ import { sortItemsByLaunchTime } from "./menu-select-order.js";
2
+ const emptyRuntimeByProject = () => ({});
3
+ export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
4
+ const sortedItems = sortItemsByLaunchTime(items, runtimeByProject);
5
+ context.setMessage(null);
6
+ context.setView({
7
+ _tag: "SelectProject",
8
+ purpose,
9
+ items: sortedItems,
10
+ runtimeByProject,
11
+ selected: 0,
12
+ confirmDelete: false,
13
+ connectEnableMcpPlaywright: false
14
+ });
15
+ };
@@ -1,23 +1,9 @@
1
- import { runDockerComposeDown } from "@effect-template/lib/shell/docker";
2
- import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
3
- import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
4
- import { Effect, Match, pipe } from "effect";
5
- import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js";
6
- import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js";
7
- import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js";
8
- const emptyRuntimeByProject = () => ({});
9
- export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
10
- context.setMessage(null);
11
- context.setView({
12
- _tag: "SelectProject",
13
- purpose,
14
- items,
15
- runtimeByProject,
16
- selected: 0,
17
- confirmDelete: false,
18
- connectEnableMcpPlaywright: false
19
- });
20
- };
1
+ import { Match } from "effect";
2
+ import { runAuthSelection, runConnectSelection, runDeleteSelection, runDownSelection, runInfoSelection } from "./menu-select-actions.js";
3
+ import { isConnectMcpToggleInput } from "./menu-select-connect.js";
4
+ import { runtimeForSelection } from "./menu-select-runtime.js";
5
+ import { resetToMenu } from "./menu-shared.js";
6
+ export { startSelectView } from "./menu-select-view.js";
21
7
  const clampIndex = (value, size) => {
22
8
  if (size <= 0) {
23
9
  return 0;
@@ -73,56 +59,7 @@ const handleSelectNavigation = (key, view, context) => {
73
59
  }
74
60
  return false;
75
61
  };
76
- const runWithSuspendedTui = (context, effect, onResume, doneMessage) => {
77
- context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(effect), Effect.ensuring(Effect.sync(() => {
78
- resumeTui();
79
- onResume();
80
- context.setSkipInputs(() => 2);
81
- })), Effect.tap(() => Effect.sync(() => {
82
- context.setMessage(doneMessage);
83
- }))));
84
- };
85
- const runConnectSelection = (selected, context, enableMcpPlaywright) => {
86
- context.setMessage(enableMcpPlaywright
87
- ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
88
- : `Connecting to ${selected.displayName}...`);
89
- context.setSshActive(true);
90
- runWithSuspendedTui(context, buildConnectEffect(selected, enableMcpPlaywright, {
91
- connectWithUp: (item) => connectProjectSshWithUp(item).pipe(Effect.mapError((error) => error)),
92
- enableMcpPlaywright: (projectDir) => mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(Effect.asVoid, Effect.mapError((error) => error))
93
- }), () => {
94
- context.setSshActive(false);
95
- }, "SSH session ended. Press Esc to return to the menu.");
96
- };
97
- const runDownSelection = (selected, context) => {
98
- context.setMessage(`Stopping ${selected.displayName}...`);
99
- context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(runDockerComposeDown(selected.projectDir)), Effect.zipRight(listRunningProjectItems), Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.map((runtimeByProject) => ({ items, runtimeByProject })))), Effect.tap(({ items, runtimeByProject }) => Effect.sync(() => {
100
- if (items.length === 0) {
101
- resetToMenu(context);
102
- context.setMessage("No running docker-git containers.");
103
- return;
104
- }
105
- startSelectView(items, "Down", context, runtimeByProject);
106
- context.setMessage("Container stopped. Select another to stop, or Esc to return.");
107
- })), Effect.ensuring(Effect.sync(() => {
108
- resumeTui();
109
- context.setSkipInputs(() => 2);
110
- })), Effect.asVoid));
111
- };
112
- const runInfoSelection = (selected, context) => {
113
- context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`);
114
- };
115
- const runDeleteSelection = (selected, context) => {
116
- context.setMessage(`Deleting ${selected.displayName}...`);
117
- runWithSuspendedTui(context, deleteDockerGitProject(selected).pipe(Effect.tap(() => Effect.sync(() => {
118
- if (context.activeDir === selected.projectDir) {
119
- context.setActiveDir(null);
120
- }
121
- context.setView({ _tag: "Menu" });
122
- }))), () => {
123
- // Only return to menu on success (see Effect.tap above).
124
- }, "Project deleted.");
125
- };
62
+ const formatSshSessionsLabel = (sshSessions) => sshSessions === 1 ? "1 active SSH session" : `${sshSessions} active SSH sessions`;
126
63
  const handleSelectReturn = (view, context) => {
127
64
  const selected = view.items[view.selected];
128
65
  if (!selected) {
@@ -131,12 +68,13 @@ const handleSelectReturn = (view, context) => {
131
68
  return;
132
69
  }
133
70
  const selectedRuntime = runtimeForSelection(view, selected);
134
- const sshSessionsLabel = selectedRuntime.sshSessions === 1
135
- ? "1 active SSH session"
136
- : `${selectedRuntime.sshSessions} active SSH sessions`;
71
+ const sshSessionsLabel = formatSshSessionsLabel(selectedRuntime.sshSessions);
137
72
  Match.value(view.purpose).pipe(Match.when("Connect", () => {
138
73
  context.setActiveDir(selected.projectDir);
139
74
  runConnectSelection(selected, context, view.connectEnableMcpPlaywright);
75
+ }), Match.when("Auth", () => {
76
+ context.setActiveDir(selected.projectDir);
77
+ runAuthSelection(selected, context);
140
78
  }), Match.when("Down", () => {
141
79
  if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
142
80
  context.setMessage(`${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.`);
@@ -158,12 +96,3 @@ const handleSelectReturn = (view, context) => {
158
96
  runDeleteSelection(selected, context);
159
97
  }), Match.exhaustive);
160
98
  };
161
- export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
162
- if (items.length === 0) {
163
- context.setMessage(purpose === "Down"
164
- ? "No running docker-git containers."
165
- : "No docker-git projects found.");
166
- return;
167
- }
168
- startSelectView(items, purpose, context, runtimeByProject);
169
- })))));
@@ -1,5 +1,21 @@
1
+ import { Effect, pipe } from "effect";
1
2
  let stdoutPatched = false;
2
3
  let stdoutMuted = false;
4
+ let baseStdoutWrite = null;
5
+ let baseStderrWrite = null;
6
+ const wrapWrite = (baseWrite) => (chunk, encoding, cb) => {
7
+ if (stdoutMuted) {
8
+ const callback = typeof encoding === "function" ? encoding : cb;
9
+ if (typeof callback === "function") {
10
+ callback();
11
+ }
12
+ return true;
13
+ }
14
+ if (typeof encoding === "function") {
15
+ return baseWrite(chunk, encoding);
16
+ }
17
+ return baseWrite(chunk, encoding, cb);
18
+ };
3
19
  const disableMouseModes = () => {
4
20
  // Disable xterm/urxvt mouse tracking and "alternate scroll" mode (wheel -> arrow keys).
5
21
  process.stdout.write("\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l");
@@ -18,23 +34,72 @@ const ensureStdoutPatched = () => {
18
34
  if (stdoutPatched) {
19
35
  return;
20
36
  }
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;
37
+ baseStdoutWrite = process.stdout.write.bind(process.stdout);
38
+ baseStderrWrite = process.stderr.write.bind(process.stderr);
39
+ process.stdout.write = wrapWrite(baseStdoutWrite);
40
+ process.stderr.write = wrapWrite(baseStderrWrite);
36
41
  stdoutPatched = true;
37
42
  };
43
+ // CHANGE: allow writing to the terminal even while stdout is muted
44
+ // WHY: we mute Ink renders during interactive commands, but still need to show prompts/errors
45
+ // REF: user-request-2026-02-18-tui-output-hidden
46
+ // SOURCE: n/a
47
+ // PURITY: SHELL
48
+ // EFFECT: n/a
49
+ // INVARIANT: bypasses the mute wrapper safely
50
+ export const writeToTerminal = (text) => {
51
+ ensureStdoutPatched();
52
+ const write = baseStdoutWrite ?? process.stdout.write.bind(process.stdout);
53
+ write(text);
54
+ };
55
+ // CHANGE: keep the user on the primary screen until they acknowledge
56
+ // WHY: otherwise output from failed docker/gh commands gets hidden again when TUI resumes
57
+ // REF: user-request-2026-02-18-tui-output-hidden
58
+ // SOURCE: n/a
59
+ // PURITY: SHELL
60
+ // EFFECT: Effect<void, never, never>
61
+ // INVARIANT: no-op when stdin/stdout aren't TTY (CI/e2e)
62
+ export const pauseForEnter = (prompt = "Press Enter to return to docker-git...") => {
63
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
64
+ return Effect.void;
65
+ }
66
+ return Effect.async((resume) => {
67
+ // Ensure the prompt isn't glued to the last command line.
68
+ writeToTerminal(`\n${prompt}\n`);
69
+ process.stdin.resume();
70
+ const cleanup = () => {
71
+ process.stdin.off("data", onData);
72
+ };
73
+ const onData = () => {
74
+ cleanup();
75
+ resume(Effect.void);
76
+ };
77
+ process.stdin.on("data", onData);
78
+ return Effect.sync(() => {
79
+ cleanup();
80
+ });
81
+ }).pipe(Effect.asVoid);
82
+ };
83
+ export const writeErrorAndPause = (renderedError) => pipe(Effect.sync(() => {
84
+ writeToTerminal(`\n[docker-git] ${renderedError}\n`);
85
+ }), Effect.zipRight(pauseForEnter()), Effect.asVoid);
86
+ export const withSuspendedTui = (effect, options) => {
87
+ const withError = options?.onError
88
+ ? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void)))
89
+ : effect;
90
+ return pipe(Effect.sync(suspendTui), Effect.zipRight(withError), Effect.ensuring(Effect.sync(() => {
91
+ resumeTui();
92
+ options?.onResume?.();
93
+ })));
94
+ };
95
+ export const resumeWithSkipInputs = (context, extra) => () => {
96
+ extra?.();
97
+ context.setSkipInputs(() => 2);
98
+ };
99
+ export const resumeSshWithSkipInputs = (context) => resumeWithSkipInputs(context, () => {
100
+ context.setSshActive(false);
101
+ });
102
+ export const pauseOnError = (render) => (error) => writeErrorAndPause(render(error));
38
103
  // CHANGE: toggle stdout write muting for Ink rendering
39
104
  // WHY: allow SSH sessions to own the terminal without TUI redraws
40
105
  // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
@@ -67,7 +132,9 @@ export const suspendTui = () => {
67
132
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
68
133
  process.stdin.setRawMode(false);
69
134
  }
70
- process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H");
135
+ // Switch back to the primary screen so interactive commands (ssh/gh/codex)
136
+ // can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
137
+ process.stdout.write("\u001B[?1049l");
71
138
  setStdoutMuted(true);
72
139
  };
73
140
  // CHANGE: restore TUI rendering after interactive commands
@@ -86,6 +153,7 @@ export const resumeTui = () => {
86
153
  }
87
154
  setStdoutMuted(false);
88
155
  disableMouseModes();
156
+ // Return to the alternate screen for Ink rendering.
89
157
  process.stdout.write("\u001B[?1049h\u001B[2J\u001B[H");
90
158
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
91
159
  process.stdin.setRawMode(true);
@@ -99,7 +167,8 @@ export const leaveTui = () => {
99
167
  // Ensure we don't leave the terminal in a broken "mouse reporting" mode.
100
168
  setStdoutMuted(false);
101
169
  disableMouseModes();
102
- process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H");
170
+ // Restore the primary screen on exit without clearing it (keeps useful scrollback).
171
+ process.stdout.write("\u001B[?1049l");
103
172
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
104
173
  process.stdin.setRawMode(false);
105
174
  }
@@ -9,6 +9,8 @@ export const createSteps = [
9
9
  export const menuItems = [
10
10
  { id: { _tag: "Create" }, label: "Create project" },
11
11
  { id: { _tag: "Select" }, label: "Select project" },
12
+ { id: { _tag: "Auth" }, label: "Auth profiles (keys)" },
13
+ { id: { _tag: "ProjectAuth" }, label: "Project auth (bind labels)" },
12
14
  { id: { _tag: "Info" }, label: "Show connection info" },
13
15
  { id: { _tag: "Status" }, label: "docker compose ps" },
14
16
  { id: { _tag: "Logs" }, label: "docker compose logs --tail=200" },
@@ -8,7 +8,7 @@ import { render, useApp, useInput } from "ink";
8
8
  import React, { useEffect, useMemo, useState } from "react";
9
9
  import { resolveCreateInputs } from "./menu-create.js";
10
10
  import { handleUserInput } from "./menu-input-handler.js";
11
- import { renderCreate, renderMenu, renderSelect, renderStepLabel } from "./menu-render.js";
11
+ import { renderAuthMenu, renderAuthPrompt, renderCreate, renderMenu, renderProjectAuthMenu, renderProjectAuthPrompt, renderSelect, renderStepLabel } from "./menu-render.js";
12
12
  import { leaveTui, resumeTui } from "./menu-shared.js";
13
13
  import { defaultMenuStartupSnapshot, resolveMenuStartupSnapshot } from "./menu-startup.js";
14
14
  import { createSteps } from "./menu-types.js";
@@ -54,6 +54,18 @@ const renderView = (context) => {
54
54
  const label = renderStepLabel(step, currentDefaults);
55
55
  return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults);
56
56
  }
57
+ if (context.view._tag === "AuthMenu") {
58
+ return renderAuthMenu(context.view.snapshot, context.view.selected, context.message);
59
+ }
60
+ if (context.view._tag === "AuthPrompt") {
61
+ return renderAuthPrompt(context.view, context.message);
62
+ }
63
+ if (context.view._tag === "ProjectAuthMenu") {
64
+ return renderProjectAuthMenu(context.view.snapshot, context.view.selected, context.message);
65
+ }
66
+ if (context.view._tag === "ProjectAuthPrompt") {
67
+ return renderProjectAuthPrompt(context.view, context.message);
68
+ }
57
69
  return renderSelect({
58
70
  purpose: context.view.purpose,
59
71
  items: context.view.items,
@@ -1,5 +1,5 @@
1
1
  import { createProject } from "@effect-template/lib/usecases/actions";
2
- import { authCodexLogin, authCodexLogout, authCodexStatus, authGithubLogin, authGithubLogout, authGithubStatus } from "@effect-template/lib/usecases/auth";
2
+ import { authClaudeLogin, authClaudeLogout, authClaudeStatus, authCodexLogin, authCodexLogout, authCodexStatus, authGithubLogin, authGithubLogout, authGithubStatus } from "@effect-template/lib/usecases/auth";
3
3
  import { renderError } from "@effect-template/lib/usecases/errors";
4
4
  import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
5
5
  import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects";
@@ -22,8 +22,8 @@ const setExitCode = (code) => Effect.sync(() => {
22
22
  const logWarningAndExit = (error) => pipe(Effect.logWarning(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid);
23
23
  const logErrorAndExit = (error) => pipe(Effect.logError(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid);
24
24
  const handleNonBaseCommand = (command) => Match.value(command)
25
- .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.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)))
26
- .pipe(Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)), Match.exhaustive);
25
+ .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: "AuthClaudeLogin" }, (cmd) => authClaudeLogin(cmd)), Match.when({ _tag: "AuthClaudeStatus" }, (cmd) => authClaudeStatus(cmd)), Match.when({ _tag: "AuthClaudeLogout" }, (cmd) => authClaudeLogout(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)))
26
+ .pipe(Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)), Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)), Match.exhaustive);
27
27
  // CHANGE: compose CLI program with typed errors and shell effects
28
28
  // WHY: keep a thin entry layer over pure parsing and template generation
29
29
  // QUOTE(ТЗ): "CLI команду... создавать докер образы"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
@@ -1,21 +1,18 @@
1
1
  import { Either, Match } from "effect"
2
2
 
3
3
  import type { RawOptions } from "@effect-template/lib/core/command-options"
4
- import {
5
- type AuthCommand,
6
- type Command,
7
- defaultTemplateConfig,
8
- type ParseError
9
- } from "@effect-template/lib/core/domain"
4
+ import { type AuthCommand, type Command, type ParseError } from "@effect-template/lib/core/domain"
10
5
 
11
6
  import { parseRawOptions } from "./parser-options.js"
12
7
 
13
8
  type AuthOptions = {
14
9
  readonly envGlobalPath: string
15
10
  readonly codexAuthPath: string
11
+ readonly claudeAuthPath: string
16
12
  readonly label: string | null
17
13
  readonly token: string | null
18
14
  readonly scopes: string | null
15
+ readonly authWeb: boolean
19
16
  }
20
17
 
21
18
  const missingArgument = (name: string): ParseError => ({
@@ -34,24 +31,32 @@ const normalizeLabel = (value: string | undefined): string | null => {
34
31
  return trimmed.length === 0 ? null : trimmed
35
32
  }
36
33
 
34
+ const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env"
35
+ const defaultCodexAuthPath = ".docker-git/.orch/auth/codex"
36
+ const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude"
37
+
37
38
  const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({
38
- envGlobalPath: raw.envGlobalPath ?? defaultTemplateConfig.envGlobalPath,
39
- codexAuthPath: raw.codexAuthPath ?? defaultTemplateConfig.codexAuthPath,
39
+ envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath,
40
+ codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath,
41
+ claudeAuthPath: defaultClaudeAuthPath,
40
42
  label: normalizeLabel(raw.label),
41
43
  token: normalizeLabel(raw.token),
42
- scopes: normalizeLabel(raw.scopes)
44
+ scopes: normalizeLabel(raw.scopes),
45
+ authWeb: raw.authWeb === true
43
46
  })
44
47
 
45
48
  const buildGithubCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
46
49
  Match.value(action).pipe(
47
50
  Match.when("login", () =>
48
- Either.right<AuthCommand>({
49
- _tag: "AuthGithubLogin",
50
- label: options.label,
51
- token: options.token,
52
- scopes: options.scopes,
53
- envGlobalPath: options.envGlobalPath
54
- })),
51
+ options.authWeb && options.token !== null
52
+ ? Either.left(invalidArgument("--token", "cannot be combined with --web"))
53
+ : Either.right<AuthCommand>({
54
+ _tag: "AuthGithubLogin",
55
+ label: options.label,
56
+ token: options.authWeb ? null : options.token,
57
+ scopes: options.scopes,
58
+ envGlobalPath: options.envGlobalPath
59
+ })),
55
60
  Match.when("status", () =>
56
61
  Either.right<AuthCommand>({
57
62
  _tag: "AuthGithubStatus",
@@ -89,6 +94,29 @@ const buildCodexCommand = (action: string, options: AuthOptions): Either.Either<
89
94
  Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
90
95
  )
91
96
 
97
+ const buildClaudeCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
98
+ Match.value(action).pipe(
99
+ Match.when("login", () =>
100
+ Either.right<AuthCommand>({
101
+ _tag: "AuthClaudeLogin",
102
+ label: options.label,
103
+ claudeAuthPath: options.claudeAuthPath
104
+ })),
105
+ Match.when("status", () =>
106
+ Either.right<AuthCommand>({
107
+ _tag: "AuthClaudeStatus",
108
+ label: options.label,
109
+ claudeAuthPath: options.claudeAuthPath
110
+ })),
111
+ Match.when("logout", () =>
112
+ Either.right<AuthCommand>({
113
+ _tag: "AuthClaudeLogout",
114
+ label: options.label,
115
+ claudeAuthPath: options.claudeAuthPath
116
+ })),
117
+ Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
118
+ )
119
+
92
120
  const buildAuthCommand = (
93
121
  provider: string,
94
122
  action: string,
@@ -98,6 +126,8 @@ const buildAuthCommand = (
98
126
  Match.when("github", () => buildGithubCommand(action, options)),
99
127
  Match.when("gh", () => buildGithubCommand(action, options)),
100
128
  Match.when("codex", () => buildCodexCommand(action, options)),
129
+ Match.when("claude", () => buildClaudeCommand(action, options)),
130
+ Match.when("cc", () => buildClaudeCommand(action, options)),
101
131
  Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`)))
102
132
  )
103
133
 
@@ -22,4 +22,3 @@ export const parseMcpPlaywright = (
22
22
  projectDir,
23
23
  runUp: raw.up ?? true
24
24
  }))
25
-
@@ -22,7 +22,7 @@ const statusCommand: Command = { _tag: "Status" }
22
22
  const downAllCommand: Command = { _tag: "DownAll" }
23
23
 
24
24
  const parseCreate = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> =>
25
- Either.flatMap(parseRawOptions(args), buildCreateCommand)
25
+ Either.flatMap(parseRawOptions(args), (raw) => buildCreateCommand(raw))
26
26
 
27
27
  // CHANGE: parse CLI arguments into a typed command
28
28
  // WHY: enforce deterministic, pure parsing before any effects run