Skip to content

Commit d4cb2ea

Browse files
committed
fix(shell): satisfy lint and effect-ts checks for docker access
1 parent 85c06a1 commit d4cb2ea

File tree

3 files changed

+155
-148
lines changed

3 files changed

+155
-148
lines changed

packages/lib/src/core/command-builders.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Either } from "effect"
22

3+
import { expandContainerHome } from "../usecases/scrap-path.js"
34
import { type RawOptions } from "./command-options.js"
45
import {
56
type CreateCommand,
@@ -10,7 +11,6 @@ import {
1011
resolveRepoInput
1112
} from "./domain.js"
1213
import { trimRightChar } from "./strings.js"
13-
import { expandContainerHome } from "../usecases/scrap-path.js"
1414

1515
const parsePort = (value: string): Either.Either<number, ParseError> => {
1616
const parsed = Number(value)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import * as Command from "@effect/platform/Command"
2+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import type { PlatformError } from "@effect/platform/Error"
4+
import { Effect, pipe } from "effect"
5+
import * as Chunk from "effect/Chunk"
6+
import * as Stream from "effect/Stream"
7+
8+
import { DockerAccessError, type DockerAccessIssue } from "./errors.js"
9+
10+
const permissionDeniedPattern = /permission denied/i
11+
12+
const collectUint8Array = (chunks: Chunk.Chunk<Uint8Array>): Uint8Array =>
13+
Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => {
14+
const next = new Uint8Array(acc.length + curr.length)
15+
next.set(acc)
16+
next.set(curr, acc.length)
17+
return next
18+
})
19+
20+
const resolveDockerHostFallbackCandidates = (): ReadonlyArray<string> => {
21+
if (process.env["DOCKER_HOST"] !== undefined) {
22+
return []
23+
}
24+
25+
const runtimeDir = process.env["XDG_RUNTIME_DIR"]?.trim()
26+
const uid = typeof process.getuid === "function"
27+
? process.getuid().toString()
28+
: process.env["UID"]?.trim()
29+
30+
return [
31+
...new Set(
32+
[
33+
runtimeDir ? `unix://${runtimeDir}/docker.sock` : undefined,
34+
uid ? `unix:///run/user/${uid}/docker.sock` : undefined
35+
].filter((value): value is string => value !== undefined)
36+
)
37+
]
38+
}
39+
40+
const runDockerInfoCommand = (
41+
cwd: string,
42+
env?: Readonly<Record<string, string | undefined>>
43+
): Effect.Effect<
44+
{ readonly exitCode: number; readonly details: string },
45+
PlatformError,
46+
CommandExecutor.CommandExecutor
47+
> =>
48+
Effect.scoped(
49+
Effect.gen(function*(_) {
50+
const executor = yield* _(CommandExecutor.CommandExecutor)
51+
const process = yield* _(
52+
executor.start(
53+
pipe(
54+
Command.make("docker", "info"),
55+
Command.workingDirectory(cwd),
56+
env ? Command.env(env) : (value) => value,
57+
Command.stdin("pipe"),
58+
Command.stdout("pipe"),
59+
Command.stderr("pipe")
60+
)
61+
)
62+
)
63+
64+
const stderrBytes = yield* _(
65+
pipe(process.stderr, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks)))
66+
)
67+
const exitCode = Number(yield* _(process.exitCode))
68+
const stderr = new TextDecoder("utf-8").decode(stderrBytes).trim()
69+
return {
70+
exitCode,
71+
details: stderr.length > 0 ? stderr : `docker info failed with exit code ${exitCode}`
72+
}
73+
})
74+
)
75+
76+
// CHANGE: classify docker daemon access failure into deterministic typed reasons
77+
// WHY: allow callers to render actionable recovery guidance for socket permission issues
78+
// QUOTE(ТЗ): "docker-git handles Docker socket permission problems predictably"
79+
// REF: issue-11
80+
// SOURCE: n/a
81+
// FORMAT THEOREM: ∀m: classify(m) ∈ {"PermissionDenied","DaemonUnavailable"}
82+
// PURITY: CORE
83+
// EFFECT: Effect<DockerAccessIssue, never, never>
84+
// INVARIANT: classification is stable for equal input
85+
// COMPLEXITY: O(|m|)
86+
export const classifyDockerAccessIssue = (message: string): DockerAccessIssue =>
87+
permissionDeniedPattern.test(message) ? "PermissionDenied" : "DaemonUnavailable"
88+
89+
// CHANGE: verify docker daemon access before compose/auth flows
90+
// WHY: fail fast on socket permission errors instead of cascading into opaque command failures
91+
// QUOTE(ТЗ): "permission denied to /var/run/docker.sock"
92+
// REF: issue-11
93+
// SOURCE: n/a
94+
// FORMAT THEOREM: ∀cwd: access(cwd)=ok ∨ DockerAccessError
95+
// PURITY: SHELL
96+
// EFFECT: Effect<void, DockerAccessError | PlatformError, CommandExecutor>
97+
// INVARIANT: non-zero docker info exit always maps to DockerAccessError
98+
// COMPLEXITY: O(command)
99+
export const ensureDockerDaemonAccess = (
100+
cwd: string
101+
): Effect.Effect<void, DockerAccessError | PlatformError, CommandExecutor.CommandExecutor> =>
102+
Effect.scoped(
103+
Effect.gen(function*(_) {
104+
const primaryResult = yield* _(runDockerInfoCommand(cwd))
105+
if (primaryResult.exitCode === 0) {
106+
return
107+
}
108+
109+
const primaryIssue = classifyDockerAccessIssue(primaryResult.details)
110+
if (primaryIssue !== "PermissionDenied") {
111+
return yield* _(
112+
Effect.fail(
113+
new DockerAccessError({
114+
issue: primaryIssue,
115+
details: primaryResult.details
116+
})
117+
)
118+
)
119+
}
120+
121+
let fallbackErrorDetails = primaryResult.details
122+
let fallbackIssue: DockerAccessIssue = primaryIssue
123+
124+
for (const fallbackHost of resolveDockerHostFallbackCandidates()) {
125+
const fallbackResult = yield* _(
126+
runDockerInfoCommand(cwd, {
127+
...process.env,
128+
DOCKER_HOST: fallbackHost
129+
})
130+
)
131+
132+
if (fallbackResult.exitCode === 0) {
133+
process.env["DOCKER_HOST"] = fallbackHost
134+
return
135+
}
136+
137+
fallbackErrorDetails = fallbackResult.details
138+
fallbackIssue = classifyDockerAccessIssue(fallbackResult.details)
139+
}
140+
141+
return yield* _(
142+
Effect.fail(
143+
new DockerAccessError({
144+
issue: fallbackIssue,
145+
details: fallbackErrorDetails
146+
})
147+
)
148+
)
149+
})
150+
)

