@magnaboy/cli-docker
v0.0.2
Published
Docker daemon checks, compose projects and container health for Node.js CLIs.
Readme
@magnaboy/cli-docker
Docker from a script: a daemon check that says what is actually wrong, one compose project instead of a project name repeated on every call, and a container assessment that knows the difference between "still starting" and "never going to start".
Install
npm i @magnaboy/cli-dockerRequires Node 25+ and ESM. Every call takes a ProcessRunner from @magnaboy/cli-core, so a test
can assert the commands a script would run without running any.
import { ComposeProject, requireDockerDaemon, withCompose } from '@magnaboy/cli-docker';Check the daemon first
const { executable, serverVersion } = await requireDockerDaemon(runner, {
hint: 'Start Docker Desktop, then run pnpm dev again.'
});docker info --format {{.ServerVersion}} is the cheapest call that fails when the daemon is not
reachable. The failure keeps the daemon's own stderr: "permission denied while trying to connect"
and "Cannot connect to the Docker daemon" need completely different fixes, and only docker can tell
them apart. Replacing that with a generic "Docker is unavailable" is what turns a thirty-second
problem into a twenty-minute one.
findDocker and readDaemonState are the non-throwing halves, for a script that wants to degrade
rather than stop.
One compose project
const project = new ComposeProject({
runner,
executable,
cwd: repository.root,
projectName: 'cx-e2e',
files: ['compose.yaml'],
profiles: ['e2e']
});
await project.up({ services: ['dev'], wait: true, waitTimeoutSeconds: 240 });
await project.exec('dev', ['pnpm', 'test']);
await project.down({ volumes: true, removeOrphans: true });The project name, files and profiles are stated once and repeated on every subcommand. Getting them
inconsistent between up and down is exactly what leaves orphan containers holding ports after a
failed run.
Details it gets right so callers do not have to:
up({ wait: true })implies-d, because--waitwithout it hangs.execpasses-Twhen there is no TTY, which compose otherwise refuses, and{ interactive: true }attaches the real stdin so a shell does not read EOF and exit at once.runOncedefaults to--rm --no-deps, the one-shot form.downtolerates its own failure by default, so a teardown error never masks the real one.
spec() returns the CommandSpec a call would run without running it, which is what makes the
whole surface testable.
Up, body, always down
const exitCode = await withCompose(project, async () => runSuite(), {
services: ['e2e'],
build: true,
abortOnContainerExit: true,
exitCodeFrom: 'e2e',
logServices: ['e2e-app', 'e2e-postgres']
});Brings the project up, runs the body, and tears it down on every path. When the body fails it dumps
recent logs before tearing down: without that, the only evidence of why a container never became
healthy disappears with the container, which is the most common way a CI compose failure becomes
unreproducible. Pass teardown: false to keep a failed stack up for inspection.
Is it actually ready?
import { assessContainers, describeContainers, parseComposePs } from '@magnaboy/cli-docker/health';
const statuses = parseComposePs((await project.ps({ output: 'capture' })).stdout);
const report = assessContainers(statuses, ['dev', 'postgres']);
if (report.failed.length > 0) throw new Error(`gave up:\n${describeContainers(report.failed)}`);parseComposePs reads both the JSON array and the newline-delimited object forms, because compose
has emitted each depending on version.
assessContainers splits a snapshot three ways. A container that is unhealthy, or exited
non-zero, is failed: waiting longer cannot help it. One that is starting, restarting, or has
not appeared yet is pending. A wait loop that does not make this distinction spends its entire
timeout on a service that died in the first second.
