# 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:

```bash
./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:

```bash
php core/cli/nimbly.php system:setup   # require host PHP
./nimbly --docker system:setup         # force Docker Compose
```

Equivalent direct invocations:

```bash
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:

```bash
./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) |

```bash
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.

```bash
./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.

```bash
./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.

```bash
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.

```bash
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.

```bash
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`.

```bash
./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. |

```bash
./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:

```bash
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:

```php
[
    '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`:

```json
{
  "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:

```bash
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:

```bash
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:

```bash
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).
