Skip to content

Commit d768913

Browse files
authored
fix(shell): avoid docker SSH port collisions and improve docker compose hints (#71)
* feat(ui): add project list scrolling and shorten prompt paths * fix(shell): avoid docker SSH port collisions and improve hints * fix(lint): satisfy effect and unicorn rules for docker port fallback
1 parent 0ef3406 commit d768913

File tree

7 files changed

+144
-10
lines changed

7 files changed

+144
-10
lines changed

packages/lib/src/shell/docker.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,76 @@ export const runDockerPsNames = (
440440
.filter((line) => line.length > 0)
441441
)
442442
)
443+
444+
const publishedHostPortPattern = /:(\d+)->/g
445+
446+
const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray<number> => {
447+
const parsed: Array<number> = []
448+
for (const match of line.matchAll(publishedHostPortPattern)) {
449+
const rawPort = match[1]
450+
if (rawPort === undefined) {
451+
continue
452+
}
453+
const value = Number.parseInt(rawPort, 10)
454+
if (Number.isInteger(value) && value > 0 && value <= 65_535) {
455+
parsed.push(value)
456+
}
457+
}
458+
return parsed
459+
}
460+
461+
// CHANGE: decode published host ports from `docker ps --format "{{.Ports}}"` output
462+
// WHY: Docker can reserve host ports via NAT even when no host TCP socket is visible
463+
// QUOTE(ТЗ): "должен просто новый порт брать под себя"
464+
// REF: user-request-2026-02-19-port-allocation
465+
// SOURCE: n/a
466+
// FORMAT THEOREM: forall p in parse(output): published_by_docker(p)
467+
// PURITY: CORE
468+
// EFFECT: Effect<ReadonlyArray<number>, never, never>
469+
// INVARIANT: returns unique ports in encounter order
470+
// COMPLEXITY: O(|output|)
471+
export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray<number> => {
472+
const unique = new Set<number>()
473+
const parsed: Array<number> = []
474+
475+
for (const line of output.split(/\r?\n/)) {
476+
const trimmed = line.trim()
477+
if (trimmed.length === 0) {
478+
continue
479+
}
480+
for (const port of parsePublishedHostPortsFromLine(trimmed)) {
481+
if (!unique.has(port)) {
482+
unique.add(port)
483+
parsed.push(port)
484+
}
485+
}
486+
}
487+
488+
return parsed
489+
}
490+
491+
// CHANGE: read currently published Docker host ports from running containers
492+
// WHY: avoid false "free port" results when Docker reserves ports without userland proxy sockets
493+
// QUOTE(ТЗ): "а не сражаться за старый"
494+
// REF: user-request-2026-02-19-port-allocation
495+
// SOURCE: n/a
496+
// FORMAT THEOREM: forall p in result: published_by_running_container(p)
497+
// PURITY: SHELL
498+
// EFFECT: Effect<ReadonlyArray<number>, CommandFailedError | PlatformError, CommandExecutor>
499+
// INVARIANT: output ports are unique
500+
// COMPLEXITY: O(command + |stdout|)
501+
export const runDockerPsPublishedHostPorts = (
502+
cwd: string
503+
): Effect.Effect<ReadonlyArray<number>, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
504+
pipe(
505+
runCommandCapture(
506+
{
507+
cwd,
508+
command: "docker",
509+
args: ["ps", "--format", "{{.Ports}}"]
510+
},
511+
[Number(ExitCode(0))],
512+
(exitCode) => new CommandFailedError({ command: "docker ps", exitCode })
513+
),
514+
Effect.map((output) => parseDockerPublishedHostPorts(output))
515+
)

