Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const program = pipe(
Effect.logWarning(renderError(error)),
Effect.asVoid
)),
Effect.catchTag("DockerAccessError", logWarningAndExit),
Effect.catchTag("DockerCommandError", logWarningAndExit),
Effect.catchTag("AuthError", logWarningAndExit),
Effect.catchTag("CommandFailedError", logWarningAndExit),
Expand Down
79 changes: 77 additions & 2 deletions packages/lib/src/shell/docker.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import * as Command from "@effect/platform/Command"
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
import * as CommandExecutor from "@effect/platform/CommandExecutor"
import { ExitCode } from "@effect/platform/CommandExecutor"
import type { PlatformError } from "@effect/platform/Error"
import { Effect, pipe } from "effect"
import * as Chunk from "effect/Chunk"
import * as Stream from "effect/Stream"

import { runCommandCapture, runCommandWithExitCodes } from "./command-runner.js"
import { CommandFailedError, DockerCommandError } from "./errors.js"
import { CommandFailedError, DockerAccessError, type DockerAccessIssue, DockerCommandError } from "./errors.js"

const composeSpec = (cwd: string, args: ReadonlyArray<string>) => ({
cwd,
Expand All @@ -27,6 +29,79 @@ const parseInspectNetworkEntry = (line: string): ReadonlyArray<readonly [string,
return [entry]
}

const collectUint8Array = (chunks: Chunk.Chunk<Uint8Array>): Uint8Array =>
Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => {
const next = new Uint8Array(acc.length + curr.length)
next.set(acc)
next.set(curr, acc.length)
return next
})

const permissionDeniedPattern = /permission denied/i

// CHANGE: classify docker daemon access failure into deterministic typed reasons
// WHY: allow callers to render actionable recovery guidance for socket permission issues
// QUOTE(ТЗ): "docker-git handles Docker socket permission problems predictably"
// REF: issue-11
// SOURCE: n/a
// FORMAT THEOREM: ∀m: classify(m) ∈ {"PermissionDenied","DaemonUnavailable"}
// PURITY: CORE
// EFFECT: Effect<DockerAccessIssue, never, never>
// INVARIANT: classification is stable for equal input
// COMPLEXITY: O(|m|)
export const classifyDockerAccessIssue = (message: string): DockerAccessIssue =>
permissionDeniedPattern.test(message) ? "PermissionDenied" : "DaemonUnavailable"

// CHANGE: verify docker daemon access before compose/auth flows
// WHY: fail fast on socket permission errors instead of cascading into opaque command failures
// QUOTE(ТЗ): "permission denied to /var/run/docker.sock"
// REF: issue-11
// SOURCE: n/a
// FORMAT THEOREM: ∀cwd: access(cwd)=ok ∨ DockerAccessError
// PURITY: SHELL
// EFFECT: Effect<void, DockerAccessError | PlatformError, CommandExecutor>
// INVARIANT: non-zero docker info exit always maps to DockerAccessError
// COMPLEXITY: O(command)
export const ensureDockerDaemonAccess = (
cwd: string
): Effect.Effect<void, DockerAccessError | PlatformError, CommandExecutor.CommandExecutor> =>
Effect.scoped(
Effect.gen(function*(_) {
const executor = yield* _(CommandExecutor.CommandExecutor)
const process = yield* _(
executor.start(
pipe(
Command.make("docker", "info"),
Command.workingDirectory(cwd),
Command.stdin("pipe"),
Command.stdout("pipe"),
Command.stderr("pipe")
)
)
)

const stderrBytes = yield* _(
pipe(process.stderr, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks)))
)
const exitCode = Number(yield* _(process.exitCode))

if (exitCode === 0) {
return
}

const stderr = new TextDecoder("utf-8").decode(stderrBytes).trim()
const details = stderr.length > 0 ? stderr : `docker info failed with exit code ${exitCode}`
return yield* _(
Effect.fail(
new DockerAccessError({
issue: classifyDockerAccessIssue(details),
details
})
)
)
})
)

