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.
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 |
| 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-sizedoes 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.shdoes (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 anddocker builder prune -afrun first. A singlefrontendbuild 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.
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.
# 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 versionPCOV 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 pcovfails with "pcre2.h not found": runexport PKG_CONFIG_PATH="$(brew --prefix pcre2)/lib/pkgconfig"beforepecl install pcov.
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.soInstall 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).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).
# 1. Clone the wrapper with all submodules
git clone --recursive https://github.com/your-org/beai.git
cd beai
# 2. Start everything
./scripts/dev.shThat 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 --seedPlain ./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-orgbeai: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 afterdocker 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).
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'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 devWhat 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).
# 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:loadThe 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:
webkitinbackoffice/tests/e2e/session-cookie.spec.tswill keep failing locally and in CI. Do not weaken the cookie (dropSecure, fall back toSameSite=Lax, or add acookie_secureenv 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
chromiumproject. - To wire the fallback when approved:
brew install mkcert && mkcert -install, generate alocalhost/127.0.0.1cert pair, pointplaywright.config.ts'swebServer.commandat an HTTPS-capable static server (servesupports--ssl-cert/--ssl-key), and updatewebServer.url/use.baseURLtohttps://. Re-runsession-cookie.spec.ts --project=webkitto confirm before removing this note.
# Build each app image locally (no push):
docker build -t beai-api ./api
docker build -t beai-frontend ./frontend
docker build -t beai-backoffice ./backofficeThe 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.ymlsetshttp://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:
toSameOriginImageUrlrewrites their/api/...URL to a same-origin path.
Two different transports, on purpose. They are not alternatives to choose between — they belong to different environments.
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 | headRatified 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.
- Version Catalog:
docs/version-catalog.md - Dependency Resolution Policy: design.md D37
- Toolchain contract: design.md D38
- Git Flow:
docs/git-flow.md