Skip to content

Commit 1ea8eb8

Browse files
committed
fix(docker-git): auto-apply templates and harden tty reset
1 parent 9cd6a0e commit 1ea8eb8

File tree

10 files changed

+277
-15
lines changed

10 files changed

+277
-15
lines changed

packages/app/src/docker-git/menu-shared.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,16 @@ const wrapWrite = (baseWrite: OutputWrite): OutputWrite =>
4141
return baseWrite(chunk, encoding, cb)
4242
}
4343

44-
const disableMouseModes = (): void => {
45-
// Disable xterm/urxvt mouse tracking and "alternate scroll" mode (wheel -> arrow keys).
44+
const disableTerminalInputModes = (): void => {
45+
// Disable mouse/input modes that can leak across TUI <-> SSH transitions.
4646
process.stdout.write(
47-
"\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l"
47+
"\u001B[0m" +
48+
"\u001B[?25h" +
49+
"\u001B[?1l" +
50+
"\u001B>" +
51+
"\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" +
52+
"\u001B[?1004l\u001B[?2004l" +
53+
"\u001B[>4;0m\u001B[>4m\u001B[<u"
4854
)
4955
}
5056

@@ -201,7 +207,7 @@ export const suspendTui = (): void => {
201207
if (!process.stdout.isTTY) {
202208
return
203209
}
204-
disableMouseModes()
210+
disableTerminalInputModes()
205211
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
206212
process.stdin.setRawMode(false)
207213
}
@@ -226,13 +232,13 @@ export const resumeTui = (): void => {
226232
return
227233
}
228234
setStdoutMuted(false)
229-
disableMouseModes()
235+
disableTerminalInputModes()
230236
// Return to the alternate screen for Ink rendering.
231237
process.stdout.write("\u001B[?1049h\u001B[2J\u001B[H")
232238
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
233239
process.stdin.setRawMode(true)
234240
}
235-
disableMouseModes()
241+
disableTerminalInputModes()
236242
}
237243

