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 .env with APP_ENV, a generated PEPPER, and BASE_PATH only for subdirectory installs
  • Generates .htaccess from template
  • Generates .user.ini from 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.tpl and main.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 .content resource, core .routes records, and default roles (admin, editor)
  • Creates the users resource 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:

  1. SCHEDULE_FILE from .env or the process environment, as an explicit file path.
  2. ext/cli/schedule.<env>.inc, where <env> comes from SCHEDULE_ENV, APP_ENV, or NIMBLY_ENV.
  3. ext/cli/schedule.inc.
  4. 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).

View this page as Markdown