Reference
CLI
Every command of ./nimbly: setup, builds, tests, upkeep, scheduler and statistics.
Nimbly ships a CLI at core/cli/nimbly.php. The root ./nimbly launcher is the preferred way to invoke it:
./nimbly init
./nimbly up
./nimbly deps
./nimbly build
./nimbly watch
./nimbly test:run
./nimbly user:create
./nimbly module:install <name>
init, deps, build, and watch use npm internally. PHP-backed commands use host PHP when available and fall back to Docker automatically. Explicit variants are also available:
php core/cli/nimbly.php system:setup # require host PHP
./nimbly --docker system:setup # force Docker Compose
Equivalent direct invocations:
php core/cli/nimbly.php system:setup
php core/cli/nimbly.php user:create
php core/cli/nimbly.php module:install <name>
php core/cli/nimbly.php routes:sync
php core/cli/nimbly.php index:rebuild [resource]
php core/cli/nimbly.php data:cache:clear
php core/cli/nimbly.php user:email-index:rebuild
php core/cli/nimbly.php system:repair
php core/cli/nimbly.php help
Documentation lookup
The documentation commands are read-only and read only the local NIMBLY.md.
Use them to load the smallest useful reference section while working:
./nimbly docs:list
./nimbly docs:section "Template Syntax"
./nimbly docs:section "Template Syntax > Route templates"
./nimbly docs:search "router_accept"
Matching is case-insensitive. A section path may identify a nested heading; a heading name without a path must resolve uniquely. Missing and ambiguous sections return an error.
Commands
./nimbly help lists the commands. ./nimbly <command> --help (or -h) prints what one command does and takes, without running it.
system:setup
Sets up the local system. Safe to re-run - existing files and records are never overwritten.
What it does:
- Creates
.envwithAPP_ENV, a generatedPEPPER, andBASE_PATHonly for subdirectory installs - Generates
.htaccessfrom template - Generates
.user.inifrom template for PHP-FPM runtime settings - Creates the
ext/directory scaffold (data/,static/,lib/,modules/,tpl/,uri/, temp dirs) - Gives a site without any page a first home page (
ext/uri/index.tplandmain.tpl): a short, editable page that shows where pages, content and data live, with one example resource (ext/data/messages/.meta) and one form that saves to it (ext/uri/message.json), closed to visitors. It is yours to change or remove; a site that already has a route is left alone - Creates
.config/site, the.contentresource, core.routesrecords, and default roles (admin,editor) - Creates the
usersresource and an initial admin user
Prompts: Site name, Admin email, Admin password. Steps that are already complete are skipped silently.
Non-interactive (CI/CD): Set environment variables to skip all prompts:
| Variable | Description |
|---|---|
APP_ENV |
Environment label (prod, stage, dev; default dev) |
BASE_PATH |
Optional URL base path for subdirectory installs, e.g. /mysite/; omit for root installs |
PEPPER |
Encryption pepper - set to reuse an existing value; omit to auto-generate |
EXT_REPO |
Git remote URL for the ext repo (written to ext/readme.md) |
SITE_NAME |
Site name written to .config/site |
SITE_LANGUAGE |
Validated lowercase two-letter primary language for a new site (default en) |
ADMIN_EMAIL |
Initial admin user email |
ADMIN_PASSWORD |
Initial admin user password (min 8 chars) |
SITE_NAME="My Site" ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret123 ./nimbly system:setup
Without a terminal (a container start, a scheduler) setup never waits for an answer. A missing SITE_NAME becomes My Nimbly Site. A site without users needs ADMIN_EMAIL and ADMIN_PASSWORD: when one is missing, setup stops with one line that names it and exit code 1, so a production container on an empty volume does not start until both are set.
user:create
Creates an additional user account. Prompts for email, role, and password interactively. Available roles are read from ext/data/roles/. The user is always also assigned the user role. Requires setup to have been run first.
module:install
Runs a module's .install.inc script. Looks in ext/modules/ first, then core/modules/ as fallback. Requires setup to have been run first.
./nimbly module:install event
routes:sync
Scans ext/uri/**/route.inc and creates missing records in .routes for dynamic routes. Use this after adding a new route folder with (param) segments.
./nimbly routes:sync
index:rebuild
Rebuilds index entries for an indexed resource. Use this after adding index to an existing resource's .meta, or after importing records outside of the normal API flow.
php core/cli/nimbly.php index:rebuild # interactive: lists indexed resources, prompts for choice
php core/cli/nimbly.php index:rebuild articles # direct: reindex the 'articles' resource
The command scans all records in the resource and creates any missing index files. It is idempotent - existing entries are left untouched.
data:cache:clear
Throws away the cached query results of every resource; the next read builds them again. Use this after editing a record file in place by hand (see Data caching). Saving through the site, the API, Git or ext:sync needs no clearing.
php core/cli/nimbly.php data:cache:clear
user:email-index:rebuild
Adds email lookup metadata to the users resource and rebuilds its email index.
php core/cli/nimbly.php user:email-index:rebuild
php core/cli/nimbly.php user:email-index:rebuild --yes
This migration supports the user identity model, where the user
record UUID is stable and email is an indexed lookup field. It adds email to
users/.meta.index, adds email to users/.meta.unique when no duplicate
emails exist, and creates index entries for all existing users, including
legacy users whose UUID was originally md5(email).
If duplicate emails are found, the command reports them and skips adding
unique=email until the duplicates are resolved. The email index is still
rebuilt so login lookups remain fast where possible.
test:run
Runs the E2E suite. The command creates temporary test data with test:setup, installs the Chromium Playwright browser if needed, runs Playwright using core/tests/playwright.config.js, then removes the temporary data with test:teardown.
./nimbly test:run
./nimbly --docker test:run
Use --docker in CI or whenever PHP data files must be written through the same Docker user model as the web runtime.
Core tests must not call metered external providers. Mock the provider boundary
and test Nimbly's request, response handling, validation, and UI behavior
deterministically. Provider availability is an operational integration check,
not part of the Core test suite.
test:setup / test:teardown
Low-level commands used by ./nimbly test:run. test:setup creates the test role, test@nimbly.dev user, test-records resource, and seed records. test:teardown removes those records and the temporary .test route config entries.
system:repair
Brings generated files and system records level with the current core. A core update never rewrites them, so an older site can miss what a new install gets from system:setup. Each check reports first; nothing changes without --yes.
| Check | Repaired when |
|---|---|
Root .htaccess |
It differs from core/cli/setup/htaccess.tpl. The pepper and the rewrite base are taken from the existing file; the previous file is kept as .htaccess.before-repair. |
| Directory guards | An .htaccess under ext/ or core/ is missing or still in the old <files *.*> form. A guard with its own content is reported and left alone. |
ext/.gitignore |
It does not ignore the thumbnail cache, the job queue, the scheduler state or the agent chats and runs. |
.config/.meta |
It has no upsert, so page settings cannot be saved for a page that has none yet. |
| Core routes | A core route added after the site was set up is not registered. |
| Users email index | users/.meta does not index email. unique is added only when no two users share an address. |
./nimbly system:repair # report only
./nimbly system:repair --yes # apply
The ignored folders change on every server by themselves (the scheduler state every minute), so tracking one makes ext:sync conflict and can leave the server's ext/ stuck in a rebase. The ignore rule does not untrack a folder that is already tracked:
git -C ext rm -r --cached data/.state
schedule:run
Runs due scheduled commands for one project. On manual VPS servers with multiple
Nimbly projects, do not create one cron entry per project. Use the server-level
scheduler orchestrator instead and let it call each registered project's
schedule:run sequentially.
Projects may define schedules in ext/cli/. The scheduler selects files in this order:
SCHEDULE_FILEfrom.envor the process environment, as an explicit file path.ext/cli/schedule.<env>.inc, where<env>comes fromSCHEDULE_ENV,APP_ENV, orNIMBLY_ENV.ext/cli/schedule.inc.core/cli/schedule.inc.
Environment aliases are normalized: production → prod, staging → stage, development and local → dev.
Use environment-specific schedule files when staging or development must run background jobs but must not run production-only tasks such as member reminders.
Schedule tasks may use every_minutes for intervals longer than one minute:
[
'id' => 'uptime-poll',
'command' => 'uptime:poll',
'every_minutes' => 2,
]
The value must be a positive integer. A new task is due immediately; after it
runs, it becomes due when the clock enters the next interval bucket. Existing
every, daily_at, and optional timezone schedules remain supported.
Scheduler last-run state is stored in ext/data/.state/schedule. Existing installs with older ext/data/.config/schedule state are read as a migration fallback until the scheduler writes the new state file.
Mandatory maintenance and session expiry
Every schedule:run invocation includes the Core task definitions before it
loads the application's schedule. Application schedules add tasks; they cannot
remove or reschedule these mandatory invocations:
| Task ID | Command | Interval |
|---|---|---|
sessions-prune |
sessions:prune |
30 minutes |
jobs-run |
jobs:run 10 |
1 minute |
jobs-prune |
jobs:prune --days=30 |
24 hours |
history-prune |
history:prune --days=90 |
24 hours |
agent-prune |
agent:prune --days=30 |
24 hours |
stats-rollup |
stats:rollup |
1 hour |
Existing application entries with these IDs or commands are deduplicated.
Override an implementation, not its schedule: define the same command in
ext/cli/commands.php, using the normal command registry contract. Core trusts
the implementation's exit status. No inspection of its internal work occurs.
The host registry lists projects, not task choices; legacy enabled flags no
longer suppress registered projects. Remove retired deployments from the
registry. Before registering an older unscheduled project, review its queued
jobs and application schedule: enabling processing may deliver pending email
or execute other queued work.
./nimbly schedule:status reports successful mandatory task completion and
exits nonzero when a task failed, never succeeded, or is overdue by more than
two intervals. A successful run with an empty queue is healthy. The admin
Needs attention band and host audit independently read this state, so they
also detect a stopped scheduler. Check this command after every deployment;
installing cron alone is not proof of working maintenance. The installed host
audit ships a root-owned nimbly-maintenance.php helper beside its collector.
For VPS installations, use the existing host orchestrator and register all
active projects. Container images already invoke the project scheduler every
minute. Other cloud platforms must invoke the same CLI every minute using a
single scheduler with access to the application's persistent ext/data and
runtime configuration. Monitor schedule:status independently of that runner.
No extra session-cleanup daemon or per-project cleanup registration is needed.
Session functions, including file inspection and cleanup, live in
core/lib/session.php. Defaults are rolling inactivity limits: anonymous
4 hours, authenticated users 3 days, admins 7 days. There is no absolute
login-age limit: regular activity keeps an authenticated session alive.
Session ID rotation does not reset its identity or sign the user out.
Anonymous reads and permission checks do not create sessions; forms and other
stateful features may do so. Authenticated cookies persist across browser
restarts; anonymous cookies remain browser-session cookies.
Every cookie core sets has the same options (session_cookie_options()): the
site's base path, HttpOnly, SameSite=Lax, and Secure on https. That goes
for the session cookie, the form key of a visitor without a session (key) and
a variable kept with [#set session ...#] when there is no session; the last
two last 30 days (persistent_cookie_options()). Scripts in the page cannot
read them.
Projects can configure durations in seconds under session in .config/site:
{
"session": {
"anonymous": 14400,
"authenticated": 259200,
"roles": { "admin": 604800, "editor": 259200 }
}
}
The longest matching assigned-role override wins; otherwise the authenticated default applies. Temporary role switching does not change the assigned-role snapshot. Legacy sessions retain their original 24-hour validity until their first valid live request upgrades them; an already-expired session is never revived. Login, cookies, and cleanup use the same policy. Core disables PHP's request-triggered garbage collection for its private session directory.
./nimbly sessions:prune --dry-run reports aggregate counts without changing
sessions. Normal execution streams files, skips locked files and symlinks, and
removes expired sessions only. Malformed, unsupported serialized objects or
references, and files larger than 1 MiB are reported as failures rather than
deleted. The reader accepts scalar/array php and php_serialize encodings.
The separate Sign out everyone action intentionally removes every session.
Remember-me UI and its longer timeout are deferred.
Request statistics (stats:*)
Core records every request that reaches index.php without cookies or
JavaScript. Recording is on for APP_ENV=prod and stage; set
STATS_ENABLED=1 or 0 to override. Files served directly by .htaccess
(ext/static, core/static, /img/ thumbnails) and blocked .php probes
never reach PHP and are not recorded.
A shutdown function appends one JSON line per request to
ext/data/.tmp/stats/running-YYYY-MM-DD.log: UTC time, method, host, path with
query (relative to the base url), status, content type, duration, IP, user agent, referrer, first
Accept-Language tag and a logged-in flag. Past STATS_MAX_DAY_BYTES
(default 200 MB) a day only counts further requests as overflow.
stats:rollup (mandatory maintenance, hourly)
archives finished days under ext/data/.stats/<env>/, which ext:sync
commits:
| Path | Content |
|---|---|
raw/YYYY/YYYY-MM-DD.log.gz.enc |
the complete day, gzipped and AES-256-GCM encrypted |
raw/YYYY/YYYY-MM-DD.apache.log.gz.enc |
optional Apache enrichment from apache-YYYY-MM-DD.log |
months/YYYY-MM.json |
complete counts per day by status, route, visitor class, device, browser, language, bot, referrer and path, plus hours (requests, pageviews, visitors, bots, scanners per UTC hour) |
years/YYYY.json |
the same counts summed per month, plus a year total |
All dates, file names and day boundaries are UTC. Interfaces convert to the
viewer's timezone by regrouping hours; a visitor counts in the hour of their
first pageview of the UTC day. Zoom from year to month to day to the raw requests; weeks and all-time views
are derived from these files. Above day level visitors becomes visits, the
sum of daily unique visitors. The JSON files hold counts only, never rankings
or IP addresses; interfaces derive top lists from them. Classification (human, editor, bot, scanner,
suspect, monitor, tool) happens during rollup, so stats:rollup --rebuild
recomputes every month from raw/ after rules improve.
stats:import-apache reads Apache access log lines (combined or
vhost_combined) from stdin, so only the log read needs root:
zcat -f /var/log/apache2/site-access.log.* | sudo -u www-data ./nimbly stats:import-apache --mode=full --before=2026-09-25T02:02:00Z
zcat -f /var/log/apache2/site-access.log.1 | sudo -u www-data ./nimbly stats:import-apache
--mode=full backfills every line before --before (days before recording
started). The default --mode=enrich only adds what never reaches PHP: files
served from ext/static or core/static, /img/ thumbnails and blocked
.php probes. Lines are written to apache-YYYY-MM-DD.log (UTC dates) and
archived by the next rollup; a watermark in .tmp/stats/apache.watermark skips
lines already imported. Use --base=/subdir/ for subdirectory installs and
--host= when SITE_URL is not set. Apache lines have no Accept-Language,
content type or login state: content types are guessed from the path and
editors count as visitors.
STATS_KEY (64 hex characters, generated by system:setup) encrypts the raw
archive. Back it up: without it the raw history cannot be read. While it is
missing, rollup fails and keeps the running logs. stats:raw YYYY-MM-DD [--apache] prints a decrypted day.
scheduler:*
Runs multiple project schedulers from one server-level cron. The project schedule
files remain project-local in ext/cli/; the server registry is the only source
of truth for which projects are orchestrated.
The orchestrator uses these server-managed files by default:
| Path | Purpose |
|---|---|
/usr/local/bin/nimbly-scheduler-orchestrator |
Wrapper run by cron |
/etc/nimbly/scheduler-projects.json |
Registered projects and defaults |
/etc/cron.d/nimbly-scheduler |
System cron entry |
/var/log/nimbly-scheduler.log |
Scheduler output log |
/etc/logrotate.d/nimbly-scheduler |
Weekly rotation of the log, eight weeks kept |
Install it once per server from any current Nimbly core checkout:
sudo php core/cli/nimbly.php scheduler:install
sudo php core/cli/nimbly.php scheduler:project:add site-name /var/www/site-name
sudo php core/cli/nimbly.php scheduler:cron:install --user=www-data
Useful management commands:
php core/cli/nimbly.php scheduler:project:list
sudo -u www-data php core/cli/nimbly.php scheduler:run --dry-run
sudo -u www-data /usr/local/bin/nimbly-scheduler-orchestrator
php core/cli/nimbly.php scheduler:cron:status
scheduler:run takes a short global lock and dispatches two workers per
registered project: one runs jobs:run 10 for prompt queue delivery, and the
other runs the project's schedule:run. Workers log their completion and exit
code to the host scheduler log. A long application task therefore does not
delay another project's minute tasks or its own queued alerts. Job runners use
a per-project lock so the direct queue worker and mandatory jobs-run task do
not dispatch the same job concurrently. The project scheduler retains its own
lock, so later cron ticks do not duplicate a long schedule run. Legacy
default_delay_after_seconds values in the registry are ignored.
scheduler:run itself always dispatches every registered project's workers
immediately (it never waits before spawning), so a slow project cannot delay
the next cron tick's dispatch. Concurrency is instead capped inside each
scheduler:worker: before running the project's real jobs:run/schedule:run,
a worker blocks on a shared slot semaphore, and releases it when done. This
keeps a large fleet from pushing every project's worker onto a single-CPU host
at the same instant. The cap defaults to 5 simultaneous workers across the
whole host and is configurable with NIMBLY_SCHEDULER_MAX_CONCURRENCY; the
slot lock files themselves live under NIMBLY_SCHEDULER_SEMAPHORE_DIR
(default: the system temp directory).