This CLI is validated for behavioral parity with the reference TypeScript
@devcontainers/cli (pinned at v0.89.0, see parity/). Where it
deliberately differs, the difference is recorded here — this is the durable record of
intentional departures from the oracle, not a backlog. User-facing additions are
documented in go-only-features.md.
These are intentional behavior differences; each is covered by tests and, where it touches a compared surface, reflected in the parity matrix.
- Go-only commands / flags (full reference in
go-only-features.md):checkandsetup(host preflight/remediation),up --cache-image(boot from a prebuilt image, skip build + feature install),read-configuration --cache-key(deterministic content hash; additive — default output is byte-identical to TS),build --secrets-file(BuildKit build secrets; TSbuildhas no such flag),open(launch VS Code attached to the workspace's dev container via a vscode-remote:// URI),stop/down(pause, or stop+remove, a workspace's container — Compose-aware; the upstream CLI leaves teardown to the editor), and the automatic credential bridge that hands the CLI's resolved auth todocker build. - A final
FROMline continued with a backslash keeps its stage name. TSensureDockerfileHasFinalStageNamereads only the physical line, so forFROM \followed byalpine AS baseit writesFROM \ AS dev, a Dockerfile no builder accepts. This CLI asks BuildKit for that stage's name and leaves the Dockerfile as it is (TestEnsureFinalStageNameReadsAContinuedFrom,internal/docker/testdata/ts-oracle/dockerfile.json). Every other FROM line is handled exactly as TS handles it. - Compose builds may read the Features Dockerfile outside the build context. Compose
builds through bake, and bake on buildx 0.38 and later refuses to read a file outside the
context unless the caller passes
--allow=fs.read, whichdocker compose buildcannot. The reference writes the Features Dockerfile under its cache folder and fails there with "additional privileges requested". This CLI starts itsdocker composeprocesses withBUILDX_BAKE_ENTITLEMENTS_FS=0unless the variable is already set (TestWithBakeFSEntitlement,TestE2E_ComposeBuildLabel). - A devcontainer.json that is not valid JSONC fails to load. TS parses the configuration
with jsonc-parser, whose error recovery keeps whatever it could read and reports nothing:
{badloads as{}, a missing comma or trailing garbage is skipped, and{"image": , "remoteUser": "u"}silently losesimage. This CLI stops with the parse error, naming the file, line and column (parse config <file>: hujson: line 1, column 2: ...), because running a configuration with properties silently dropped is worse than refusing it. Valid JSONC (comments, trailing commas) loads as in TS (TestLoadRejectsAConfigThatIsNotValidJSONC). --override-configdeep-merges the override onto the base config, whereas TS replaces the config wholesale (readDocument(overrideConfigFile ?? configFile)). With no readable base, the override stands alone — identical to TS. This lets an orchestrator pass a partial override. Only matrix case for the flag is the error path, so contract + semantic stay green.--terminal-log-filetees the same combined stream as--log-file. TS produces two files (a terminal stream with ANSI and a plain one); this CLI keeps a single log stream (no self-managed PTY — see decisions), so both flags capture the same output (without ANSI). Never a black hole.config.build.cacheFromis honored (wired to--cache-fromafter the flag's values) — matchingsingleContainer.ts. Upstream defines the field; this is a parity fix, noted here because it was previously a dead field.- OCI auth hardening is enforced through
oras-go, which is stricter by default.--oci-auth-hardeningand--allow-cross-origin-auth-hostbehave as documented upstream (bearer realms pinned to the registry authority or a trusted auth host, token endpoints may not redirect), andociAuthDiagnosticsreports the same three flags inup/build/read-configuration. The difference is what happens without the flag: the reference CLI still forwards registry credentials to a challenge that arrives from another origin, whileoras-gonever does (GHSA-vh4v-2xq2-g5cg). Hardening off is therefore already safe here; the diagnostics still report what hardening would change, so the flag remains a faithful compatibility probe. BUILDKIT_INLINE_CACHE=1is omitted when--cache-tois an inline exporter (/type\s*=\s*inline/i), matching TSisBuildxCacheToInline— a parity fix over the earlier unconditional build-arg.- Two upstream fixes newer than the pinned reference are already ported. A
${localEnv:VAR:default}or${containerEnv:VAR:default}default keeps its colons (my.registry.io:5000/img; upstream #1213), where v0.89.0 keeps the text up to the next colon. A read-only mount keeps:roin the Compose override (upstream #881), where v0.89.0'sconvertMountToVolumedropsreadonly. Thetestdata/ts-oraclegoldens record both as divergence cases. - Dockerfile instructions are read with BuildKit's parser, not with the reference's
regular expressions, when resolving the base image and the
USERof a Dockerfile. BuildKit unquotes'...'values, reads everyNAME=valueof oneARG/ENVline and takesENV A = BasA== B, as Docker does; the reference keeps the single quotes, reads only the first pair and takesB. Variable expansion, stage lookup and the final stage name otherwise follow the reference (recorded ininternal/{docker,cli}/testdata/ts-oracle/dockerfile.json). - An unknown
${templateOption:NAME}is always replaced with the empty string. TSreplaceTemplatedValuesreadsoptions[NAME]from a plain object, so a name that is not an option but anObject.prototypeproperty (constructor,toString, …) becomes the inherited function's source text (function Object() { [native code] }) in the applied template. This CLI treats such a name like every other unknown option (internal/templates/testdata/ts-oracle/templates.json). - A
devcontainer-lock.jsonthat parses but is not shaped like a lockfile is an error. The reference keeps whateverJSON.parsereturns and looks each Feature up withlockfile?.features[id]?.…: an array, a number or a string throws aTypeErroronce a Feature is looked up, and afeaturesvalue that is not an object pins nothing. This CLI refuses such a file when it reads it (parse lockfile: …). JSON that does not parse, or a file that cannot be read, fails with the reference's own message, andnull,false,0and""are no lockfile, as in TS (internal/features/testdata/ts-oracle/lockfile.json). - The
devcontainer.metadatalabel writes object values with their keys sorted. The label is TS'sJSON.stringifyof the entries: each entry's properties in TS's pick order (pickFeaturePropertiesfor a Feature,pickConfigPropertiesfor the configuration) and&,<,>unescaped, as in TS. Within a property's object value (a mount object,customizations,portsAttributes, …) the keys are sorted, because a decoded Go map keeps no order, where TS keeps the order they were written in; an entry given in another order (a base image's label written by another tool) is likewise rewritten in pick order. The parsed metadata is the same; only the label's bytes differ (internal/imagemeta/testdata/ts-oracle/imagemeta.jsonrecords both cases). - Command results write nested objects with their keys sorted. TS writes each result
with
JSON.stringify, so properties come in insertion order. Go matches that order for the results ofup,build,set-up,run-user-commandsandread-configuration, forfeaturesConfiguration, for the top level ofconfiguration(the document's order, withcustomizationsmoved from deprecated properties and thenconfigFilePathlast) and for the top level ofmergedConfiguration(the configuration withoutmergeConfiguration'sreplaceProperties, then the properties it sets). Objects nested deeper are written with sorted keys, because a decoded Go map keeps no order: a configuration'scontainerEnvorcustomizations, merged objects such asremoteEnvorportsAttributes, and each Feature infeaturesConfiguration.featureSets, which TS writes in itsdevcontainer-feature.jsonorder. The parsed values are the same; the parity matrix'skey_ordercases hold the orders that match.
- Platform: Linux only (amd64 / arm64). Windows and macOS are not targets — no
runtime/E2E/release/
windows-latest/ConPTY lane. Theplatform="win32"logic is kept solely for parity with the oracle. (arm64 runtime is validated via a non-gating, QEMU-emulated experimental job.) - Runtime: Docker only. Podman is not supported (no parity guarantee or test).
- Compose: v2 only (
docker compose). Compose v1 (docker-compose) is not supported. exec: inherited terminal (docker exec -itinherits the controlling terminal), no self-managed PTY. The 128+N contract comes from the child process. Interactivedocker execis deliberately kept as a shell-out.- Docker Go SDK:
github.com/moby/moby/{client,api}(the v29 "options-in, result-out" surface), replacing the deprecatedgithub.com/docker/docker. The top-levelgithub.com/moby/moby(v2) module is an internal implementation detail and is deliberately not a dependency. Requires Docker Engine API ≥ v1.44 (Docker v25+). - OCI image:
ghcr.io/spin-stack/devcontainer-cli(source repogithub.com/spin-stack/devcontainer-cli), distroless/static, non-root. Contract: it is a distribution/self-contained-operations artifact, not a batteries-included runner. It carries only the static/devcontainerbinary (plus CA certs), so it serves two uses: (1) extracting the binary onto a host, and (2) running the commands that work fully in-process —read-configuration,features/templatesauthoring, OCI push/pull,--version. The commands that shell out (up/build/exec/Compose) need thedocker/buildx/docker composebinaries and a reachable daemon, which the distroless image deliberately does not bundle; run those on a host that has Docker, ordocker cpthe binary out. The release smoke test (--version) validates the distribution use, not the docker-dependent commands (which cannot run from this image by design). - Self-containment stance. Container/engine operations, Docker-context resolution and
git-root detection run in-process (Go libraries / stdlib), and lifecycle hooks run via
the Docker exec API rather than
docker exec. The following are kept as shell-outs on purpose:docker buildx build(buildx feature breadth + the user's builder/context; a library would regress buildx or pull the heavy buildkit client),docker compose(its output is not in the compared stream, so a library adds large deps for zero parity gain), interactivedocker exec -it, and the credential-helper protocol (external executables by design). These shell-outs receive the command'scontext.Context(viaexec.CommandContext), so a deadline/cancel — orCtrl-C— aborts an in-flightbuild/composesubprocess rather than orphaning it.
Known gaps that are deliberately not closed; each is a conscious trade-off, not an oversight.
-
Which of several failing parallel lifecycle commands is named. When more than one command of an object-form hook (
"postCreateCommand": {"a": ..., "b": ...}) fails, TS names the first failure in the object's key order (Promise.allSettled+find); the command maps decode without key order here, so the first failure in sorted order is named. With one failing command — the common case — the description (b of postCreateCommand from devcontainer.json failed.) is identical. Keeping key order would mean an ordered representation for every lifecycle command through the config, metadata and merge;TestHookErrorDescribesTheCommandAndItsOriginpins the current choice. -
A lone surrogate in a string is written as U+FFFD. Command results are written as
JSON.stringifywrites them:&,<,>, U+2028 and U+2029 as they are, not escaped asencoding/jsonwould (read-configuration.json-stringify-escapes-success). A configuration string holding a lone UTF-16 surrogate escape ("\ud800") is the exception: a Go string cannot carry one, so the JSON decoder replaces it with U+FFFD when the file is read, and the result holds that character where TS writes the escape back. -
Byte-for-byte tarball parity is unattainable because of
mtimedifferences; tarball contents are compared by parsed structure, not raw bytes. -
Cloud registry auth matrix is out of default CI scope. The hermetic auth paths (401→bearer, credential-helper protocol,
DEVCONTAINERS_OCI_AUTH,GITHUB_TOKEN) are unit-tested; a real ACR (identity/refresh) / ECR / authenticated-GHCR matrix is secrets-gated and non-blocking. Credential helpers are Linux-only (secretservice/pass). -
TS→Go metadata interop is exercised by
TestMetadataInteropTSToGo, which builds the same configuration with the oracle and with this CLI and compares the mergeddevcontainer.metadatalabels; it runs when the oracle is compiled (task reference) and Docker is available. The Go→Go round-trip and whitespace invariance are unit-tested. -
Version banner differs cosmetically: this CLI reports a git hash / CalVer, TS a semver, and the banner box width depends on the version length. Verbose commands (features-test / features-info) are compared via
exit_code/ stderr rather than the banner. -
Legacy Feature fallback via GitHub Releases is not implemented; feature resolution is OCI-first (the supported path for v2 features). A configuration that references one fails with the resolution error (read-configuration included) instead of silently omitting the Feature.