Development
Repo layout
hyprbox/
├── apps/
│ ├── web/ # Next.js 16 — port 3000
│ │ ├── src/app/ # routes (App Router)
│ │ ├── src/components/ # nodes-dashboard, node-detail, studio-nav, …
│ │ ├── src/lib/ # tokens, auth, stream, hyprbox-api
│ │ └── src/proxy.ts # edge proxy (gates /dashboard/*)
│ └── api/ # Fastify 5 — port 4000
│ ├── src/routes/ # auth, nodes, tokens, presets, jobs, stream
│ ├── src/middleware/ # auth, nodeAuth
│ ├── src/lib/ # prisma, events, env, presets/{schema,loader,render}
│ ├── prisma/ # schema.prisma, seed.ts, seed-admin.ts
│ └── test/ # vitest specs (32 total)
├── agent/hyprnode/ # Go agent
├── cli/hyprbox/ # Go CLI
├── presets/ # YAML recipes loaded at API boot
├── docs/ # this directory
├── docker-compose.dev.yml # Postgres + Redis for dev
├── docker-compose.prod.yml # Postgres + API + Web
└── .github/workflows/ci.yml # node + go + docker jobs
Prerequisites
- Node 20+, pnpm 9 (
corepack enable && corepack prepare pnpm@9.15.4 --activate) - Go 1.23 for the agent + CLI
- PostgreSQL 16 — either via Docker (
pnpm db:up) or native install (winget install PostgreSQL.PostgreSQL.16on Windows). The dev compose also spins up Redis but nothing currently uses it. - Optional: Docker Desktop for building the prod images locally.
First-time setup
git clone <repo> && cd hyprbox
pnpm install # installs all workspaces
pnpm db:up # OR run a native Postgres + create the DB
cp apps/api/.env.example apps/api/.env
# Schema + data — fresh clones land the baseline migration cleanly.
pnpm --filter @hyprbox/api exec prisma migrate deploy
pnpm db:seed # 6 fake nodes with 24h history
pnpm db:seed:admin # admin@hyprbox.local / hyprbox-admin
pnpm db:seed:recommendations # seeds the curated Recommendation catalogue
Upgrading from a pre-Phase-6.1 dev DB (one-time):
# Your DB was bootstrapped with `db push` and is already at the baseline
# shape — but Prisma's _prisma_migrations table doesn't know that yet.
pnpm --filter @hyprbox/api exec prisma migrate resolve --applied 20260531000000_init
# From here on, `prisma migrate deploy` will be a no-op until a new migration lands.
Dev loops
pnpm dev:api # tsx watch — auto-reloads on src/** edits
pnpm dev:web # next dev with Turbopack — HMR on src/app/**
Both processes are noisy on first run because of TLS cert generation and Prisma client compile. Subsequent reloads are sub-second.
Agent + CLI live separately:
cd agent/hyprnode && go run . # heartbeats every 5m
cd cli/hyprbox && go run . status
The agent only runs the job loop on Linux (runtime.GOOS != "linux" short-
circuits the polling). Heartbeats work everywhere.
Tests
pnpm test # 66 specs vitest (API)
pnpm --filter @hyprbox/api test:watch # watch mode
pnpm --filter @hyprbox/api exec tsc --noEmit # API typecheck
pnpm --filter @hyprbox/web exec tsc --noEmit # Web typecheck
cd agent/hyprnode && go test ./... # 10 Go specs (sshd parser)
Tests assume:
- A separate
hyprbox_testdatabase. Set it once:$env:PGPASSWORD = "postgres" & "C:\Program Files\PostgreSQL\16\bin\psql.exe" -U postgres \ -c "CREATE DATABASE hyprbox_test OWNER hyprbox;" HYPRBOX_TEST_DATABASE_URL=postgresql://hyprbox:hyprbox@localhost:5432/hyprbox_testin the shell that runspnpm test.
test/global-setup.ts does a prisma db push once before any spec runs.
Each spec's beforeEach calls resetDb() (heartbeats → jobs → tokens →
nodes → users — in FK-respecting order).
db push (not migrate deploy) on the test DB is deliberate: the test DB
is ephemeral and the migration replay history isn't worth the boot cost.
CI compensates by running an explicit drift check that fails the build if
schema.prisma diverges from prisma/migrations/.
Go side:
cd agent/hyprnode && go vet ./... && go test ./... && go build ./...
cd cli/hyprbox && go vet ./... && go build ./...
The agent's internal/scanner/ssh_test.go covers the sshd_config parser
edge cases (Include, Match, first-write-wins).
Schema changes & migrations
Phase 6.1 switched the project from prisma db push to a real migration
history in apps/api/prisma/migrations/. The workflow when you change
the schema:
# 1. Edit prisma/schema.prisma.
# 2. Generate a migration. This runs against your *dev* DB AND creates
# the migration.sql file in one shot.
cd apps/api
pnpm db:migrate -- --name <short_snake_case_name>
# 3. Commit both the schema change AND the new migration directory.
Drift check (locally) — confirm schema.prisma and migrations/ agree:
# One-time: create a shadow DB for Prisma to replay migrations into.
psql -U hyprbox -d postgres -c "CREATE DATABASE hyprbox_shadow OWNER hyprbox;"
# Then any time you want to verify:
pnpm --filter @hyprbox/api exec prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "postgresql://hyprbox:hyprbox@localhost:5432/hyprbox_shadow" \
--exit-code
CI runs this on every PR. Exit code non-zero ⇒ you forgot to db:migrate.
Prod migration in the API container — prisma migrate deploy runs at
boot (see apps/api/Dockerfile CMD). It applies any pending migrations
in order; a no-op when the DB is up-to-date. Failure aborts the boot so
the API never serves a request against an inconsistent schema.
Common debugging recipes
"Why doesn't my Bearer token authenticate?"
Both the cookie AND the Authorization header are accepted. The middleware
extracts the token manually (see apps/api/src/middleware/auth.ts —
@fastify/jwt v9 has a known bug where the cookie config short-circuits the
header path). If you've just edited that file, restart the API: tsx watch
sometimes misses the change for module-level singletons.
"API responds 401 on /api/nodes but works fine on /api/auth/login"
Login is unauthenticated. Everything under /api/nodes/* (except
/heartbeat) is requireAuth. Mint a token via /api/auth/login and pass
it as Authorization: Bearer ….
"Agent heartbeats arrive but the dashboard doesn't update in realtime"
The dashboard SSE connection needs the hb_token cookie. The login flow
sets it; if you cleared cookies, log out and back in. EventSource can't set
custom headers, so the Bearer alone won't reach /api/stream/*.
"Preset YAML changes aren't picked up"
The loader caches at first call. Restart the API. There's no file-watcher yet because preset edits in prod should be code-reviewed PRs anyway.
"Hydration mismatch on the dashboard"
A browser extension is injecting attributes into <body> before React
hydrates (Bitdefender, Honey, etc). The root layout has
suppressHydrationWarning to silence the warning — your component tree
hydration is still verified normally.
Editor config
VS Code: install ESLint, Prisma, Tailwind CSS IntelliSense.
JetBrains: enable the same plugins. Both repos work fine with default Prettier
settings (no .prettierrc — we use Prettier defaults).
TypeScript settings live in each workspace's tsconfig.json. The web app uses
moduleResolution: 'bundler'; the API uses the same. Both target ES2022.
Conventions
- No comments that just repeat code. Only explain non-obvious why (a hidden constraint, a workaround, a subtle invariant).
- Trust internal callers. Validate at the system boundary (HTTP body via Zod, env vars at boot). Don't pepper internal helpers with defensive checks.
- Idempotent writes. Anything an agent does should be safe to repeat: preset steps re-run cleanly, heartbeat upserts, token revoke is a no-op on already-revoked rows.
- One step per concern in presets. Easier to read, easier to rerun a failed step manually.
- Tests hit Postgres. We had mocked-DB tests bite us; everything that
touches Prisma goes through a real
hyprbox_testdatabase.
Adding a new route
- New file in
apps/api/src/routes/<area>.ts, exporting aFastifyPluginAsync. - Wire it in
apps/api/src/index.ts:await app.register(myRoutes, { prefix: '/api/<area>' }). - Add a spec in
apps/api/test/<area>.spec.ts. Don't forget to register the plugin intest/helpers.ts:buildTestApp()too — that's bit us before. - Gate writes behind
requireAuth(per-routepreHandler, not plugin-wide — the plugin-wide hook interacts badly with@fastify/jwtcookie config).
Adding a new step type to presets
- Define the Zod schema in
apps/api/src/lib/presets/schema.ts(extendbaseStep, add to thediscriminatedUnion). - Add a
renderXxx(step, vars)function inapps/api/src/lib/presets/render.ts. - Wire it in the
switchinsiderenderStep. - Add an example in one of the built-in presets.
- Document it in
docs/PRESETS.mdunder "Step types".