diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index b6e15505..1f9158cb 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -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), diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index d50efe9f..1cd9582a 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -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) => ({ cwd, @@ -27,6 +29,79 @@ const parseInspectNetworkEntry = (line: string): ReadonlyArray): 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 +// 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 +// INVARIANT: non-zero docker info exit always maps to DockerAccessError +// COMPLEXITY: O(command) +export const ensureDockerDaemonAccess = ( + cwd: string +): Effect.Effect => + 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, diff --git a/packages/lib/src/shell/errors.ts b/packages/lib/src/shell/errors.ts index 13a8aa22..c47c46f2 100644 --- a/packages/lib/src/shell/errors.ts +++ b/packages/lib/src/shell/errors.ts @@ -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 diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 214fc2a4..07574a0d 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -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" @@ -21,6 +28,7 @@ type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor. type CreateProjectError = | FileExistsError | CloneFailedError + | DockerAccessError | DockerCommandError | PortProbeError | PlatformError @@ -76,6 +84,10 @@ const runCreateProject = ( command: CreateCommand ): Effect.Effect => Effect.gen(function*(_) { + if (command.runUp) { + yield* _(ensureDockerDaemonAccess(process.cwd())) + } + const ctx = makeCreateContext(path, process.cwd()) const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir)) diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 46f57d73..ab8c799e 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -7,6 +7,7 @@ import type { CommandFailedError, ConfigDecodeError, ConfigNotFoundError, + DockerAccessError, DockerCommandError, FileExistsError, InputCancelledError, @@ -18,6 +19,7 @@ export type AppError = | ParseError | FileExistsError | CloneFailedError + | DockerAccessError | DockerCommandError | ConfigNotFoundError | ConfigDecodeError @@ -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)` @@ -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}` } diff --git a/packages/lib/tests/shell/docker-access.test.ts b/packages/lib/tests/shell/docker-access.test.ts new file mode 100644 index 00000000..263ae6a8 --- /dev/null +++ b/packages/lib/tests/shell/docker-access.test.ts @@ -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") + }) +}) diff --git a/packages/lib/tests/usecases/errors.test.ts b/packages/lib/tests/usecases/errors.test.ts index 3c214264..05e91812 100644 --- a/packages/lib/tests/usecases/errors.test.ts +++ b/packages/lib/tests/usecases/errors.test.ts @@ -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", () => { @@ -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:") + }) })