This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
devstack is a single static Go binary (CLI) that manages Docker-based dev
environments and shares infrastructure across projects — one warm
Postgres/Redis/MinIO on a tool-owned network for many repos, instead of a
duplicate stack per repo. The Go module and GitHub repo are
github.com/open-source-cloud/devstack (local checkout folder devstack); the binary built from ./cmd/devstack
is devstack. The same binary is invocable under aliases (rq, uranus) via
argv[0] dispatch.
The project is spec-driven: docs/ (ARCHITECTURE, DECISIONS, ROADMAP,
FEATURES, OPEN-QUESTIONS, and docs/specs/01..14) is the source of truth. When
behavior is ambiguous, those docs win — read them before designing. Implementation
is at the M0 (foundations) stage; see "Current status" below.
make build # CGO_ENABLED=0 static binary → ./dist/devstack (version-stamped)
make run ARGS="doctor" # build + run with ldflags
make test # unit tests
make test-race # tests with the race detector (forces CGO_ENABLED=1)
make test-one RUN=TestSerializesAcquire PKG=./internal/lock # single test
make ci # fmt-check + vet + build + test-race (what CI runs)
make fmt # gofmt -w .
make vuln # govulncheck ./...
make help # list targetsPlain go works too, but mind the CGO split: the release binary must be
CGO_ENABLED=0 (pure-Go static), yet go test -race requires
CGO_ENABLED=1. The Makefile sets CGO per-target; don't set it globally.
CGO_ENABLED=0, single static binary, cross-compiled todarwin/{amd64,arm64}+linux/{amd64,arm64}from one Linux runner. Every dep must be pure-Go or behind a build tag / external binary. WSL2 uses the ordinarylinuxbuild (not a separate target) but is a distinct runtime-behavior target.- Go 1.25 toolchain floor (max required by fang + validator/v10). Enforced in CI.
- Every fast-moving / risky dependency sits behind an
internal/interface so it can be swapped or vendored (docker client, template engine, secrets, git, trust).
Stateless CLI, no daemon (v1). Every invocation: load config + the global
ledger → take a coarse cross-process lock before mutating shared state → drive
Docker (the docker compose CLI for lifecycle; the Engine SDK read-only) →
exit. Consequence: no autostop; ref-count drift is reconciled lazily on the next
command / shared gc / doctor --fix.
State lives in exactly two places (ARCHITECTURE §2):
- In the repo, committed:
workspace.yaml(workspace root) + each repo'sdevstack.yaml— the declarative source of truth. - Machine-global under XDG, not committed: a SQLite ledger keyed by Docker
context (so WSL2's two daemons — Desktop vs in-distro
dockerd— never share counts), plus the alias registry, template cache, and per-project generated artifacts under.devstack/.
One generation pipeline, single owner (ARCHITECTURE §3): discover workspace
(walk up for workspace.yaml) → parse+validate config → resolve template
extends (render then deep-merge) → render text artifacts + build the compose
model programmatically → resolve ${ref} against the workspace graph (not
one project — this is what makes sharing work) → resolve secret:// (batched, never
written to disk) → validate via compose-go/v2 → writeIfChanged (atomic, SHA-256
build contexts) → selective rebuild + up -d. Determinism is a hard requirement
(byte-identical output; CI asserts it).
Network topology (ARCHITECTURE §4): one tool-owned external bridge network
devstack_shared (pinned name) that both the shared stack and every project stack
join. Compose refuses to create/remove external: true networks, so devstack
owns create + cleanup. Shared services are reached by stable DNS aliases
(shared-postgres, …), never bare service name (collision guardrail). Default is
no published host ports (DNS over the shared network); ports are allocated from
the ledger, inside the lock, only on explicit host-reachability requests.
Module map (internal/*, ARCHITECTURE §6 is the full table): cli (cobra tree
- global flag/error/log contract),
config,template,merge,generate,docker(SDK read-only + compose CLI driver),workspace(the differentiator: shared-stack lifecycle + ref-counting),state(SQLite ledger),lock(flock spine),provision(pgx),git/gitx,secrets,proxy,trust,dns,tunnel,doctor,hooks,health,orchestrate(the up saga),xdg,migrate. Public plugin contractpkg/pluginsdkis deferred to v2.
Concurrent invocations (two terminals, IDE + terminal, a watch script) are the
top architectural risk. Every operation that mutates the ledger or the shared
stack MUST go through internal/lock (coarse gofrs/flock advisory lock) — from
the first commit, never retrofitted. SQLite alone is not safe under concurrent
writers (especially on WSL2/9p); WAL + busy_timeout=5s + the flock are required
together. Reads are lock-free snapshots. Port allocation, network-ensure, ref-count
rows, and CREATE ROLE all happen inside the lock.
DECISIONS.md records these; they are the difference between "compiles in a tutorial" and "compiles today". The ones most likely to bite:
- Docker SDK is
github.com/moby/moby/client, NOTdocker/docker/client(the latter is deprecated as of Docker v29 and fails as a hard module-path error). Inmoby/moby/clientv0.4+, useclient.New(...)and pass option structs (Ping(ctx, PingOptions{}),ServerVersion(ctx, ServerVersionOptions{})). - fang import path is
charm.land/fang/v2(vanity domain), notgithub.com/charmbracelet/fang. fang is a thin, removable wrapper over cobra. - YAML:
goccy/go-yaml(forfile:line:colerrors) is NOT byte-compatible withyaml.v3(emits1.0for1; randomizes map order — use ordered structs).gopkg.in/yaml.v3is archived. - Config layering merge REPLACES lists by default (koanf), whereas the Python
predecessor appended — supply a custom append merge or document
$merge: append. - koanf, not viper (viper force-lowercases keys → breaks Docker env names and
${ref}keys). koanf is not goroutine-safe: load once, treat immutable; env keys arrive lowercased (needs an env-transform on merge). - Compose is built as a typed model +
compose-go/v2, never string-templated. Filter container enumeration on your own label (not justcom.docker.compose.project), setAll=true, excludeoneoff=true. - Postgres 18+ moved PGDATA to
/var/lib/postgresql(was.../data) — mounting the old path silently loses persistence. Provisioning usespgx/v5with existence-guarded SQL (CREATE DATABASEis not idempotent); neverinitdb.d. - Secrets ↔ generation are coupled: a host env var reaches a container only if
the compose file references it; emit each secret name per service as a
valueless
environment: [NAME]key and pass the value viaexec.Cmd.Env. A CI test must assert no secret value lands in any generated file. *.localhostis not uniformly zero-config; marker-fenced/etc/hostsedits are the only consistently reliable path. Trust uses the externalmkcertbinary, notsmallstep/truststore.- git: shell out to system git (≥2.30) behind
internal/git(gitx) to inherit the user's SSH/credential setup;go-gitis a build-tagged read-only fallback only.
Full dependency-risk register is at the bottom of DECISIONS.md — pin everything, wrap risky ones.
M0 foundations and the full M1 pipeline (config + templating + generation) are
implemented and green (make ci + make determinism):
cmd/devstack— thin entry:argv[0]alias dispatch +--asoverride + fang.internal/cli— cobra tree wrapped by fang; global--json/--quiet/--debug/--verbose; slog logging;doctor(real preflight matrix +--json),config validate/show,generate(the M1 pipeline entry point, with--project/--profile/--check),template list/lint/test/init,alias add/remove/list,versionare functional. The remaining spec-07 surface (up,down,status,ws,shared,secrets,trust,dns,tunnel,import,self,workspace) are milestone-tagged stubs ininternal/cli/stubs.go.internal/config— M1 two-file loader (spec 01): goccy parse with positions, validator/v10 + custom cross-ref/cycle resolver, the${env/self/ref/profile}grammar, immutable model. ExposesParseReffor the generator's${ref}resolver.internal/template— M1 text engine (spec 02): stdlibtext/templatewith custom[[ ]]delimiters + a deterministic in-tree FuncMap,RenderText/RenderYAML, theTemplateSourceinterface (embedded built-ins; git/OCI deferred to spec 19), andextends-chain render-then-merge.internal/merge— M1 deep-merge (spec 02): recursive map merge, lists replace by default with opt-in$merge: append, deep-clones to avoid shared-ref mutation.internal/generate— M1 generation owner (spec 02, ARCHITECTURE §3): builds the compose model programmatically, validates/normalizes via compose-go/v2, resolves${ref}/importagainst the workspace graph, emits per-service valueless secret env keys (§7.5 coupling),writeIfChanged(atomic) + the SHA-256 rebuild-hash ledger (.devstack/state.json). Deterministic output is golden- and CI-asserted.templates/— embedded built-ins viago:embed:postgres/redis/minio(shared engines) +php.nginx→php.laravel.nginx(extends) +node.vite.internal/lock— the flock spine (with a concurrency test).internal/state— modernc SQLite ledger, WAL/busy_timeout/foreign_keys, versioned migrations + backup, the spec-08 tables, keyed by Docker context. M2 CRUD layer (ledger.go): ref-counting, port allocation, the provisioning ownership ledger, shared-service rows + reconcile/orphan queries — all under the flock.internal/xdg— XDG paths, WSL2 detection,/mnt/*refusal, 9p/networked-FS detection.internal/docker— read-onlyClient(+MockClient), moby-backed impl, compose≥2.20 / git≥2.30 preflight. M2:EnsureNetwork/NetworkExists/ListManaged(label-filtered, All=true, one-offs excluded) + theComposeCLI driver (up/down/stop/build via an injectableRunner;CmdErrorcarries cmd+code+stderr).internal/provision(M2) — idempotent per-project Postgres role/db via guarded SQL behind a testableConninterface; pgx/v5-backed impl + DSN builder (D8).internal/workspace(M2, the differentiator) — resolves shared instances per(engine,major), ref-counting register/unregister, self-healing reconcile from live containers,shared statusprojection, and host-port allocation (ledger ∪ bind-test ∪ Docker-published union). Unit+race tested with the mock client.internal/git(M3, gitx) — hardened system-git wrapper (D9): clone/fetch/ pull/status via--porcelain=v2 -z, shorthand URL expansion, GIT_ASKPASS token shim. CLIws clone/sync/status/git— bounded-parallel (errgroup), plain/JSON dual-mode,--check. Integration-tested against real temp repos.internal/selfupdate+ CLIself check/update— GitHub-API release resolution (token-capable), SHA-256-verified, install-method-aware atomic in-place replace.internal/store+ CLIstore init/path/show— the global~/.devstackhome ($DEVSTACK_HOME):config.yaml(global shared services),templates/(custom templates that override built-ins by name, chained ahead of the embedded set in the generate/template source), andshared/.internal/alias— registry + symlink installer;internal/version— ldflags target.- CI (
.github/workflows/: ci + release + installer + release-dryrun + determinism),.goreleaser.yaml,Makefile,install.sh(curl|sh installer). Tagged v0.1.0.
Build order follows the ROADMAP: M0 spine ✅ → M1 config+templating+generation ✅
→ M2 shared services + workspace lifecycle — core landed (ledger CRUD, docker
network/compose driver, provisioning, ref-counting/reconcile, shared status);
remaining: the up/down saga + shared gc (daemon orchestration — needs the
dind/integration lane to build responsibly), plus one design call: provisioning
runs pgx from the host, so the shared Postgres needs a ledger-allocated published
host port (default is no host ports) — wire that into generation + the up saga.
→ M3 multi-repo git ✅ (internal/git + ws clone/sync/status/git) → secrets
(M4) / networking (M5) / onboarding+doctor+health+hooks (M6) / GA (M7).
Anything that mutates shared state goes through internal/lock from its first commit.
- New external-tool integrations go behind an
internal/interface with a mock (seeinternal/docker), so race/unit tests run without a real daemon. - Errors that wrap an external command should carry the command + exit code +
stderr and map known signatures to one-line remediations (
doctoris the model; ARCHITECTURE §7.6). Config errors render asfile:line:col. - Headline outputs need a non-TTY
--json/--quietcontract (ARCHITECTURE §7.9);doctor --jsonis the existing example. - Migrations in
internal/stateare append-only and forward-only; never edit a released one.
@AGENTS.md
The devstack section of AGENTS.md above is generated by devstack ai install.
Run devstack ai docs to read devstack's full documentation from the binary.