packages/lib/src/shell/docker.ts

Lines changed: 4 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import * as Command from "@effect/platform/Command"
2-
import * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
33
import { ExitCode } from "@effect/platform/CommandExecutor"
44
import type { PlatformError } from "@effect/platform/Error"
55
import { Effect, pipe } from "effect"
6-
import * as Chunk from "effect/Chunk"
7-
import * as Stream from "effect/Stream"
8-
import { existsSync } from "node:fs"
96

107
import { runCommandCapture, runCommandWithExitCodes } from "./command-runner.js"
11-
import { CommandFailedError, DockerAccessError, type DockerAccessIssue, DockerCommandError } from "./errors.js"
8+
import { CommandFailedError, DockerCommandError } from "./errors.js"
9+
10+
export { classifyDockerAccessIssue, ensureDockerDaemonAccess } from "./docker-daemon-access.js"
1211

1312
const composeSpec = (cwd: string, args: ReadonlyArray<string>) => ({
1413
cwd,
@@ -30,148 +29,6 @@ const parseInspectNetworkEntry = (line: string): ReadonlyArray<readonly [string,
3029
return [entry]
3130
}
3231

33-
const collectUint8Array = (chunks: Chunk.Chunk<Uint8Array>): Uint8Array =>
34-
Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => {
35-
const next = new Uint8Array(acc.length + curr.length)
36-
next.set(acc)
37-
next.set(curr, acc.length)
38-
return next
39-
})
40-
41-
const permissionDeniedPattern = /permission denied/i
42-
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-
104-
// CHANGE: classify docker daemon access failure into deterministic typed reasons
105-
// WHY: allow callers to render actionable recovery guidance for socket permission issues
106-
// QUOTE(ТЗ): "docker-git handles Docker socket permission problems predictably"
107-
// REF: issue-11
108-
// SOURCE: n/a
109-
// FORMAT THEOREM: ∀m: classify(m) ∈ {"PermissionDenied","DaemonUnavailable"}
110-
// PURITY: CORE
111-
// EFFECT: Effect<DockerAccessIssue, never, never>
112-
// INVARIANT: classification is stable for equal input
113-
// COMPLEXITY: O(|m|)
114-
export const classifyDockerAccessIssue = (message: string): DockerAccessIssue =>
115-
permissionDeniedPattern.test(message) ? "PermissionDenied" : "DaemonUnavailable"
116-
117-
// CHANGE: verify docker daemon access before compose/auth flows
118-
// WHY: fail fast on socket permission errors instead of cascading into opaque command failures
119-
// QUOTE(ТЗ): "permission denied to /var/run/docker.sock"
120-
// REF: issue-11
121-
// SOURCE: n/a
122-
// FORMAT THEOREM: ∀cwd: access(cwd)=ok ∨ DockerAccessError
123-
// PURITY: SHELL
124-
// EFFECT: Effect<void, DockerAccessError | PlatformError, CommandExecutor>
125-
// INVARIANT: non-zero docker info exit always maps to DockerAccessError
126-
// COMPLEXITY: O(command)
127-
export const ensureDockerDaemonAccess = (
128-
cwd: string
129-
): Effect.Effect<void, DockerAccessError | PlatformError, CommandExecutor.CommandExecutor> =>
130-
Effect.scoped(
131-
Effect.gen(function*(_) {
132-
const primaryResult = yield* _(runDockerInfoCommand(cwd))
133-
if (primaryResult.exitCode === 0) {
134-
return
135-
}
136-
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-
)
147-
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-
}
162-
}
163-
164-
return yield* _(
165-
Effect.fail(
166-
new DockerAccessError({
167-
issue: primaryIssue,
168-
details: primaryResult.details
169-
})
170-
)
171-
)
172-
})
173-
)
174-
17532
const runCompose = (
17633
cwd: string,
17734
args: ReadonlyArray<string>,

0 commit comments

Comments
 (0)