const runCompose = (
cwd: string,
args: ReadonlyArray<string>,
Expand Down
7 changes: 7 additions & 0 deletions packages/lib/src/shell/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export class DockerCommandError extends Data.TaggedError("DockerCommandError")<{
readonly exitCode: number
}> {}

export type DockerAccessIssue = "PermissionDenied" | "DaemonUnavailable"

export class DockerAccessError extends Data.TaggedError("DockerAccessError")<{
readonly issue: DockerAccessIssue
readonly details: string
}> {}

export class CloneFailedError extends Data.TaggedError("CloneFailedError")<{
readonly repoUrl: string
readonly repoRef: string
Expand Down
14 changes: 13 additions & 1 deletion packages/lib/src/usecases/actions/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { Effect } from "effect"

import type { CreateCommand } from "../../core/domain.js"
import { deriveRepoPathParts } from "../../core/domain.js"
import type { CloneFailedError, DockerCommandError, FileExistsError, PortProbeError } from "../../shell/errors.js"
import { ensureDockerDaemonAccess } from "../../shell/docker.js"
import type {
CloneFailedError,
DockerAccessError,
DockerCommandError,
FileExistsError,
PortProbeError
} from "../../shell/errors.js"
import { logDockerAccessInfo } from "../access-log.js"
import { applyGithubForkConfig } from "../github-fork.js"
import { defaultProjectsRoot } from "../menu-helpers.js"
Expand All @@ -21,6 +28,7 @@ type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.
type CreateProjectError =
| FileExistsError
| CloneFailedError
| DockerAccessError
| DockerCommandError
| PortProbeError
| PlatformError
Expand Down Expand Up @@ -76,6 +84,10 @@ const runCreateProject = (
command: CreateCommand
): Effect.Effect<void, CreateProjectError, CreateProjectRuntime> =>
Effect.gen(function*(_) {
if (command.runUp) {
yield* _(ensureDockerDaemonAccess(process.cwd()))
}

const ctx = makeCreateContext(path, process.cwd())
const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir))

Expand Down
16 changes: 16 additions & 0 deletions packages/lib/src/usecases/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
CommandFailedError,
ConfigDecodeError,
ConfigNotFoundError,
DockerAccessError,
DockerCommandError,
FileExistsError,
InputCancelledError,
Expand All @@ -18,6 +19,7 @@ export type AppError =
| ParseError
| FileExistsError
| CloneFailedError
| DockerAccessError
| DockerCommandError
| ConfigNotFoundError
| ConfigDecodeError
Expand All @@ -38,6 +40,11 @@ const isParseError = (error: AppError): error is ParseError =>
error._tag === "InvalidOption" ||
error._tag === "UnexpectedArgument"

const renderDockerAccessHeadline = (issue: DockerAccessError["issue"]): string =>
issue === "PermissionDenied"
? "Cannot access Docker daemon socket: permission denied."
: "Cannot connect to Docker daemon."

const renderPrimaryError = (error: NonParseError): string | null => {
if (error._tag === "FileExistsError") {
return `File already exists: ${error.path} (use --force to overwrite)`
Expand All @@ -50,6 +57,15 @@ const renderPrimaryError = (error: NonParseError): string | null => {
].join("\n")
}

if (error._tag === "DockerAccessError") {
return [
renderDockerAccessHeadline(error.issue),
"Hint: ensure Docker daemon is running and current user can access the docker socket.",
"Hint: if you use rootless Docker, set DOCKER_HOST to your user socket (for example unix:///run/user/$UID/docker.sock).",
`Details: ${error.details}`
].join("\n")
}

if (error._tag === "CloneFailedError") {
return `Clone failed for ${error.repoUrl} (${error.repoRef}) into ${error.targetDir}`
}
Expand Down
21 changes: 21 additions & 0 deletions packages/lib/tests/shell/docker-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "@effect/vitest"

import { classifyDockerAccessIssue } from "../../src/shell/docker.js"

describe("classifyDockerAccessIssue", () => {
it("classifies socket permission failures as PermissionDenied", () => {
const issue = classifyDockerAccessIssue(
'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/info": dial unix /var/run/docker.sock: connect: permission denied'
)

expect(issue).toBe("PermissionDenied")
})

it("classifies non-permission docker access failures as DaemonUnavailable", () => {
const issue = classifyDockerAccessIssue(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"
)

expect(issue).toBe("DaemonUnavailable")
})
})
16 changes: 15 additions & 1 deletion packages/lib/tests/usecases/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest"

import { DockerCommandError } from "../../src/shell/errors.js"
import { DockerAccessError, DockerCommandError } from "../../src/shell/errors.js"
import { renderError } from "../../src/usecases/errors.js"

describe("renderError", () => {
Expand All @@ -10,4 +10,18 @@ describe("renderError", () => {
expect(message).toContain("docker compose failed with exit code 1")
expect(message).toContain("/var/run/docker.sock")
})

it("renders actionable recovery for DockerAccessError", () => {
const message = renderError(
new DockerAccessError({
issue: "PermissionDenied",
details:
'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock'
})
)

expect(message).toContain("permission denied")
expect(message).toContain("DOCKER_HOST")
expect(message).toContain("Details:")
})
})