238244
export const leaveTui = (): void => {
@@ -241,7 +247,7 @@ export const leaveTui = (): void => {
241247
}
242248
// Ensure we don't leave the terminal in a broken "mouse reporting" mode.
243249
setStdoutMuted(false)
244-
disableMouseModes()
250+
disableTerminalInputModes()
245251
// Restore the primary screen on exit without clearing it (keeps useful scrollback).
246252
process.stdout.write("\u001B[?1049l")
247253
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {

packages/lib/src/core/templates-prompt.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
// INVARIANT: script is deterministic
1010
// COMPLEXITY: O(1)
1111
const dockerGitPromptScript = `docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; }
12+
docker_git_terminal_sanitize() {
13+
# Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools.
14+
if [ -t 0 ]; then
15+
stty sane 2>/dev/null || true
16+
fi
17+
if [ -t 1 ]; then
18+
printf "\\033[0m\\033[?25h\\033[?1l\\033>\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1005l\\033[?1006l\\033[?1015l\\033[?1007l\\033[?1004l\\033[?2004l\\033[>4;0m\\033[>4m\\033[<u"
19+
fi
20+
}
1221
docker_git_short_pwd() {
1322
local full_path
1423
full_path="\${PWD:-}"
@@ -61,6 +70,7 @@ docker_git_short_pwd() {
6170
printf "%s" "$result"
6271
}
6372
docker_git_prompt_apply() {
73+
docker_git_terminal_sanitize
6474
local b
6575
b="$(docker_git_branch)"
6676
local short_pwd
@@ -178,6 +188,15 @@ zstyle ':completion:*' tag-order builtins commands aliases reserved-words functi
178188
179189
autoload -Uz add-zsh-hook
180190
docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; }
191+
docker_git_terminal_sanitize() {
192+
# Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools.
193+
if [[ -t 0 ]]; then
194+
stty sane 2>/dev/null || true
195+
fi
196+
if [[ -t 1 ]]; then
197+
printf "\\033[0m\\033[?25h\\033[?1l\\033>\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1005l\\033[?1006l\\033[?1015l\\033[?1007l\\033[?1004l\\033[?2004l\\033[>4;0m\\033[>4m\\033[<u"
198+
fi
199+
}
181200
docker_git_short_pwd() {
182201
local full_path="\${PWD:-}"
183202
if [[ -z "$full_path" ]]; then
@@ -235,6 +254,7 @@ docker_git_short_pwd() {
235254
print -r -- "$result"
236255
}
237256
docker_git_prompt_apply() {
257+
docker_git_terminal_sanitize
238258
local b
239259
b="$(docker_git_branch)"
240260
local short_pwd

packages/lib/src/core/templates/docker-compose.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const buildPlaywrightFragments = (
5757
maybePlaywrightEnv:
5858
` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`,
5959
maybeBrowserService:
60-
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
60+
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
6161
maybeBrowserVolume: ` ${browserVolumeName}:\n`
6262
}
6363
}
@@ -93,6 +93,7 @@ const renderComposeServices = (config: TemplateConfig, fragments: ComposeFragmen
9393
${config.serviceName}:
9494
build: .
9595
container_name: ${config.containerName}
96+
restart: unless-stopped
9697
environment:
9798
REPO_URL: "${config.repoUrl}"
9899
REPO_REF: "${config.repoRef}"

packages/lib/src/usecases/actions/create-project.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ const openSshBestEffort = (
143143
},
144144
[0, 130],
145145
(exitCode) => new CommandFailedError({ command: "ssh", exitCode })
146-
)
146+
).pipe(Effect.ensuring(ensureTerminalCursorVisible()))
147147
)
148148
}).pipe(
149149
Effect.asVoid,

packages/lib/src/usecases/projects-ssh.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ export const connectProjectSsh = (
131131
[0, 130],
132132
(exitCode) => new CommandFailedError({ command: "ssh", exitCode })
133133
)
134-
)
134+
),
135+
Effect.ensuring(ensureTerminalCursorVisible())
135136
)
136137

137138
// CHANGE: ensure docker compose is up before SSH connection

packages/lib/src/usecases/projects-up.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ import { parseComposePsOutput } from "./projects-core.js"
2727

2828
const maxPortAttempts = 25
2929

30+
const syncManagedProjectFiles = (
31+
projectDir: string,
32+
template: TemplateConfig
33+
): Effect.Effect<void, FileExistsError | PlatformError, FileSystem | Path> =>
34+
Effect.gen(function*(_) {
35+
yield* _(Effect.log(`Applying docker-git templates in ${projectDir} before docker compose up...`))
36+
yield* _(writeProjectFiles(projectDir, template, true))
37+
yield* _(ensureCodexConfigFile(projectDir, template.codexAuthPath))
38+
})
39+
3040
// CHANGE: update template port when the preferred SSH port is reserved or busy
3141
// WHY: keep each project on a unique port even across restarts
3242
// QUOTE(ТЗ): "Почему контейнер пытается подниматься на существующий порт?"
@@ -95,8 +105,7 @@ export const runDockerComposeUpWithPortCheck = (
95105
? config.template
96106
: yield* _(ensureAvailableSshPort(projectDir, config))
97107
// Keep generated templates in sync with the running CLI version.
98-
yield* _(writeProjectFiles(projectDir, updated, true))
99-
yield* _(ensureCodexConfigFile(projectDir, updated.codexAuthPath))
108+
yield* _(syncManagedProjectFiles(projectDir, updated))
100109
yield* _(ensureComposeNetworkReady(projectDir, updated))
101110
yield* _(runDockerComposeUp(projectDir))
102111

packages/lib/src/usecases/terminal-cursor.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import { Effect } from "effect"
22

3-
const cursorVisibleEscape = "\u001B[?25h"
3+
const terminalSaneEscape =
4+
"\u001B[0m" + // reset rendition
5+
"\u001B[?25h" + // show cursor
6+
"\u001B[?1l" + // normal cursor keys mode
7+
"\u001B>" + // normal keypad mode
8+
"\u001B[?1000l" + // disable mouse click tracking
9+
"\u001B[?1002l" + // disable mouse drag tracking
10+
"\u001B[?1003l" + // disable any-event mouse tracking
11+
"\u001B[?1005l" + // disable UTF-8 mouse mode
12+
"\u001B[?1006l" + // disable SGR mouse mode
13+
"\u001B[?1015l" + // disable urxvt mouse mode
14+
"\u001B[?1007l" + // disable alternate scroll mode
15+
"\u001B[?1004l" + // disable focus reporting
16+
"\u001B[?2004l" + // disable bracketed paste
17+
"\u001B[>4;0m" + // disable xterm modifyOtherKeys
18+
"\u001B[>4m" + // reset xterm modifyOtherKeys
19+
"\u001B[<u" // disable kitty keyboard protocol
420

521
const hasInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY
622

@@ -19,5 +35,8 @@ export const ensureTerminalCursorVisible = (): Effect.Effect<void> =>
1935
if (!hasInteractiveTty()) {
2036
return
2137
}
22-
process.stdout.write(cursorVisibleEscape)
38+
if (typeof process.stdin.setRawMode === "function") {
39+
process.stdin.setRawMode(false)
40+
}
41+
process.stdout.write(terminalSaneEscape)
2342
})

packages/lib/tests/usecases/prepare-files.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ describe("prepareProjectFiles", () => {
130130
expect(entrypoint).toContain('"plugin": ["oh-my-opencode"]')
131131
expect(entrypoint).toContain("branch '$REPO_REF' missing; retrying without --branch")
132132
expect(entrypoint).not.toContain("git ls-remote --symref")
133+
expect(composeBefore).toContain("container_name: dg-test")
134+
expect(composeBefore).toContain("restart: unless-stopped")
133135
expect(composeBefore).toContain(":/home/dev/.docker-git")
134136
expect(composeBefore).not.toContain("dg-test-browser")
135137
expect(composeBefore).toContain("docker-git-shared")
@@ -152,6 +154,9 @@ describe("prepareProjectFiles", () => {
152154
expect(composeAfter).toContain('GIT_AUTH_LABEL: "AGIENS"')
153155
expect(composeAfter).toContain('CODEX_AUTH_LABEL: "agien-codex"')
154156
expect(composeAfter).toContain('CLAUDE_AUTH_LABEL: "agien-claude"')
157+
expect(composeAfter).toContain("container_name: dg-test")
158+
expect(composeAfter).toContain("container_name: dg-test-browser")
159+
expect(composeAfter).toContain("container_name: dg-test-browser\n restart: unless-stopped")
155160
expect(composeAfter).toContain("docker-git-shared")
156161
expect(composeAfter).toContain("external: true")
157162
expect(readEnableMcpPlaywrightFlag(configAfter)).toBe(true)

0 commit comments

Comments
 (0)