Skip to content

Commit 85c06a1

Browse files
committed
fix(shell): improve docker access diagnostics and e2e paths
1 parent d6c4296 commit 85c06a1

File tree

4 files changed

+115
-24
lines changed

4 files changed

+115
-24
lines changed

packages/lib/src/shell/docker.ts

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PlatformError } from "@effect/platform/Error"
55
import { Effect, pipe } from "effect"
66
import * as Chunk from "effect/Chunk"
77
import * as Stream from "effect/Stream"
8+
import { existsSync } from "node:fs"
89

910
import { runCommandCapture, runCommandWithExitCodes } from "./command-runner.js"
1011
import { CommandFailedError, DockerAccessError, type DockerAccessIssue, DockerCommandError } from "./errors.js"
@@ -39,6 +40,67 @@ const collectUint8Array = (chunks: Chunk.Chunk<Uint8Array>): Uint8Array =>
3940

4041
const permissionDeniedPattern = /permission denied/i
4142

43+
const resolveDockerHostFallback = (): string | undefined => {
44+
if (process.env["DOCKER_HOST"] !== undefined) {
45+
return undefined
46+
}
47+
48+
const runtimeDir = process.env["XDG_RUNTIME_DIR"]?.trim()
49+
const uid =
50+
typeof process.getuid === "function"
51+
? process.getuid().toString()
52+
: process.env["UID"]?.trim()
53+
54+
const candidates = Array.from(
55+
new Set(
56+
[
57+
runtimeDir ? `${runtimeDir}/docker.sock` : undefined,
58+
uid ? `/run/user/${uid}/docker.sock` : undefined
59+
].filter((value): value is string => value !== undefined)
60+
)
61+
)
62+
63+
for (const candidate of candidates) {
64+
if (existsSync(candidate)) {
65+
return `unix://${candidate}`
66+
}
67+
}
68+
69+
return undefined
70+
}
71+
72+
const runDockerInfoCommand = (
73+
cwd: string,
74+
env?: Readonly<Record<string, string | undefined>>
75+
): Effect.Effect<{ readonly exitCode: number; readonly details: string }, PlatformError, CommandExecutor.CommandExecutor> =>
76+
Effect.scoped(
77+
Effect.gen(function*(_) {
78+
const executor = yield* _(CommandExecutor.CommandExecutor)
79+
const process = yield* _(
80+
executor.start(
81+
pipe(
82+
Command.make("docker", "info"),
83+
Command.workingDirectory(cwd),
84+
env ? Command.env(env) : (value) => value,
85+
Command.stdin("pipe"),
86+
Command.stdout("pipe"),
87+
Command.stderr("pipe")
88+
)
89+
)
90+
)
91+
92+
const stderrBytes = yield* _(
93+
pipe(process.stderr, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks)))
94+
)
95+
const exitCode = Number(yield* _(process.exitCode))
96+
const stderr = new TextDecoder("utf-8").decode(stderrBytes).trim()
97+
return {
98+
exitCode,
99+
details: stderr.length > 0 ? stderr : `docker info failed with exit code ${exitCode}`
100+
}
101+
})
102+
)
103+
42104
// CHANGE: classify docker daemon access failure into deterministic typed reasons
43105
// WHY: allow callers to render actionable recovery guidance for socket permission issues
44106
// QUOTE(ТЗ): "docker-git handles Docker socket permission problems predictably"
@@ -67,35 +129,43 @@ export const ensureDockerDaemonAccess = (
67129
): Effect.Effect<void, DockerAccessError | PlatformError, CommandExecutor.CommandExecutor> =>
68130
Effect.scoped(
69131
Effect.gen(function*(_) {
70-
const executor = yield* _(CommandExecutor.CommandExecutor)
71-
const process = yield* _(
72-
executor.start(
73-
pipe(
74-
Command.make("docker", "info"),
75-
Command.workingDirectory(cwd),
76-
Command.stdin("pipe"),
77-
Command.stdout("pipe"),
78-
Command.stderr("pipe")
79-
)
80-
)
81-
)
132+
const primaryResult = yield* _(runDockerInfoCommand(cwd))
133+
if (primaryResult.exitCode === 0) {
134+
return
135+
}
82136

83-
const stderrBytes = yield* _(
84-
pipe(process.stderr, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks)))
85-
)
86-
const exitCode = Number(yield* _(process.exitCode))
137+
const primaryIssue = classifyDockerAccessIssue(primaryResult.details)
138+
if (primaryIssue === "PermissionDenied" && process.env["DOCKER_HOST"] === undefined) {
139+
const fallbackHost = resolveDockerHostFallback()
140+
if (fallbackHost !== undefined) {
141+
const fallbackResult = yield* _(
142+
runDockerInfoCommand(cwd, {
143+
...process.env,
144+
DOCKER_HOST: fallbackHost
145+
})
146+
)
87147

88-
if (exitCode === 0) {
89-
return
148+
if (fallbackResult.exitCode === 0) {
149+
process.env["DOCKER_HOST"] = fallbackHost
150+
return
151+
}
152+
153+
return yield* _(
154+
Effect.fail(
155+
new DockerAccessError({
156+
issue: classifyDockerAccessIssue(fallbackResult.details),
157+
details: fallbackResult.details
158+
})
159+
)
160+
)
161+
}
90162
}
91163

92-
const stderr = new TextDecoder("utf-8").decode(stderrBytes).trim()
93-
const details = stderr.length > 0 ? stderr : `docker info failed with exit code ${exitCode}`
94164
return yield* _(
95165
Effect.fail(
96166
new DockerAccessError({
97-
issue: classifyDockerAccessIssue(details),
98-
details
167+
issue: primaryIssue,
168+
details: primaryResult.details
99169
})
100170
)
101171
)

packages/lib/src/usecases/errors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ const renderDockerAccessHeadline = (issue: DockerAccessError["issue"]): string =
5454
? "Cannot access Docker daemon socket: permission denied."
5555
: "Cannot connect to Docker daemon."
5656

57+
const renderDockerAccessActionPlan = (issue: DockerAccessError["issue"]): string => {
58+
const permissionDeniedPlan = [
59+
"Action plan:",
60+
"1) In the same shell, run: `groups $USER` and make sure group `docker` is present.",
61+
"2) Re-login to refresh group memberships and run command again.",
62+
"3) If DOCKER_HOST is set to rootless socket, keep running: `export DOCKER_HOST=unix:///run/user/$UID/docker.sock`.",
63+
"4) If using a dedicated socket not in /run/user, set DOCKER_HOST explicitly and re-run.",
64+
"Tip: this app now auto-tries a rootless socket fallback on first permission error."
65+
]
66+
67+
const daemonUnavailablePlan = [
68+
"Action plan:",
69+
"1) Check daemon status: `systemctl --user status docker` or `systemctl status docker`.",
70+
"2) Start daemon: `systemctl --user start docker` (or `systemctl start docker` for system Docker).",
71+
"3) Retry command in a new shell."
72+
]
73+
74+
return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n")
75+
}
76+
5777
const renderPrimaryError = (error: NonParseError): string | null =>
5878
Match.value(error).pipe(
5979
Match.when({ _tag: "FileExistsError" }, ({ path }) => `File already exists: ${path} (use --force to overwrite)`),
@@ -68,6 +88,7 @@ const renderPrimaryError = (error: NonParseError): string | null =>
6888
renderDockerAccessHeadline(issue),
6989
"Hint: ensure Docker daemon is running and current user can access the docker socket.",
7090
"Hint: if you use rootless Docker, set DOCKER_HOST to your user socket (for example unix:///run/user/$UID/docker.sock).",
91+
renderDockerAccessActionPlan(issue),
7192
`Details: ${details}`
7293
].join("\n")),
7394
Match.when({ _tag: "CloneFailedError" }, ({ repoRef, repoUrl, targetDir }) =>

scripts/e2e/clone-cache.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export DOCKER_GIT_PROJECTS_ROOT="$ROOT"
2121
export DOCKER_GIT_STATE_AUTO_SYNC=0
2222

2323
REPO_URL="https://github.com/octocat/Hello-World/issues/1"
24-
TARGET_DIR="/home/dev/octocat/hello-world/issue-1"
24+
TARGET_DIR="/home/dev/.docker-git/workspaces/octocat/hello-world/issue-1"
2525
MIRROR_PREFIX="/home/dev/.docker-git/.cache/git-mirrors"
2626

2727
ACTIVE_OUT_DIR=""

scripts/e2e/opencode-autoconnect.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export DOCKER_GIT_PROJECTS_ROOT="$ROOT"
2525
export DOCKER_GIT_STATE_AUTO_SYNC=0
2626

2727
REPO_URL="https://github.com/octocat/Hello-World/issues/1"
28-
TARGET_DIR="/home/dev/octocat/hello-world/issue-1"
28+
TARGET_DIR="/home/dev/.docker-git/workspaces/octocat/hello-world/issue-1"
2929
E2E_BIN="$ROOT/.e2e-bin"
3030
dg_ensure_docker "$E2E_BIN"
3131

0 commit comments

Comments
 (0)