Development Environment
Use baander.test for the persistent instance and a worktree preview for changes.
The persistent instance follows remote master in its own checkout. A preview runs
this worktree's PHP and Vite source with a separate database, Redis, keys and
writable storage. Changing an agent branch does not change the persistent checkout.
Prerequisites and host setup
The controller requires Linux, PHP 8.5+ with Swoole, Composer, Bash, GNU coreutils,
setsid, Git, OpenSSL, Docker with Compose and Buildx, NetworkManager managing DNS
through dnsmasq, mkcert, and a working systemd user manager. Docker must provide
cgroup v2 and publish loopback port 443 for the invoking user. Each worker pair
requires at least two daemon CPUs and 8448 MiB of daemon RAM; allow additional
resources for web, dependencies and concurrent instances. Use Yarn for frontend
work; never run npm.
The selected image stack uses PHP 8.5.12, Swoole 6.2.4 and Composer 2.11.1.
Dependency maintenance needs PHP 8.5 or newer and exactly Composer 2.11.1 on
PATH; its local check does not require Swoole. See
PHP dependency maintenance for the seven projects, local
and online checks, retained fixes and recorded upgrade blockers.
Host setup uses the Composer project under scripts/, which installs the local
baander/tooling library from packages/baander-tooling/; application
dependencies and the application kernel are not prerequisites. It checks the PHP
runtime before writing state. Bash entrypoints forward to PHP; the application
console keeps its application commands. The root command composer tooling:autoload
installs both the scripts/ and relay/ Composer projects for development and test
work. For host setup, use the scripts/ installation shown below.
Generate the host instructions before starting an instance:
composer --working-dir=scripts install --no-dev
make dev-host-setup
Read the generated host-setup/README.md and configuration files under
${XDG_STATE_HOME:-$HOME/.local/state}/baander/development. They describe installing
the NetworkManager dnsmasq configuration with administrator permission and
reloading NetworkManager. Verify that baander.test and a subdomain both resolve
to 127.0.0.1. This DNS change can briefly interrupt network access.
Install the local certificate trust explicitly, after inspecting those instructions:
make dev-host-setup OPTIONS=--install-trust
This invokes mkcert -install, which can request administrator permission, and
creates the gateway leaf certificate for baander.test and *.baander.test.
The private CA stays in host state. make dev-host-setup OPTIONS=--inspect checks
DNS ownership, mkcert availability and whether loopback port 443 is free before
first gateway startup. Host setup writes inspectable files; it does not install DNS
configuration for you.
The Docker daemon must see every source and state bind path. On SELinux hosts, prepare the selected worktree and media directories explicitly:
make dev-host-setup OPTIONS="--share-path /absolute/path/to/worktree --share-path /absolute/path/to/media"
This applies shared container labels to those directories and the managed runtime
mounts. It preserves the private CA's separate label and rejects paths that would
expose it. Root-owned generated files can require an administrator to apply the
same chcon -R --no-dereference -t container_file_t -l s0 command to the selected
worktree. Do not relabel the home directory as a whole. Ordinary startup does not
relabel user directories or disable SELinux. A remote daemon cannot read a
client-only checkout path.
Everyday commands
Run these commands from the repository root:
| Command | Result |
|---|---|
make dev-up |
Start or resume https://baander.test and enable automatic remote-master updates |
make dev-down |
Disable automatic updates and stop the persistent instance, retaining data |
make dev-status |
Inspect persistent revisions, readiness, updater state and failures |
make preview-up |
Start this worktree's preview and refresh its worker snapshot |
make preview-down |
Stop this worktree's preview, retaining data |
On first startup, the controller creates private configuration, initializes an empty
database, creates OAuth keys and clients, and seeds development accounts. Subsequent
starts reuse that identity and data. Lifecycle commands do not reset databases.
Development credentials are admin@baander.test / admin and
user@baander.test / user; keep these instances local.
The persistent updater is a systemd user timer named
baander-development-<state-root-hash>.timer. The state-root hash keeps separate
controller state directories from sharing a timer. Inspect installed timers with
systemctl --user list-timers 'baander-development-*'.
It checks remote master every 60 seconds while enabled. Its controller and clone
live outside worktrees. Publication installs a complete controller generation,
atomically selects it and records its absolute PHP executable, preserving updater
intent. Invocations lease
the selected generation until they finish, so an update retains code still in use.
The service retains the absolute executable search paths
from dev-up, including Docker credential helpers; remote access must work without
an interactive prompt. The user manager must remain available for checks to run;
the workflow does not enable login lingering. A stopped instance stays stopped
until make dev-up enables it again. If the daemon stops a ready instance, the
enabled timer can restore its retained release and admit a fresh worker boot.
Updates prepare immutable images before stopping writers. Migration checks run before advancing the instance, and readiness includes the worker pair. A blocked migration or uncertain worker retirement requires inspection; repeated timer ticks do not authorize a reset or database rollback.
Previews and browser verification
A preview has a stable worktree-specific hostname below baander.test. PHP and
Vite read the live worktree. PHP's development watcher reloads included files on
close-write events; atomic saves and configuration changes can require
make restart-app or make preview-up. Workers run immutable code captured by the most recent
make preview-up; run that command again after worker changes. Preview PostgreSQL,
Redis, secrets, caches and generated media are separate from the persistent instance.
Startup creates empty mount directories in the checkout; dependencies and runtime
contents live in the preview volumes.
make preview-up
make preview-status
make preview-url
Use the URL returned by make preview-url for browser work. For Playwright:
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless
The browser runner requires an explicit isolated preview URL. It does not provision an app or fall back to the persistent instance. See Testing for the separate disposable backend coordinator and frontend suites.
Local tooling settings
The optional tooling object in dev.local.json selects role image references,
Docker transport and finite workflow budgets alongside libraries. See the
tooling package reference
for supported keys, environment names and browser compatibility metadata.
Invocation settings take precedence over captured environment settings, local
settings and tracked defaults. For example:
make preview-up OPTIONS="--docker-context desktop-linux"
Use --docker-host for an endpoint or --docker-executable for a different CLI.
A host and context cannot be supplied together. Startup validates new settings
before changing instance state. Recovery uses the saved release's target, layout,
images and generated inputs, even if today's local settings are invalid. Existing
PostgreSQL and Redis data keep their recorded image identities; an image override
does not upgrade those data stores. Preserve saved state when conversion or
ownership checks refuse recovery. Lifecycle commands do not authorize a reset.
Media mounts
To create libraries automatically during startup, copy dev.local.example.json
to dev.local.json in the checkout root and set each host directory:
{
"libraries": [
{
"slug": "movies",
"name": "Movies",
"type": "movie",
"path": "/absolute/path/to/movies"
}
]
}
Each entry requires a unique slug, a nonempty name, a supported type and an
absolute path to an existing readable directory. Types are music, podcast,
audiobook, movie and tv_show. Slugs use lowercase letters, digits and hyphens
between words; surrounding whitespace and uppercase letters are normalized.
Unknown fields and invalid definitions fail before instance state is created.
The controller reads the current worktree's file first. A linked worktree without
that file uses the primary checkout's file from the same Git repository. Files
are not merged. With a separate Git directory, fallback requires core.worktree
to identify the primary checkout; otherwise put the file in the linked worktree.
Git ignores the local file, and Docker builds and preview source snapshots exclude
it even if accidentally tracked. The example remains versioned. Preview web
containers can still see the local file through their live checkout bind.
Startup mounts each directory read-only at /media/<slug>, waits for application
readiness, then creates any missing library. An existing library with the same
slug must match the configured name, type, container path and local filesystem.
A conflict or failed command stops activation with an error. Startup never scans
the directory; start a scan from the library administration page when wanted.
Stop an existing preview before adding, removing or changing mounted paths:
make preview-down
# Edit dev.local.json.
make preview-up
Changing the host path keeps the library's container path and existing catalog.
Removing a definition removes its configured mount but does not delete the
application library or its catalog. An empty libraries array removes all
configured library mounts and provisioning while retaining unrelated manual
mounts. A missing file or omitted libraries field preserves saved settings;
new previews can inherit saved settings from this repository's persistent instance.
Saved definitions also let the persistent updater run without the local file.
Add a host media directory when starting the persistent instance:
make dev-up MEDIA=/absolute/path/to/media
make dev-up MEDIA=/absolute/path/to/media=/media/library
The optional target preserves a path expected by an existing library. New previews
inherit persistent media mount declarations and mount source media read-only.
An explicit MEDIA mapping to a configured library target overrides its host
source for that invocation and saves the effective source with the instance.
Their generated conversions and other writable state remain separate. Stop an
instance before changing its mount declarations. Directory visibility and SELinux
permissions must work on the selected Docker daemon. Media directories must not
contain the private controller state directory or sit inside it.
Commands and diagnostics
Generic Make commands select this worktree's preview. Select the persistent
instance explicitly with INSTANCE=persistent:
make exec cmd="php bin/console about"
make logs
make exec INSTANCE=persistent cmd="php bin/console about"
make dev-update
make dev-update OPTIONS=--retry
make exec splits cmd into arguments with shell-style quoting, preserving quoted
values as single arguments. Use make exec-bash when the command needs shell
operations such as pipes, redirection or variable expansion.
make dev-update requests an immediate remote-master check. --retry retries a
failed build after its cause has been corrected. Inspect make dev-status before
using make dev-recover; recovery reconciles a blocked attempt after database
history has been checked. It does not reverse a migration. make restart refreshes
the selected instance. make ssh, make logs, and dependency or migration commands
use the same explicit instance selection.
State defaults to ${XDG_STATE_HOME:-$HOME/.local/state}/baander/development.
For an isolated controller test or another state directory, use the dispatcher's
--state-root option before its subcommand:
php scripts/dev-environment.php --state-root /absolute/private/state status --instance persistent
There is no implicit state-root environment override. Keep state private: it holds credentials, certificates, manifests and recorded Docker identities. The state root must be outside the checkout.
Adopting an existing instance
Inspect existing resources before selecting adoption. Do not run a fresh setup or reset to make adoption pass. Supply two explicit JSON files through the dispatcher:
make dev-up OPTIONS="--adopt /absolute/private/adoption.json --environment-file /absolute/private/environment.json"
The adoption inventory's containers object records full immutable Docker IDs for
postgres, redis, app, and optionally nginx. Its worker_manifests array
records each existing deployment as an object with directory, operator_image,
socket, and network. Retain its old controller, manifest and credentials so it
can retire the old workers through their admitted deployment protocol.
The environment file records the existing database identity, application and stream
secrets, VAPID keys, OAuth client configuration and media paths. Protect both files.
Adoption requires MEDIA_STORAGE_PATH and CONVERT_STORAGE_PATH to be disjoint
from the writable storage root at /var/www/html/storage. Legacy paths such as
/var/www/html/storage/media overlap that root and are refused before configuration
is saved or writers stop. Keep the original deployment intact until its owner
explicitly relocates those paths; adoption does not relocate them automatically.
The selected PostgreSQL container must have recorded persistent PGDATA. Existing Redis data, OAuth keys and writable application mounts must also be discoverable. Missing Redis persistence, incompatible migration history, ambiguous mounts or mismatched identity cause refusal. Adoption does not make an incompatible database compatible. Inspect the reported failure and preserve its resources; any manual data reset requires separate owner approval. An origin change can also require re-enrolling passkeys.
Runtime qualification
Run the integrated acceptance harness on a host that has completed DNS, trust and SELinux setup. It needs unused loopback port 443, enough Docker capacity for three instances, host FFmpeg/ffprobe, the PHP OpenSSL extension, and the web workspace's installed Playwright package and Chromium browser.
Select a new workspace and a separate state directory outside the checkout:
BAANDER_TEST_IMAGE=baander-ci-qualified:local \
BAANDER_TEST_POSTGRES_IMAGE=baander-database:latest \
BAANDER_QUALIFICATION_WORKSPACE=/absolute/private/qualification/workspace \
BAANDER_QUALIFICATION_STATE_ROOT=/absolute/private/qualification/state \
bash scripts/test-dev-environment-container.sh
Select locally built CI and extension-capable PostgreSQL images for the first two
variables. The integrated scenarios build their application releases from a
throwaway Git remote seeded from the current committed revision. The controller
runs from the current checkout. The harness copies the trusted leaf certificate
and public CA from the default host state; it never copies the CA private key.
Use BAANDER_QUALIFICATION_HOST_STATE_ROOT when host setup used another state root.
The harness checks repeated startup, unattended master updates, build and schema
failures, two simultaneous previews, stop/resume races, queued ingestion and
complete original/transcoded playback. It enables audio transcoding only on its
disposable instances. Browser checks follow UI links, use normal certificate
verification and require the preview's HTTPS HMR connection. A successful run removes
its owned runtime resources and retains evidence in the workspace. A failed run
disables its timer, preserves resources and writes failure-context.json for
inspection. Source media hashes and unrelated Docker inventories are checked.
CI runs bash scripts/test-dev-environment-container.sh --components-only for
component safety and worker source-admission checks. Those checks do not qualify
the host or replace the integrated run. Current delivery evidence is recorded in
ROADMAP.md.
Agent instructions
AGENTS.md gives agents repository instructions and links to
canonical rules in .agents/rules/. Update affected guidance when conventions change.