# 2. Local setup

Every command in this document was run against this checkout and produced the
output shown. If one fails for you, the difference is your machine, not the doc.

## Toolchain

| Tool | Why | Notes |
|---|---|---|
| **Python 3.12** | backend runtime and test suite | Must have the `_sqlite3` extension. A pyenv 3.10 without it will fail at `conftest` import. `scripts/test.sh` looks for `python3.12` (or uses `uv`) rather than whatever `python3` is on `PATH` — deliberately. |
| **Go 1.26** | frontend service and the backend daemon | Both are static `CGO_ENABLED=0` builds. |
| **Docker** | images, the compose/Swarm stacks, smoke tests | Docker CLI ≥ 27 is fine. |
| **uv** *(recommended)* | fast venv creation | `scripts/test.sh` uses it when present. |
| **shellcheck v0.11.0** | matches CI's pin exactly | Install to `~/.local/bin/shellcheck`. A *different* version can invent new findings and fail CI on an unrelated push. |
| **sops + age** | reading/editing the secrets vault | Only needed if you touch `infra/deploy/secrets/`. |
| **jq** | the repo's `.claude` hooks use it | Without it the prod-guard hook fails toward *asking*, which is safe but noisy. |

Check what you have:

```bash
which go python3.12 uv docker sops age jq shellcheck
go version && python3.12 --version && docker --version
```

## Backend

### Test suite (hermetic, no network, no Docker)

```bash
cd backend
make test               # or: ./scripts/test.sh
make test ARGS='tests/data -q'   # pass pytest args through
```

Verified: **429 passed, 8 skipped in 11.57s** on a cold venv build.

The first run builds `.venv-test/` on Python 3.12 and installs
`requirements-dev.txt`. `tests/conftest.py` is what makes the suite hermetic:

- no real Open-Meteo, Nominatim or GeoNames calls (the places index is marked
  as already-loaded so no background download starts);
- a throwaway SQLite accounts DB and derived store in `/tmp`, never the repo's
  `data/`;
- audit/error/access/activity/heartbeat log dirs redirected to `/tmp`;
- notifier and heartbeat threads disabled;
- `THERMOGRAPH_FRONTEND_BASE_INTERNAL` pointed at an unreachable placeholder
  (the app fails loud at import without it).

### Boot it locally, no Docker, no Postgres

The backend falls back to SQLite when `THERMOGRAPH_DATABASE_URL` isn't a
Postgres URL, so this is enough:

```bash
cd backend
THERMOGRAPH_FRONTEND_BASE_INTERNAL=http://127.0.0.1:8080 \
THERMOGRAPH_BASE=/thermograph \
THERMOGRAPH_ENABLE_NOTIFIER=0 \
THERMOGRAPH_ENABLE_HEARTBEAT=0 \
  .venv-test/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8137
```

Verified responses:

```
GET /healthz                     → {"status":"ok","role":"all"}
GET /thermograph/api/version     → {"backend_version":"2","min_frontend":"1","payload_ver":"p2"}
```

`app:app` is a one-line re-export shim for `web/app.py`. Keep it — systemd, CI
and the container entrypoint all target that name.

