/** * Concrete `DockerCli` class — wraps `docker` and `docker buildx` subprocess * calls behind a typed interface. The class itself is thin: it carries shared * state (logger, env, dockerBin, abort signal) and delegates each method to a * pure-ish free function in a sibling file (`DockerCli.build.ts`, * `DockerCli.registry.ts`, `DockerCli.daemon.ts`). * * Splitting was mandated by the plan's AC12 (each file < ~300 lines, one * cohesion zone per file: build, registry, daemon). * * Subprocess discipline (every spawn call): * - `shell: false` * - `env` is `filterDangerousEnvVars(process.env)` plus any caller overrides * - `onProgress` events are masked via `maskSensitiveOutput()` once at the * boundary BEFORE the consumer callback fires * - abort routed through `abortChildProcess()` from `./abortHelpers.js` * - cleanup-side awaits in `finally` are wrapped with `.catch(...)` so * cleanup failures cannot replace the operation result */ import { type ChildProcess } from "node:child_process"; import type { BuildxBuildArgs, BuildxBuildResult, DockerCliError, DockerCliErrorKind } from "./dockerCliSchemas.js"; import type { EcrAuthSession } from "./ecrCredentialStore.js"; import { failure, success, type Result } from "./result.js"; export interface DockerCliLogger { debug(category: string, message: string, data?: Record): void; info(category: string, message: string, data?: Record): void; warn(category: string, message: string, data?: Record): void; error(category: string, message: string, data?: Record): void; } export interface DockerCliOptions { readonly logger: DockerCliLogger; readonly dockerBin?: string; readonly env?: NodeJS.ProcessEnv; readonly abortSignal?: AbortSignal; } export interface DockerCliState { readonly logger: DockerCliLogger; readonly dockerBin: string; readonly env: NodeJS.ProcessEnv; readonly abortSignal: AbortSignal | undefined; /** * Ephemeral ECR credential context. `loginEcr` establishes it (an isolated * `DOCKER_CONFIG` carrying the ECR token inline, never the OS keychain); * `logoutEcr` tears it down. `spawnEnv` merges `session.env` over `env` on * every docker subprocess so the credentials reach buildx push / docker push * without touching the keychain. Mutable by design — the login→build→push * flow is sequential on a given DockerCli instance. */ ecrSession: EcrAuthSession | undefined; } export interface BuildxProgressEvent { readonly type: "vertex" | "log" | "status" | "warning"; readonly message: string; readonly vertex?: string; /** Aggregate publish-phase completion (0–100); only on the synthetic * push-progress events emitted by the buildx monitor. */ readonly percentage?: number; } export interface PushProgressEvent { readonly id: string; readonly status: string; readonly current?: number; readonly total?: number; } export interface PushResult { readonly digest: string; } export interface PullProgressEvent { readonly id: string; readonly status: string; readonly current?: number; readonly total?: number; } export interface PullResult { readonly imageId: string; } export interface ImagetoolsInspect { readonly raw: string; readonly mediaType?: string; readonly digest?: string; } export interface EcrLoginArgs { readonly registry: string; readonly username: string; readonly password: string; } export interface BuildxCapabilities { readonly version: string; } export interface DaemonInfo { readonly serverName: string; readonly serverVersion: string; } export declare class DockerCli { private readonly state; constructor(opts: DockerCliOptions); buildxBuild(args: BuildxBuildArgs, onProgress: (event: BuildxProgressEvent) => void, abortSignal?: AbortSignal): Promise>; tag(source: string, target: string): Promise>; /** * Create one or more tags pointing at a previously pushed digest without * re-uploading layers. Uses `docker buildx imagetools create * --prefer-index=false` (a flat carbon-copy, not an image index — see * `_tagByDigest`) against `@`. */ tagByDigest(sourceImage: string, digest: string, tags: readonly string[]): Promise>; push(image: string, onProgress?: (event: PushProgressEvent) => void): Promise>; pull(image: string, platform?: string, onProgress?: (event: PullProgressEvent) => void): Promise>; imageInspect(image: string): Promise>; imagetoolsInspect(image: string): Promise>; loginEcr(args: EcrLoginArgs): Promise>; /** * Tear down the ephemeral ECR credential context established by `loginEcr` * (removes the temp `DOCKER_CONFIG` dir). Callers MUST invoke this in a * `finally` after a build/push so the deploy-scoped token file does not leak. * Best-effort: never throws. */ logoutEcr(): Promise; /** * Run `fn` inside a self-scoped ephemeral ECR session: `loginEcr(auth)` → * `fn()` → `logoutEcr()` in a `finally`. The single home for the * login→op→teardown ceremony that every self-scoped registry op * (`buildAndPush`, `tagByDigest`, in BOTH the CLI and worker providers) would * otherwise hand-copy. Flattens so callers get `Result` rather than * `Result>`: a login failure short-circuits through `mapLoginError` * (which preserves each provider's masked failure prefix — the login is no * longer inside the provider, so the provider cannot format the error itself); * otherwise the caller's own Result flows through untouched. Teardown is * best-effort — a `logoutEcr` throw cannot replace the operation result. * * Providers that bracket a WHOLE docker phase instead call `loginEcr` once at * `beginEcrSession` and `logoutEcr` once at `endEcrSession`; this helper is * the per-op fallback for standalone / unbracketed calls. */ withEcrSession(auth: EcrLoginArgs, fn: () => Promise>, mapLoginError: (error: DockerCliError) => E): Promise>; ensureBuilder(name: string): Promise>; assertBuildxAvailable(): Promise>; detectDaemon(): Promise>; } export interface SpawnDockerOptions { readonly args: readonly string[]; readonly stdin?: string; readonly timeoutMs?: number; /** Extra caller-owned signal (e.g. a phase watchdog), composed with the * instance signal and `timeoutMs`. */ readonly abortSignal?: AbortSignal; } export interface SpawnDockerResult { readonly exitCode: number | null; readonly stdout: string; readonly stderr: string; readonly stderrTail: readonly string[]; readonly aborted: boolean; /** * True when the per-call `timeoutMs` budget is what fired the abort. A * caller cancel and a budget expiry compose into ONE signal, so `aborted` * alone cannot tell them apart and every timeout would report as a cancel. */ readonly timedOut: boolean; readonly spawnError?: string; } export interface StreamDockerResult { readonly exitCode: number | null; readonly stderrTail: readonly string[]; readonly aborted: boolean; readonly timedOut: boolean; readonly spawnError?: string; readonly child: ChildProcess | null; } export declare function makeError(kind: DockerCliErrorKind, message: string, details?: Record): DockerCliError; export declare function tailLines(buffer: string, count: number): string[]; export declare function maskOutput(value: string): string; export declare function runDocker(state: DockerCliState, opts: SpawnDockerOptions): Promise; export declare function streamDocker(state: DockerCliState, opts: SpawnDockerOptions, onStdoutLine: (line: string) => void, onStderrLine?: (line: string) => void): Promise; export { failure, success }; export type { Result };