Skip to content

Latest commit

 

History

History
607 lines (473 loc) · 25.5 KB

dev-setup.md

File metadata and controls

607 lines (473 loc) · 25.5 KB

BEAI — Local Development Setup

This document describes the required local toolchain for BEAI development (design D38). All versions are pinned per the Version Catalog (design.md D25) and must match exactly.

Dependency Resolution Policy (D37 — mandatory): if any pinned dependency or tool cannot be installed or resolved, STOP and report — never downgrade a package, never replace it with an alternative library, never remove or loosen a version constraint, never substitute an unspecified tool. A blocked dependency is a human decision, not an implementation choice.


Required Toolchain

shellcheck and dash became load-bearing when the guard library got its own lint step, and they were listed nowhere for a while — two tools the pipeline hard-fails without, riding on an assumed ubuntu-latest preinstall. They run locally too, via task test:scripts, so they belong here rather than in CI alone. Unpinned deliberately: neither has a version this repo depends on, and pinning a linter to a patch only invents a dependency the Resolution Policy would then have to stop for.

Tool Required version Notes
PHP 8.5.x (8.5.8 target) Must include pdo_pgsql and PCOV extensions
Composer 2.4+ Includes composer audit built-in
Bun 1.4.x Sole package manager for both Nuxt apps
Node 24 LTS SSR runtime + Vitest + Playwright runners
Docker ≥ 29.x Docker Engine
Docker Compose v2 (docker compose, no hyphen) Minimum v2.24
go-task 3.x Task runner (brew install go-task)
git any recent Must support --recursive submodule clone
Playwright browsers Chromium + WebKit Install with --with-deps flag
shellcheck any recent Lints scripts/*.sh; CI hard-fails without it (brew install shellcheck)
dash any recent Proves scripts/ci-guards.sh is really POSIX sh, not bash (brew install dash)
Java (JRE/JDK) 11+ (CI: Temurin 21.0.12) Runs the openapi-generator jar for sdks/generate.sh (brew install openjdk)
Python 3.x (CI: 3.13.15) sdks/generate.sh spec rewrite and the smoke parse of the Python SDK (brew install python)
k6 any recent Local load tests only; never CI on PRs

Host resources (not optional)

Resource Minimum Why
Memory available to the Docker VM 6 GB (8 GB comfortable) The frontend image build runs bun run build — Nuxt 4 producing the client bundle, the SSR bundle and the Nitro server (~3100 modules) inside one container
Host RAM 12 GB Docker Desktop defaults its VM to roughly half the host; an 8 GB host yields ~3.8 GB, which is below the floor above
Free disk ~20 GB beai-api:local alone is ~6 GB, plus the build cache

Why this is a hard requirement, not a suggestion. Below the floor the frontend build does not degrade — it is SIGKILLed by the cgroup OOM killer at the Nitro step, after the client and SSR bundles have both reported success:

[nitro] Building Nuxt Nitro server (preset: node-server)
error: script "build" was terminated by signal SIGKILL (Forced quit)
failed to solve: ResourceExhausted: cannot allocate memory

Two things that look like fixes and are not:

  • NODE_OPTIONS=--max-old-space-size does nothing here. The kill comes from the cgroup, not from V8 exhausting its heap. Capping the heap can move the failure, not remove it.
  • Building services one at a time is already what scripts/dev.sh does (see its own comment on the serial build loop — this was fixed once before for exactly this reason). Observed 2026-08-31: the build still OOMs with the whole stack stopped and docker builder prune -af run first. A single frontend build does not fit under the floor, with nothing else running.

Docker Desktop 29.x exposes VM memory only through the GUI — Settings → Resources → Memory, then Apply & Restart. There is no CLI for it, and ~/Library/Group Containers/group.com.docker/settings-store.json does not carry the key, so this cannot be scripted or checked into the repo.

If you are under the floor and need the stack up now, ./scripts/dev.sh --no-build reuses the images already on the machine. Know what that costs: those images serve whatever code they were built from, which for a stale image is not the working tree.

Disk, when Docker's data lives on another volume

Docker Desktop lets you move its DataFolder off the internal disk, and the DataFolder key is in settings-store.json — unlike the memory setting above, this one is readable, which is what makes the check below possible.

Relocating it introduces a failure mode the default setup does not have. Docker.raw is a sparse file provisioned at a virtual size fixed when Docker was installed, and that size can exceed the volume now hosting it. Docker sizes its internal filesystem against the virtual figure, so it keeps allocating past what the host volume can actually deliver. The result is not a slowdown; it is an ENOSPC in the middle of a write, with beai_postgres_data inside that file.

task doctor:disk                      # report free space and provisioning
DOCKER_DISK_MIN_GB=40 task doctor:disk # raise the floor (default 20 GB)

It reads the DataFolder from Docker Desktop's own settings rather than a hardcoded path, and makes no daemon calls — so it answers in the situation it exists for, when the volume is not mounted and Docker cannot answer anything at all. It exits non-zero when space is below the floor or the folder is missing, and exits 0 silently on a machine with no relocated folder (Linux, CI, colima), which is why it is safe to wire into shared tasks.

task up and the e2e:* tasks run it automatically and never block on it: low disk is a legitimate state to work in. The e2e:* tasks matter most here — they pull the pinned Playwright image, 3.45 GB, the largest single write this repo asks of that volume.

Do not reach for docker system prune to make room. Re-pulling the evicted layers is bounded by the external volume's write throughput, which can be an order of magnitude below the internal disk's, and the Playwright image alone is 3.45 GB. Free space by other means and prune deliberately, not reflexively.


Installation Guide

macOS (Homebrew)

# PHP 8.5 via shivammathur/homebrew-php tap
brew tap shivammathur/php
brew install shivammathur/php/php@8.5
brew link php@8.5 --force --overwrite

# Verify
php -v   # PHP 8.5.x

# Composer
brew install composer
composer --version  # 2.x.x

# Node 24 LTS
brew install node@24
brew link node@24 --force --overwrite
node -v  # v24.x.x

# Bun 1.4
curl -fsSL https://bun.sh/install | bash
# Or: brew install bun
bun -v  # 1.4.x

# Docker Desktop (includes Compose v2)
# Download from https://www.docker.com/products/docker-desktop/
docker compose version  # Docker Compose version v2.x.x

# go-task
brew install go-task
task --version  # Task version: 3.x.x

# Java + Python 3 (SDK generation: sdks/generate.sh, sdks/smoke.sh)
brew install openjdk python
java -version
python3 --version

# k6 (local load tests only)
brew install k6
k6 version

PCOV Installation (PHP coverage driver — Homebrew gotcha)

PCOV is faster than Xdebug for coverage collection and is the required driver for the api CI coverage gate (design D8). On macOS with Homebrew PHP, PCOV requires pcre2 headers and a manual phpize build because the Homebrew PHP formula does not bundle PCOV out of the box.

# 1. Ensure pcre2 is installed (dependency for PCOV build)
brew install pcre2

# 2. Install PCOV via PECL (uses phpize under the hood)
pecl install pcov

# 3. Verify the extension loaded
php -m | grep pcov      # should print: pcov
php --ri pcov           # shows PCOV version and config

# 4. In php.ini (or a conf.d drop-in), ensure:
#    extension=pcov.so
#    [pcov]
#    pcov.enabled = 1
#    pcov.directory = /path/to/project/app
#
# Find your php.ini: php --ini | grep "Loaded Configuration"

If pecl install pcov fails with "pcre2.h not found": run export PKG_CONFIG_PATH="$(brew --prefix pcre2)/lib/pkgconfig" before pecl install pcov.

pdo_pgsql Extension

The api requires the PostgreSQL PDO driver. On Homebrew PHP 8.5 it may need to be enabled explicitly:

# Check if already loaded
php -m | grep pdo_pgsql

# If not loaded, install the extension via PECL or your PHP formula
# For shivammathur/php formula the extension is typically bundled — just enable it:
# Find conf.d: php --ini
# Add file: /opt/homebrew/etc/php/8.5/conf.d/ext-pdo_pgsql.ini
# Content: extension=pdo_pgsql.so

Playwright Browsers

Install Chromium and WebKit with their system dependencies:

# After installing Node 24 and project deps with Bun:
cd frontend   # or backoffice
bun install
bunx playwright install --with-deps chromium webkit

# The --with-deps flag installs OS-level libraries (FFmpeg, WebKit system libs, etc.)
# Required on CI too (see each app's .github/workflows/ci.yml).

Getting Started

Two paths. Pick one:

  • Containers — one command, no host toolchain. This is the default and the one to use unless you have a reason not to.
  • Host toolchain — apps run on your machine against containerised infra. Needed for step-debugging, IDE integration, or running the test suites, which execute on the host (see Running Tests).

Option A — containers (recommended)

# 1. Clone the wrapper with all submodules
git clone --recursive https://github.com/your-org/beai.git
cd beai

# 2. Start everything
./scripts/dev.sh

That single command is idempotent and safe to re-run. It performs the Docker preflight, creates any missing .env from its .env.example, initialises submodules, generates APP_KEY and JWT_SECRET on the host (never baked into an image — see the comments in the script), starts the full stack, waits for every healthcheck, applies migrations, and prints the service URLs.

Requires only Docker + Docker Compose v2 — no local PHP, Node or Bun.

Flag Effect
(none) start everything
--build force a rebuild of the app images
--seed run database seeders after migrating
--fresh destructive — wipe volumes and rebuild from zero (asks for confirmation)
--no-worker scale worker and scheduler to zero
--status show service health and exit
--logs follow logs of all services
--down stop containers, preserving volumes

Use --seed on the first boot, because that is what gives you a populated tenant to click through:

./scripts/dev.sh --seed

Plain ./scripts/dev.sh migrates but seeds nothing, and DatabaseSeeder itself seeds only the dev-org organization and the framework catalog — no participants, no projects, no evaluations. --seed also runs beai:demo-seed --org=dev-org --create-org, which brings a rich, BARS-valid demo dataset into that organization:

Demo dataset provisioned.
  | FrameworkVersion | beai-demo-1.0.0 (locked=false)                            |
  | Avatar templates | 2 (1 active)                                              |
  | Projects         | 4                                                        |
  | Participants     | 9 across every lifecycle status                          |
  | Evaluations      | 5 with computed competency results and indicator scores  |
  | Snapshots        | 34 objects written to the configured disk                |

No account is created by --seed, in any environment — beai:demo-seed never creates a user. Log in with an account you already have (or mint one with beai:provision-organization, below); --create-org only creates the dev-org organization itself if it is somehow missing, and is refused when APP_ENV=production.

The whole step is idempotent: a second --seed writes nothing new. If the dataset is ever left partially seeded (e.g. a row deleted by hand), a re-run refuses rather than guessing — run beai:demo-seed's teardown counterpart first:

docker compose exec api php artisan beai:demo-teardown --org=dev-org

beai:demo-seed is not part of DatabaseSeeder and never runs automatically: db:seed executes with --force in CI and inside the production image, and a demo dataset has no business appearing there unannounced. Every demo row carries the beai-demo- prefix (on projects.slug, participants.candidate_ref, framework_versions.version, avatar_templates.name) so it is never mistaken for real client data and is always selectable for teardown.

For a real tenant — an actual organization with a generated, non-published password and its own admin account — use the provisioning command instead:

docker compose exec api php artisan beai:provision-organization \
  --name="Another Tenant" \
  --admin-email=admin@another.test
# Prints a generated password once — store it.
# Use --admin-password=… to choose your own (it is then NOT echoed).

The api image bakes its source; there is no bind mount. Any change under api/ — including seeders — reaches the container only after docker compose build api, or ./scripts/dev.sh --build. A seeder edit that appears to have no effect has almost always simply not been rebuilt.

Services, once up:

Service URL
Candidate app (frontend) http://localhost:3000
Backoffice http://localhost:3001
API health http://localhost:8000/api/health
Mailpit http://localhost:8025
Postgres · Redis localhost:5433 · localhost:6379

Ports are overridable in the wrapper .env (FRONTEND_PORT, BACKOFFICE_PORT, API_PORT, MAILPIT_UI_PORT, POSTGRES_PORT, REDIS_PORT).

Why Postgres is on 5433 and not 5432

Because a native PostgreSQL is very often already running on 5432, and the two do not fail loudly when they collide — they fail SILENTLY.

Docker publishes on the wildcard address (*:5432); a native server binds the specific loopback one (127.0.0.1:5432). The specific bind wins, so with both running, every localhost:5432 connection reaches the NATIVE server. It connects, it authenticates, and it shows a different database. Nothing errors.

That has already cost time on this project twice: an OpenAPI export that ran against the wrong server, and a SQL client showing databases that were not the application's.

Only the HOST port moved. The container still listens on 5432 and every service inside the compose network still reaches it at postgres:5432, so nothing in the application configuration changes.

If you have no native PostgreSQL, set POSTGRES_PORT=5432 in the wrapper .env and use the standard port.

If you do, keep 5433 and note where each server is used:

Host port Holds
Docker (this compose file) 5433 beai — the app's real data
Your native server 5432 your other projects, and beai_test

api/phpunit.xml points the test suite at 127.0.0.1:5432/beai_test, so the suite runs against your NATIVE server, not this container. Keep that server on PostgreSQL 17 to match production; a different major version is a silent divergence that will eventually pass locally and fail in CI. The test database needs the vector extension:

createdb -h 127.0.0.1 -p 5432 -U postgres beai_test
psql -h 127.0.0.1 -p 5432 -U postgres -d beai_test -c 'CREATE EXTENSION IF NOT EXISTS vector'

Option B — host toolchain

Requires the full toolchain from Required Toolchain above. Infra stays in Docker; the three apps run on your machine.

# 1. Clone the wrapper with all submodules
git clone --recursive https://github.com/your-org/beai.git
cd beai

# 2. Start local infra (postgres + redis + mailpit)
task up

# 3. Verify infra is healthy
docker compose ps

# 4. Bootstrap the api (in ./api directory)
cd api
cp .env.example .env
# Fill in secrets (POSTGRES_PASSWORD, etc.)
composer install
php artisan migrate

# 4b. Create an organization and an admin to log in with.
#     A migrated database has no organization, so it has no admin, so there is
#     no account the backoffice can authenticate. Every input is an option, so
#     this also works in a container with no TTY.
php artisan beai:provision-organization \
  --name="Local Dev" \
  --admin-email=admin@local.test
# Prints a generated password once — store it.
# Use --admin-password=… to choose your own (it is then NOT echoed).

# 5. Bootstrap the frontend (in ./frontend directory)
cd ../frontend
cp .env.example .env
bun install
bun run dev

# 6. Bootstrap the backoffice (in ./backoffice directory)
cd ../backoffice
cp .env.example .env
bun install
bun run dev

Schema drift and task stack:check

What it is. GET /api/health is liveness and never touches the database, so a stack whose schema is behind the code (typically a long-lived Postgres volume under freshly built images) answers 200 while real requests fail with relation "..." does not exist. GET /api/health/ready is the readiness probe that does look.

Check it. task stack:check (scripts/stack-doctor.sh, API_URL overrides http://localhost:8000) asks liveness, then readiness, and exits 0 only when both are 200 {"status":"ok"} (a 404 on readiness is the warning case below). ./scripts/dev.sh runs the same check after its migrate step and fails the boot if the api is not ready.

Older api images. An api that predates /api/health/ready answers 404 there; the doctor then prints a WARNING ("schema state NOT verified") and exits 0. Set STACK_DOCTOR_STRICT=1 to make that an error (exit 1): intended for CI and the real-stack e2e once the pinned api carries the endpoint.

Recreating api. Only backoffice goes stale: its nginx resolves api once at start, so a recreated api (new IP) means a 502 on :3001. depends_on.api.restart: true covers docker compose restart api only. After up -d --force-recreate api or up -d --build api, run docker compose restart backoffice; task stack:check detects the 502 and prints that command. The frontend re-resolves api per connection and is not affected.

How compose self-heals. The local api service runs php artisan migrate --force && exec supervisord ... before it serves, so docker compose up brings a stale volume current. A failed migration exits the container non-zero; restart: unless-stopped then loops it, so read docker compose logs api for the cause. worker, scheduler, frontend and backoffice wait for api to be healthy, so none of them starts on a stale schema.

How production migrates. Not through the container: Railway runs php artisan beai:deploy once per deploy (preDeployCommand). The shared image entrypoint deliberately does not migrate, because it runs once per replica and would race. scripts/ci-guards.sh fails CI if the entrypoint ever gains a migrate.

Reason codes (/api/health/ready returns 503 {"status":"down","reason":...}; machine constants, never localized):

Reason Meaning Fix
pending_migrations Schema is behind the code docker compose exec api php artisan migrate --force
database_unavailable The api cannot reach Postgres docker compose ps postgres, then docker compose up -d postgres
anything else Unrecognised Printed literally; docker compose logs --tail=50 api

The script also reports "not reachable" (api down: docker compose up -d).

Running Tests

# From the wrapper root — delegates to each app:
task test:api         # Pest suite (requires infra up)
task test:frontend    # Vitest + Playwright (frontend)
task test:backoffice  # Vitest + Playwright (backoffice)

# Load tests (local only, never Railway):
task up               # ensure infra is up first
task test:load

Local Dev Cookie Story (backoffice-session-refresh-hardening D4/D11, A2/A3)

The beai_refresh cookie is HttpOnly; Secure; SameSite=None; Path=/api/auth/refresh unconditionally, in every environment including local dev — there is deliberately no cookie_secure config knob (design D4). Secure requires a "secure context"; on http://localhost browsers grant an exemption for Secure, but SameSite=None still requires Secure to be present, so the assumption (A2) is that the http://localhost secure-context exemption is enough for local dev without real TLS.

A3 — the highest-risk assumption in this whole change — was verified EMPIRICALLY by backoffice/tests/e2e/session-cookie.spec.ts (D11) and found FALSE for WebKit in this environment: WebKit rejects storing a Secure cookie over plain http://localhost. Chromium accepts it; WebKit does not. The gate is failing on webkit by design — per D11 it must fail CI, not fail silently — and that failure is the accurate, expected signal until the fallback below is actually wired, not a bug in the test.

Pre-decided fallback (NOT yet implemented — task 6.3): serve the Playwright webServer (and, for parity, the docker-compose stack) over HTTPS with a locally trusted certificate via mkcert. mkcert is not part of the pinned toolchain above and was not installed in the apply session that found this — per the Dependency Resolution Policy (D37), adding a new local tool is a decision for a human, not something to silently install mid-implementation. Until this is wired:

  • webkit in backoffice/tests/e2e/session-cookie.spec.ts will keep failing locally and in CI. Do not weaken the cookie (drop Secure, fall back to SameSite=Lax, or add a cookie_secure env knob) to make it pass — that reopens exactly the exposure D4 exists to close.
  • Chromium's E2E coverage of the cookie mechanism is unaffected and remains the authoritative signal for the chromium project.
  • To wire the fallback when approved: brew install mkcert && mkcert -install, generate a localhost/127.0.0.1 cert pair, point playwright.config.ts's webServer.command at an HTTPS-capable static server (serve supports --ssl-cert/--ssl-key), and update webServer.url/use.baseURL to https://. Re-run session-cookie.spec.ts --project=webkit to confirm before removing this note.

Docker Image Builds (local verification)

# Build each app image locally (no push):
docker build -t beai-api      ./api
docker build -t beai-frontend ./frontend
docker build -t beai-backoffice ./backoffice

Backoffice CSP and the API origin (local docker)

The backoffice nginx serves img-src 'self' data: blob: https:. Locally the api builds media URLs from APP_URL (http://localhost:8000), an http: origin, so images it serves directly (e.g. signed /storage/... profile photos) are blocked. BEAI_CSP_IMG_EXTRA is an optional build arg of backoffice/Dockerfile (build-time, like BEAI_API_ORIGIN, because the runtime stage is non-root and has no envsubst) that appends extra origins to img-src:

  • Empty by default, so the production CSP is unchanged (https-only).
  • docker-compose.yml sets http://localhost:${API_PORT:-8000} for the local backoffice service; rebuild the image after changing it.
  • Only space-separated http(s)://host[:port] origins are accepted; anything else fails the build, so a typo cannot inject another directive.
  • Org logos need no allowance: toSameOriginImageUrl rewrites their /api/... URL to a same-origin path.

Mail

Two different transports, on purpose. They are not alternatives to choose between — they belong to different environments.

Local — Mailpit, nothing to configure

docker-compose.yml pins MAIL_MAILER: smtp on the shared x-api-environment anchor, alongside MAIL_HOST: mailpit and MAIL_PORT: 1025. It reaches api, worker and scheduler together, which matters: the process that actually sends operator alerts is the worker, and a worker left on the log driver writes every alert into a container filesystem nobody reads.

Read the caught mail at http://localhost:8025. Nothing ever leaves your machine.

Verify the wiring after a change:

docker compose exec api php artisan tinker --execute="Mail::raw('probe', fn(\$m) => \$m->to('you@example.test')->subject('probe'));"
curl -s http://localhost:8025/api/v1/messages | head

Production — Resend

Ratified 2026-07-30. Laravel 13.20 ships the transport first-party; the resend/resend-php package (D25) is what it type-hints.

Variable Value Notes
MAIL_MAILER resend Set per environment. Never in the compose stack — that is local.
RESEND_API_KEY (secret) A credential: Railway's variable store, never committed. .env.example may carry the NAME only.
MAIL_FROM_ADDRESS a verified sender Must be on a domain verified in the Resend dashboard.

The failure mode to know about. An unverified sender domain, or a missing key, does not fail at boot. It throws inside the queued notification job, so it surfaces as a failed row in notification_logs — visible on the operator dashboard, but only if someone looks. The default MAIL_FROM_ADDRESS is hello@beai.test in api/.env.example, and hello@example.com is Laravel's own fallback in config/mail.php. Neither is a real domain, so Resend rejects both — changing it is not optional.

Tests never touch either transport: api/phpunit.xml pins MAIL_MAILER=array, and a test asserts that pin still holds.


References

  • Version Catalog: docs/version-catalog.md
  • Dependency Resolution Policy: design.md D37
  • Toolchain contract: design.md D38
  • Git Flow: docs/git-flow.md