hack.foo

Architecture

Hack is a local CLI that turns a small committed .hack/ folder into a running project: services, environment, secrets, trusted HTTPS, logs and host-side helpers. Everything runs on the developer's machine. There is no hosted component.

Two kinds of state exist. Global state under ~/.hack/ (overridable with HACK_HOME) holds the shared infrastructure every project uses: the proxy, DNS, the logging stack, the project registry and the optional daemon. Project state under <repo>/.hack/ holds that project's definition and is committed, apart from machine-local generated files that Hack keeps ignored.

System overview

Hack on one developer machine Hack on one developer machine

  • Caddy terminates TLS with a locally trusted CA and routes https://<service>.<project>.hack.local to containers by their Docker labels. Existing .hack names, .hack.gy OAuth aliases and custom domains route the same way.
  • CoreDNS gives containers the same *.hack.local names as the host, so a service can call another service by its public local URL.
  • Alloy, Loki and Grafana collect container logs and keep searchable history.
  • hackd is an optional daemon that caches Docker state for fast --json status. The CLI works without it.
  • Schemas for the project config files are served by Caddy at https://schemas.hack.local for editor validation.

Backends

The CLI reads the same .hack/ project on either backend. Docker Compose is the default and runs everywhere Hack runs. The native backend is opt-in and experimental: on Apple Silicon macOS it runs an explicitly prepared project inside a provider-managed Linux VM pool without Docker, with its own routing and a bounded command subset.

One project, two backends One project, two backends

The native backend selects a verified executor binary and a private native home, plans the project through the config compiler, and runs workloads under a foreground owner process that holds the run until it is stopped. Its supported commands are whole-project up and restart, owner-mediated down, saved-run ps, finite logs, and noninteractive exec and run. See the native backend guide and native authored commands. A Compose flag in these docs does not imply native support.

Global vs project scope

  • Global scope (~/.hack)
    • Caddy proxy on 80/443, routing by Docker labels
    • CoreDNS for container DNS (*.hack.local and legacy *.hack → Caddy)
    • macOS DNS helper: dnsmasq plus /etc/resolver entries pointing at Caddy
    • Logging stack (Alloy → Loki → Grafana)
    • Global config hack.config.json and the project registry projects.json
    • Networks hack-dev (ingress) and hack-logging
  • Project scope (.hack)
    • docker-compose.yml defines services and Caddy labels
    • hack.config.json stores the project name, dev host, log preferences, OAuth alias and browser launch preference
    • hack.env.default.yaml and optional overlays hold committed env, with per-value encryption for secrets
    • hack.branches.json is an optional committed branch registry
    • .internal/ and .branch/ hold generated, machine-local files. Hack rewrites them on every run; keep them out of Git. hack doctor reports tracked copies and hack doctor --fix untracks them.

Project lifecycle

hack up and hack down run more than Compose. Hooks and managed host processes declared in .hack/hack.config.json run around the services, inside a dedicated tmux or zellij session that Hack owns and can tear down. The exact ordering is in Lifecycle.

hack up and hack down hack up and hack down

Lifecycle state is checkout-local at .hack/.internal/lifecycle/state.json, one entry per instance. A random token mirrored in the mux session and in that state proves ownership. Teardown never trusts a session name or a reused process group on its own: hack down stops the saved process groups only while a matching session still proves ownership, and hack doctor --fix removes a stale session only when its instance is absent and a second check agrees. Fixed-port helpers such as database or SSM tunnels can declare a singleton listener set so Hack reuses an equivalent helper instead of starting a competing one; see shared host helper lifecycle.

Environment and secrets

Env lives in committed YAML. Secrets are encrypted per value with a key that stays out of Git. At runtime Hack merges the layers below and injects the result into Compose, host commands and lifecycle processes. Nothing decrypted rests on disk: the generated Compose override holds only ${HACK_ENV_...} references and the values travel through the docker compose process environment.

Env resolution order Env resolution order

Later layers win. The primary-checkout layers apply only in a linked git worktree, which also inherits the primary checkout's secret key through the shared git directory. HACK_ENV_SECRET_KEY covers CI and other detached environments. Environment and secrets covers the file format, delivery matrix, rotation and the external secret-store backends.

Internal DNS and TLS

When internal.dns and internal.tls are enabled, hack up writes a Compose override that points each service at CoreDNS, mounts Caddy's local CA and the combined trust bundle, sets the usual SSL_CERT_FILE-style variables, and adds extra_hosts entries for runtimes that ignore custom DNS. Containers then use the same https://*.hack.local names as the host. If Caddy's IP changes, hack status and hack doctor warn and hack restart refreshes the mapping.

A request from one service to another A request from one service to another

Workflow: init, up, logs

First run of a project First run of a project

Branch instances

--branch <name> generates a per-branch Compose override that prefixes hostnames (api.myapp.hack.local → api.<branch>.myapp.hack.local) and the Compose project name, so parallel checkouts never collide on ports or names. In a linked git worktree, hack up selects a branch instance named after the branch automatically. A detached worktree has no branch to derive a name from, so Hack refuses to target the base instance silently; pass --branch, or set worktree.auto_branch=false.

Checkouts and instances Checkouts and instances

hack doctor checks linked checkouts for divergent secret keys and dev_host collisions. See Worktrees and watchers.

Logging pipeline

hack logs has two backends. compose streams docker compose logs directly and is the default for following output. loki adds searchable history and LogQL filters and is the default for snapshots (--no-follow), falling back to Compose with a one-line notice when Loki has nothing for the selection. hack logs --json emits NDJSON start, log, error and end events for agents and the TUI.

