Skip to content

Commit bb55787

Browse files
committed
Merge remote-tracking branch 'origin/main' into issue-84
2 parents 984ce9c + 1ea8eb8 commit bb55787

15 files changed

Lines changed: 438 additions & 36 deletions

File tree

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-entrypoint/tasks.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,22 +139,15 @@ const renderCloneBodyRef = (config: TemplateConfig): string =>
139139
fi
140140
else
141141
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then
142-
DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)"
143-
DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')"
144-
if [[ -n "$DEFAULT_BRANCH" ]]; then
145-
echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'"
146-
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then
147-
echo "[clone] git clone failed for $REPO_URL"
148-
CLONE_OK=0
149-
elif [[ "$REPO_REF" == issue-* ]]; then
150-
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then
151-
echo "[clone] failed to create local branch '$REPO_REF'"
152-
CLONE_OK=0
153-
fi
154-
fi
155-
else
142+
echo "[clone] branch '$REPO_REF' missing; retrying without --branch"
143+
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
156144
echo "[clone] git clone failed for $REPO_URL"
157145
CLONE_OK=0
146+
elif [[ "$REPO_REF" == issue-* ]]; then
147+
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then
148+
echo "[clone] failed to create local branch '$REPO_REF'"
149+
CLONE_OK=0
150+
fi
158151
fi
159152
fi
160153
fi

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/shell/docker.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,31 @@ export const runDockerNetworkCreateBridge = (
381381
(exitCode) => new DockerCommandError({ exitCode })
382382
)
383383

384+
// CHANGE: create a Docker bridge network with an explicit subnet
385+
// WHY: allow callers to bypass default address-pool allocation when it is exhausted
386+
// QUOTE(ТЗ): "научилось создавать сети правильно"
387+
// REF: user-request-2026-02-20-network-fallback
388+
// SOURCE: n/a
389+
// FORMAT THEOREM: ∀(n,s): create(n,s)=0 -> exists(n) ∧ subnet(n)=s
390+
// PURITY: SHELL
391+
// EFFECT: Effect<void, DockerCommandError | PlatformError, CommandExecutor>
392+
// INVARIANT: network driver is always `bridge`
393+
// COMPLEXITY: O(command)
394+
export const runDockerNetworkCreateBridgeWithSubnet = (
395+
cwd: string,
396+
networkName: string,
397+
subnet: string
398+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
399+
runCommandWithExitCodes(
400+
{
401+
cwd,
402+
command: "docker",
403+
args: ["network", "create", "--driver", "bridge", "--subnet", subnet, networkName]
404+
},
405+
[Number(ExitCode(0))],
406+
(exitCode) => new DockerCommandError({ exitCode })
407+
)
408+
384409
// CHANGE: inspect how many containers are attached to a network
385410
// WHY: network GC must remove only detached networks
386411
// QUOTE(ТЗ): "Только так что бы текущие проекты не ложились"

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/docker-network-gc.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import {
1111
runDockerNetworkContainerCount,
1212
runDockerNetworkCreateBridge,
13+
runDockerNetworkCreateBridgeWithSubnet,
1314
runDockerNetworkExists,
1415
runDockerNetworkRemove
1516
} from "../shell/docker.js"
@@ -20,6 +21,54 @@ const protectedNetworkNames = new Set(["bridge", "host", "none"])
2021
const isProtectedNetwork = (networkName: string, sharedNetworkName: string): boolean =>
2122
protectedNetworkNames.has(networkName) || networkName === sharedNetworkName
2223

24+
const sharedNetworkFallbackSubnets: ReadonlyArray<string> = [
25+
"10.250.0.0/24",
26+
"10.251.0.0/24",
27+
"10.252.0.0/24",
28+
"10.253.0.0/24",
29+
"172.31.250.0/24",
30+
"172.31.251.0/24",
31+
"172.31.252.0/24",
32+
"172.31.253.0/24",
33+
"192.168.250.0/24",
34+
"192.168.251.0/24"
35+
]
36+
37+
const createSharedNetworkWithSubnetFallback = (
38+
cwd: string,
39+
networkName: string
40+
): Effect.Effect<boolean, PlatformError, CommandExecutor> =>
41+
Effect.gen(function*(_) {
42+
for (const subnet of sharedNetworkFallbackSubnets) {
43+
const created = yield* _(
44+
runDockerNetworkCreateBridgeWithSubnet(cwd, networkName, subnet).pipe(
45+
Effect.as(true),
46+
Effect.catchTag("DockerCommandError", (error) =>
47+
Effect.logWarning(
48+
`Shared network create fallback failed (${networkName}, subnet ${subnet}, exit ${error.exitCode}); trying next subnet.`
49+
).pipe(Effect.as(false))
50+
)
51+
)
52+
)
53+
if (created) {
54+
yield* _(Effect.log(`Created shared Docker network ${networkName} with subnet ${subnet}.`))
55+
return true
56+
}
57+
}
58+
return false
59+
})
60+
61+
const ensureSharedNetworkExists = (
62+
cwd: string,
63+
networkName: string
64+
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor> =>
65+
runDockerNetworkCreateBridge(cwd, networkName).pipe(
66+
Effect.catchTag("DockerCommandError", (error) =>
67+
createSharedNetworkWithSubnetFallback(cwd, networkName).pipe(
68+
Effect.flatMap((created) => (created ? Effect.void : Effect.fail(error)))
69+
))
70+
)
71+
2372
// CHANGE: ensure shared docker network exists before compose up
2473
// WHY: avoid compose failures when using `external: true` shared network mode
2574
// QUOTE(ТЗ): "Что бы текущие проекты не ложились"
@@ -44,7 +93,7 @@ export const ensureComposeNetworkReady = (
4493
exists
4594
? Effect.void
4695
: Effect.log(`Creating shared Docker network: ${networkName}`).pipe(
47-
Effect.zipRight(runDockerNetworkCreateBridge(cwd, networkName))
96+
Effect.zipRight(ensureSharedNetworkExists(cwd, networkName))
4897
))
4998
)
5099
}

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
})

0 commit comments

Comments
 (0)