packages/lib/src/usecases/actions/ports.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
12
import type { PlatformError } from "@effect/platform/Error"
23
import type * as FileSystem from "@effect/platform/FileSystem"
34
import type * as Path from "@effect/platform/Path"
@@ -15,7 +16,7 @@ export const resolveSshPort = (
1516
): Effect.Effect<
1617
CreateCommand["config"],
1718
PortProbeError | PlatformError,
18-
FileSystem.FileSystem | Path.Path
19+
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
1920
> =>
2021
Effect.gen(function*(_) {
2122
const reserved = yield* _(loadReservedPorts(outDir))

packages/lib/src/usecases/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ const renderPrimaryError = (error: NonParseError): string | null =>
6060
Match.when({ _tag: "DockerCommandError" }, ({ exitCode }) =>
6161
[
6262
`docker compose failed with exit code ${exitCode}`,
63-
"Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group)."
63+
"Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group).",
64+
"Hint: if output above contains 'port is already allocated', retry with a free SSH port via --ssh-port <port> (for example --ssh-port 2235), or stop the conflicting project/container."
6465
].join("\n")),
6566
Match.when({ _tag: "DockerAccessError" }, ({ details, issue }) =>
6667
[

packages/lib/src/usecases/ports-reserve.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
12
import type { PlatformError } from "@effect/platform/Error"
23
import type { FileSystem } from "@effect/platform/FileSystem"
34
import * as Path from "@effect/platform/Path"
4-
import { Effect, Option } from "effect"
5+
import { Effect, Either, Option } from "effect"
56

7+
import { runDockerPsPublishedHostPorts } from "../shell/docker.js"
68
import { PortProbeError } from "../shell/errors.js"
79
import { isPortAvailable } from "../shell/ports.js"
810
import { listProjectItems } from "./projects-list.js"
@@ -12,6 +14,8 @@ export type ReservedPort = {
1214
readonly projectDir: string
1315
}
1416

17+
const dockerPublishedMarker = "<docker:published>"
18+
1519
const resolveExclude = (
1620
path: Path.Path,
1721
excludeDir: string | null
@@ -29,38 +33,74 @@ const filterReserved = (
2933
return path.resolve(item.projectDir) !== resolvedExclude
3034
}
3135

36+
const reservePort = (
37+
reserved: Array<ReservedPort>,
38+
seen: Set<number>,
39+
port: number,
40+
projectDir: string
41+
): void => {
42+
if (seen.has(port)) {
43+
return
44+
}
45+
seen.add(port)
46+
reserved.push({ port, projectDir })
47+
}
48+
49+
const loadPublishedDockerPorts = (): Effect.Effect<ReadonlySet<number>, never, CommandExecutor.CommandExecutor> =>
50+
Effect.either(runDockerPsPublishedHostPorts(process.cwd())).pipe(
51+
Effect.flatMap(
52+
Either.match({
53+
onLeft: (error) =>
54+
Effect.logWarning(
55+
`Failed to read published Docker ports; falling back to TCP probing only: ${
56+
error instanceof Error ? error.message : String(error)
57+
}`
58+
).pipe(Effect.as(new Set<number>())),
59+
onRight: (ports) => Effect.succeed(new Set(ports))
60+
})
61+
)
62+
)
63+
3264
// CHANGE: collect SSH ports currently occupied by existing docker-git projects
3365
// WHY: avoid port collisions while allowing reuse of ports from stopped projects
3466
// QUOTE(ТЗ): "для каждого докера брать должен свой порт"
3567
// REF: user-request-2026-02-05-port-reserve
3668
// SOURCE: n/a
3769
// FORMAT THEOREM: ∀p∈Projects: reserved(port(p))
3870
// PURITY: SHELL
39-
// EFFECT: Effect<ReadonlyArray<ReservedPort>, PlatformError | PortProbeError, FileSystem | Path.Path>
71+
// EFFECT: Effect<ReadonlyArray<ReservedPort>, PlatformError | PortProbeError, FileSystem | Path.Path | CommandExecutor>
4072
// INVARIANT: excludes the current project dir when provided
4173
// COMPLEXITY: O(n) where n = number of projects
4274
export const loadReservedPorts = (
4375
excludeDir: string | null
4476
): Effect.Effect<
4577
ReadonlyArray<ReservedPort>,
4678
PlatformError | PortProbeError,
47-
FileSystem | Path.Path
79+
FileSystem | Path.Path | CommandExecutor.CommandExecutor
4880
> =>
4981
Effect.gen(function*(_) {
5082
const path = yield* _(Path.Path)
5183
const items = yield* _(listProjectItems)
84+
const publishedByDocker = yield* _(loadPublishedDockerPorts())
5285
const reserved: Array<ReservedPort> = []
86+
const seen = new Set<number>()
5387
const filter = filterReserved(path, excludeDir)
5488

5589
for (const item of items) {
5690
if (!filter(item)) {
5791
continue
5892
}
59-
if (!(yield* _(isPortAvailable(item.sshPort)))) {
60-
reserved.push({ port: item.sshPort, projectDir: item.projectDir })
93+
const occupiedByDocker = publishedByDocker.has(item.sshPort)
94+
const occupiedBySocket = occupiedByDocker ? false : !(yield* _(isPortAvailable(item.sshPort)))
95+
if (occupiedByDocker || occupiedBySocket) {
96+
reservePort(reserved, seen, item.sshPort, item.projectDir)
6197
}
6298
}
6399

100+
for (const port of publishedByDocker) {
101+
reservePort(reserved, seen, port, dockerPublishedMarker)
102+
}
103+
64104
return reserved
65105
})
66106

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const maxPortAttempts = 25
3333
// SOURCE: n/a
3434
// FORMAT THEOREM: ∀p: reserved(p) ∨ occupied(p) → selected(p') ∧ available(p')
3535
// PURITY: SHELL
36-
// EFFECT: Effect<TemplateConfig, PortProbeError | PlatformError | FileExistsError, FileSystem | Path>
36+
// EFFECT: Effect<TemplateConfig, PortProbeError | PlatformError | FileExistsError, FileSystem | Path | CommandExecutor>
3737
// INVARIANT: config is rewritten when port changes
3838
// COMPLEXITY: O(n) where n = maxPortAttempts
3939
const ensureAvailableSshPort = (
@@ -42,7 +42,7 @@ const ensureAvailableSshPort = (
4242
): Effect.Effect<
4343
TemplateConfig,
4444
PortProbeError | PlatformError | FileExistsError,
45-
FileSystem | Path
45+
FileSystem | Path | CommandExecutor
4646
> =>
4747
Effect.gen(function*(_) {
4848
const reserved = yield* _(loadReservedPorts(projectDir))
Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import { describe, expect, it } from "@effect/vitest"
22

3-
import { dockerComposeUpRecreateArgs } from "../../src/shell/docker.js"
3+
import { dockerComposeUpRecreateArgs, parseDockerPublishedHostPorts } from "../../src/shell/docker.js"
44

55
describe("docker compose args", () => {
66
it("uses build when force-env recreates containers", () => {
77
expect(dockerComposeUpRecreateArgs).toEqual(["up", "-d", "--build", "--force-recreate"])
88
})
99
})
10+
11+
describe("parseDockerPublishedHostPorts", () => {
12+
it("extracts unique published host ports from docker ps output", () => {
13+
const output = [
14+
"127.0.0.1:2222->22/tcp",
15+
"0.0.0.0:5672->5672/tcp, [::]:5672->5672/tcp",
16+
"5900/tcp, 6080/tcp, 9223/tcp",
17+
":::8080->80/tcp"
18+
].join("\n")
19+
20+
expect(parseDockerPublishedHostPorts(output)).toEqual([2222, 5672, 8080])
21+
})
22+
23+
it("returns empty array when no host ports are published", () => {
24+
expect(parseDockerPublishedHostPorts("5900/tcp, 6080/tcp")).toEqual([])
25+
})
26+
})

packages/lib/tests/usecases/errors.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ describe("renderError", () => {
99

1010
expect(message).toContain("docker compose failed with exit code 1")
1111
expect(message).toContain("/var/run/docker.sock")
12+
expect(message).toContain("port is already allocated")
13+
expect(message).toContain("--ssh-port")
1214
})
1315

1416
it("renders actionable recovery for DockerAccessError", () => {

0 commit comments

Comments
 (0)