Note the port: **8137** is the backend everywhere in this project (compose,
Caddy, the smoke harness at 18137, the frontend's default internal base).

### Image smoke test

```bash
cd backend
make smoke      # builds the image, boots it + a throwaway TimescaleDB (tmpfs),
                # asserts /healthz and /api/version
```

Uses `docker-compose.test.yml` on host port 18137 so it can't collide with a
dev server on 8137.

## Frontend

**The live frontend is Go.** `frontend/server/` is what builds, tests, ships
and runs. The Python files one level up (`app.py`, `content.py`,
`api_client.py`, `format.py`) are the superseded original — see
[traps](11-traps.md).

### Test

```bash
cd frontend/server
go build ./... && go vet ./... && go test ./...
```

Verified: all seven packages `ok` (`config`, `content`, `contentapi`,
`contentdata`, `format`, `handlers`, `render`).

These same commands run inside `frontend/Dockerfile`'s builder stage — plus a
`gofmt -l` check that must come back empty — so **a failing Go test fails the
image build**, which is how CI catches it. There is no separate frontend test
step in the workflows.

### Run it

The process resolves `static/` and `content/` **relative to its working
directory**, so build in `server/` and run from `frontend/`:

```bash
cd frontend/server && go build -o thermograph-frontend .
cd ..
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
THERMOGRAPH_BASE=/thermograph \
PORT=8080 \
  ./server/thermograph-frontend
```

Verified: `GET /healthz` → `{"status":"ok"}`, with a structured JSON log line
per request.

`THERMOGRAPH_API_BASE_INTERNAL` is **required** — boot fails loudly without it,
by design. Optional: `THERMOGRAPH_BASE` (default `/thermograph`; the image sets
`/`), `THERMOGRAPH_API_VERSION` (default `v2` — only ever change it per the
[API-version contract](06-contracts.md)), `THERMOGRAPH_API_BASE_PUBLIC`,
`THERMOGRAPH_SSR_CACHE_TTL` (seconds, default 600), `THERMOGRAPH_GOOGLE_VERIFY`,
`THERMOGRAPH_BING_VERIFY`, `PORT` (default 8080).

### Frontend against a real backend container

```bash
cd frontend
make backend-up      # pulls + runs the published backend image + throwaway db
                     # on 127.0.0.1:18137, waits for /healthz, prints the URL
make backend-down
```

The image tag is **derived from your checkout** — `sha-<12hex of git log -1 --
backend/>`, the same domain-keyed rule `build-push.yml` and `deploy.yml` use —
so the harness follows the tree. If those backend commits are still local-only,
no image exists yet and the script says so; pin a published build with
`THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.

`make test-integration` runs the Python integration tier against that. CI does
**not** run it — it needs a live backend container.

> Heads-up: against a freshly-booted throwaway backend, that tier currently
> fails 7 of 16 with `503` — the database is empty, so nothing is warm.
> Pre-existing, and unrelated to which image tag you use.

## The daemon

```bash
cd backend/daemon
go build ./... && go vet ./... && go test ./...

THERMOGRAPH_INTERNAL_TOKEN=dev-token \
THERMOGRAPH_API_BASE_INTERNAL=http://localhost:8137 \
  go run .
```

It refuses to start without `THERMOGRAPH_INTERNAL_TOKEN` — and the backend
answers `404` on the whole `/internal/*` surface when that token is unset. Both
ends fail closed. With Discord unconfigured it logs once and runs cron-only.

## The full stack, locally

```bash
cd infra
make dev-up      # docker-compose.yml + docker-compose.dev.yml overlay:
                 # uncapped CPU, backend published on 0.0.0.0:8137 for your LAN
make dev-down
```

This is a **laptop convenience**, not the hosted dev environment: the actual
`dev` environment now runs on vps1 with its own Postgres, deployed by CI, and
bound only to the WireGuard mesh (`10.10.0.2:8137`) — never `0.0.0.0`, since
vps1 is a public box. See [Infra and secrets](08-infra-secrets.md). A local
`make dev-up` is fine on `0.0.0.0` because it's your own machine's LAN, not the
public internet.

The dev overlay exports `COMPOSE_PROJECT_NAME=thermograph-dev` so this stack
keeps volumes separate from anything else. **Do not remove either half of the
project-name pinning** — `infra/docker-compose.yml` pins `name: thermograph`,
and without it running compose from `infra/` derives the project name `infra`,
silently creating a *new* stack beside the running one with fresh volumes.

Other `infra/Makefile` targets: `up`/`down` (pull + run the published images),
`db-up`/`db-down` (just Postgres, e.g. to run the app from a venv against it),
`om-up`/`om-down`/`om-backfill` (the self-hosted Open-Meteo overlay — the
backfill writes ~1–1.5 TB and takes hours).

## Connectors (Centralis and friends)

The fleet is not reachable from a laptop off the WireGuard mesh, and Forgejo is
mesh-only. **Centralis** is the control plane that fronts all of it — the app
database, the ERA5 lake, fleet logs, Grafana, Forgejo, docs and notes, and
Discord.

```bash
claude mcp add --transport http centralis https://mcp.thermograph.org/mcp \
  --header "Authorization: Bearer $CENTRALIS_TOKEN"
```

Ask the operator for a token; don't share it. Verify with *"what's running on
prod right now?"* — it should call `fleet_status` and list the Swarm services.

Also worth installing locally: **Chrome DevTools MCP** (design verification
needs a real browser on your machine) and **Figma**. Grafana's official MCP
server is optional and read-only — but remember dashboards are provisioned from
repo JSON, so a durable change is still a PR via `dashboard_write`.

Run `mcp__centralis__onboarding` for the current, authoritative connector list;
it will be fresher than this page.

## Repo-local guardrails

`.claude/settings.json` wires three hooks that travel with the checkout:

| Hook | When | What |
|---|---|---|
| `prod-guard.sh` | before Bash / live-host MCP calls | Classifies by **allowlist**: only positively-recognised read-only commands pass; everything else asks. vps1 is guarded as strictly as vps2 — it hosts Forgejo, Grafana *and* the mesh-only `dev` environment, so a destructive command there takes out git, CI and the registry at once, not just a dev sandbox. |
| `secrets-guard.sh` | before Write/Edit | Denies any direct write to `infra/deploy/secrets/*.yaml`. Use `sops edit`. |
| `lint-after-edit.sh` | after Write/Edit | shellchecks an edited `*.sh` and feeds findings straight back. Exits quietly if shellcheck is missing. |

They enforce what `CLAUDE.md` can only ask for. If you change `prod-guard.sh`'s
classifier, re-read `.claude/hooks/README.md` first — it documents a real
silent-total-bypass failure mode in the parsing loop.

Next: [Repo map](03-repo-map.md).