Where a log line goes Where a log line goes

Alloy discovers containers by polling Docker every five seconds and includes exited containers, so a one-shot service such as a migration is shipped after it stops as long as Docker keeps the container. A container removed between two polls never reaches Loki; use hack logs --compose or the command's own output for those. Alloy keeps its read positions in a named volume across hack global down and up; hack global logs-reset clears positions and Loki data together. Loki retention defaults to one week in the global template; per-project logs.retention_period applies when hack down prunes logs.

Daemon (hackd)

hackd is an optional local daemon that watches Docker events and keeps a cached view of running containers behind a Unix socket at ~/.hack/daemon/hackd.sock. It powers hack projects --json and streaming consumers such as the TUI and MCP. hack ps queries Compose directly so failed startups are visible immediately. If the daemon is missing or version-mismatched, the CLI falls back to direct Docker calls.

Cached status Cached status

The daemon treats the container runtime as ephemeral: it fingerprints the engine, detects resets, performs one bounded refresh, and marks cached state stale with runtime_* metadata when the runtime is unavailable. Scripted and noninteractive commands never autostart it. v5 prerelease launchers (hack-next, hack-v5) never autostart it either, because autostart would run the stable daemon on PATH; hack doctor --fix under those launchers skips its hackd repair and names the command to run yourself. On macOS the launchd service belongs to the default ~/.hack root only; an isolated HACK_HOME runs hackd directly.

Native backend internals

The native backend is built around explicit ownership: every run has one owner, every resource it creates is recorded before it can outlive the frontend, and cleanup is confirmed rather than assumed. These rules are checked against finite TLA+ models under tests/models/tla/; the charts below are drawn from those specifications, and the model coverage map records what each model does and does not prove.

Foreground startup owner

A foreground hack up on the native backend serves the run until it is ready or fails. Readiness publishes the run mapping. A failure after resources were created must leave them owned: either cleanup is confirmed, or the frontend's final read-only inspection authenticates the owner receipt and records it.

Owner · native foreground startup Owner · native foreground startup

Source: Owner.tla. A refused inspection is a known coverage gap: nothing can be authenticated, so only the runtime journal retains the graph.

Interrupted restart

hack restart saves a prepared intent bound to the exact old run, asks the old run's owner to stop, and marks the intent cleaned only after that stop is confirmed. hack up refuses while an intent is pending, so an interrupted restart must always leave a state that hack restart itself accepts.

RestartIntent · hack restart under interruption RestartIntent · hack restart under interruption

Source: RestartIntent.tla. The restart's stop request runs in its own process group so a terminal Ctrl-C reaches only the restart frontend and lets the stop finish.

Shared HTTPS owner

Native runs on one machine share a single HTTPS owner. Clients lease it, prove cleanup, and release; the owner may begin closing only once no lease remains.

SharedHttps · owner lifetime SharedHttps · owner lifetime

Source: SharedHttps.tla.

Other models cover registry writers and lock recovery, publication journals, persistent data enrollment, dependency rebinding, engine restart policies and MCP startup. A finite model checks a property over a bounded domain; it does not qualify the installed binary, real filesystems or an application.

Files and directories

  • ~/.hack/ (default; override the whole root with an absolute HACK_HOME)
    • hack.config.json (global config and extension settings)
    • projects.json (project registry; hack projects prune removes stale entries)
    • daemon/hackd.sock, daemon/hackd.pid, daemon/hackd.log (capped at 5 MiB)
    • caddy/docker-compose.yml, caddy/Corefile
    • logging/docker-compose.yml, logging/alloy.alloy, logging/loki.yaml, logging/grafana/
    • schemas/hack.config.schema.json, schemas/hack.branches.schema.json
    • certs/ (mkcert output for non-Caddy services)
  • <repo>/.hack/
    • docker-compose.yml, hack.config.json, optional hack.branches.json
    • .gitignore (committed; created by init and repaired by doctor --fix)
    • hack.env.default.yaml and optional hack.env.<overlay>.yaml (committed env)
    • hack.env.local.yaml and hack.env.<overlay>.local.yaml (worktree-local)
    • .env (only when explicitly materialized) and .env.state.json
    • .internal/compose.override.yml, compose.env.override.yml, compose.runtime.override.yml
    • .internal/extra-hosts.json, .internal/branch-usage.json
    • .internal/lifecycle/state.json and .internal/lifecycle/*.log
    • .branch/compose.<branch>.override.yml and .branch/compose.<branch>.runtime.override.yml
    • hack.project.json and native/adaptation.json (native backend only)

Key design choices

  • Docker Compose is the default execution substrate for predictability and portability.
  • Caddy routes by container label, so no project carries reverse-proxy config.
  • CoreDNS gives containers the same *.hack.local namespace as the host.
  • Logs default to docker compose logs for speed; Loki holds history and filtering.
  • The daemon keeps JSON queries fast without becoming a dependency.
  • Config lives beside each repo in .hack/ so projects stay isolated and portable.
  • The native backend records ownership before effects and confirms cleanup, and those rules are model-checked.

Unsupported retained surfaces

Remote, gateway, node and dispatch code, the extension dispatcher and the macOS app remain in the repository for explicit maintenance. They are outside the supported local CLI and are hidden from default help. See unsupported retained surfaces.


Source: v5 (next.14) · docs/architecture.md.

On this page