Console commands and the CLI
The navree CLI (new, every make:* generator, dev), your app's own commands (serve, migrate, queue:work, ...), and writing your own.
On this page
Navree has two command lines, much like Laravel's laravel installer and Artisan:
- The
navreeCLI creates apps, generates code and runs the development loop. It doesn't depend on any Navree crate: everything it knows about the framework is in the code it writes. - Your app's binary owns the runtime commands:
serve,migrate,queue:workand the rest, registered by the modules you use, plus your own.navree <command>forwards to it, so you rarely call the binary yourself.
Installing the CLI
The CLI is the navree-cli crate. Navree isn't published on crates.io yet, so install it from the Git repository:
cargo install --git https://github.com/NavreeHQ/navree navree-cliFrom a clone of the repository, cargo install --path crates/navree-cli does the same.
navree --helpCreate Navree apps, generate code and run the dev loop
Usage: navree <COMMAND>
Commands:
new Create a new app in ./NAME
make:module Generate a feature module: mod.rs, controller.rs, service.rs, entity.rs, dto.rs, migrations/, tests/
make:controller Generate a controller in a module (controllers/NAME.rs)
make:service Generate a service in a module (services/NAME.rs)
make:entity Generate a database entity in a module (entities/NAME.rs)
make:migration Generate a migration in a module (create_TABLE_table creates the table)
make:job Generate a queued job in a module (jobs/NAME.rs)
make:event Generate an event in a module (events/NAME.rs)
make:listener Generate an event listener in a module (listeners/NAME.rs)
make:mail Generate a mail with HTML and text templates in a module (mail/NAME.rs)
make:notification Generate a notification in a module (notifications/NAME.rs)
make:policy Generate ability rules in a module (policies/NAME.rs), added to src/abilities.rs
make:command Generate a CLI command in a module (commands/NAME.rs)
make:test Generate a test in a module (tests/NAME.rs)
dev Serve the app, rebuilding and restarting it when the code changes
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Every other command runs in the app binary: `navree <command> [args...]` is `cargo run --quiet -- <command> [args...]` (migrate, migrate:fresh --seed, db:seed, queue:work, schedule:run, routes, check --graph, openapi:export, key:generate, <module>:<command>, ...). `navree list` shows the app's commands.Creating an app
navree new <name> creates a working app in ./<name>:
navree new blogCreated blog in /home/ada/code/blog
It depends on navree at /home/ada/src/navree/crates/navree.
Next steps:
cd blog
navree key:generate # then set APP_KEY in .env
navree dev # http://127.0.0.1:8080/api/docs- .enva copy of .env.example (never commit it)
- .env.example
- Cargo.toml
- README.md
- config/
- app.tomlhttp, database, log, mail, queue, auth, api
- src/
- main.rsblog::app(ConfigSources::defaults()).run()
- lib.rs
- app.rsthe root: every module, configured once
- abilities.rswho may do what
- modules/
- mod.rs
- users/profiles at /api/v1/profile, on top of AuthModule
- tests/
- app.rs
- storage/
- .gitignore
| Option | What it does |
|---|---|
--ui next | Also creates web/, a Next.js front end that talks to the API through its own server and keeps the tokens in httpOnly cookies, like the CRM's. navree dev starts it. Default: --ui none, API only. |
--navree-path <path> | Depend on a local checkout (the repository or its crates/navree). Without it, the app gets a path dependency on the checkout the CLI was built from when it still exists (after cargo install --git, Cargo's own copy of the repository), else navree = "0.1.0", which won't build until the crate is on crates.io. |
The name may use lower-case letters, digits, - and _. navree new refuses a directory that exists and isn't empty, and formats the generated code with rustfmt when it is installed. The app is its own Cargo workspace; remove the [workspace] table from its Cargo.toml to make it a member of an enclosing one. See Your first app for a walk through it.
Generators
Run generators anywhere inside your app: the CLI finds the package from the current directory. They never overwrite a file (refusing to overwrite stops them before anything is written), and they register what they create by editing your code only at marker comments:
| Marker | In | Where generators add |
|---|---|---|
// navree:modules | src/app.rs | .module(...) lines for new modules |
// navree:wire | a module's wire method | registrations: controllers, providers, jobs, listeners, templates, commands, migrations |
// navree:policies | src/abilities.rs | calls to new policies |
When a marker is missing (in code you wrote by hand), the generator prints the line to add as a next hint instead. Names are snake_case; anything else fails with a suggestion (blog_posts for BlogPosts).
make:module
navree make:module <name> generates a feature module and adds it to the app:
navree make:module posts --crud created src/modules/posts/mod.rs
created src/modules/posts/controller.rs
created src/modules/posts/service.rs
created src/modules/posts/entity.rs
created src/modules/posts/dto.rs
created src/modules/posts/migrations/mod.rs
created src/modules/posts/migrations/m20261026_081536_create_posts_table.rs
created src/modules/posts/tests/mod.rs
updated src/modules/mod.rs
updated src/app.rsWith --crud, the module serves the five JSON resource routes under /api/v1/posts (list, show, create, update, delete), each guarded by abilities on its entity, with validated DTOs and tests. Without it, the module has one authenticated index route to start from. Use the plural for the name: posts gets the entity Post and the table posts. --path changes the modules directory (default src/modules).
Other generators
Every other generator adds one item to an existing module: navree make:<kind> <module> <name> [--path src/modules].
navree make:job posts publish_post created src/modules/posts/jobs/publish_post.rs
created src/modules/posts/jobs/mod.rs
updated src/modules/posts/mod.rs
updated src/modules/posts/mod.rsThe report lists a file once per edit: here mod.rs gets the pub mod jobs; declaration and the job's registration.
| Command | Creates (in the module) | Registers |
|---|---|---|
make:controller posts admin | controllers/admin.rs, AdminController | the controller, in wire |
make:service posts pricing | services/pricing.rs, PricingService | m.provider::<..>(), in wire |
make:entity posts tag | entities/tag.rs, a SeaORM entity for tags | nothing; prints the make:migration to run next |
make:migration posts create_tags_table | migrations/m<timestamp>_create_tags_table.rs | adds it to the module's migration set. A create_<table>_table name generates a table-creating migration |
make:job posts publish_post | jobs/publish_post.rs, a job named posts.publish_post | the job, in wire |
make:event posts post_published | events/post_published.rs | nothing (events need no registration) |
make:listener posts notify_followers --event post_published | listeners/notify_followers.rs | listen::<PostPublished, NotifyFollowers>, in wire. Without --event, prints the line to add |
make:mail posts welcome | mail/welcome.rs with welcome.html and welcome.txt | the templates, in wire |
make:notification posts post_liked | notifications/post_liked.rs, a database and broadcast notification | nothing |
make:policy posts moderation | policies/moderation.rs, ability rules | a call in src/abilities.rs |
make:command posts publish_scheduled | commands/publish_scheduled.rs, the command posts:publish-scheduled | m.command(..), in wire |
make:test posts publishing | tests/publishing.rs, a test booting the module | declares it in tests/mod.rs |
make:listener --event needs the event to exist (make:event first). The guides for each kind say what to fill in: controllers, entities, migrations, jobs, events and listeners, mail, notifications, policies and tests.
The development server
navree dev builds the app, runs its serve command, and rebuilds and restarts it when the code changes:
navree dev
navree dev --no-web -- --addr 0.0.0.0:8080| Option | Default | What it does |
|---|---|---|
--debounce-ms <ms> | 300 | How long the files must be quiet before a rebuild. |
--no-web | Don't start the front end in web/. | |
-- <args> | Arguments for the app's serve command. |
- It watches
src/,config/andmigrations/, andCargo.toml,.envandbuild.rsat the root. Template files underresources/aren't watched: views reload on their own. - It runs a copy of the binary, so the next build can replace the original while the current server keeps answering, and restarts the server only once the new build succeeds. When a build fails, the previous one keeps serving and the compiler errors are shown.
- With a
web/package.json, it also starts the front end's dev server once, withpnpm,yarnornpmdepending on the lockfile.
Two limits: the listening socket isn't kept across restarts, so requests fail for the moment the new server takes to start (and open WebSockets drop and reconnect), and if navree dev itself is killed forcibly, the server it started keeps running.
Running your app's commands
Any command the CLI doesn't know is forwarded to your app: navree migrate:fresh --seed runs cargo run --quiet -- migrate:fresh --seed in the current directory and exits with the command's exit code. You can also run the binary directly, which is what you do in production:
navree migrate:status
cargo run -- migrate:status # the same, without the CLI
./target/release/blog migrate # a release buildYour app's main hands the command line to AppBuilder::run:
fn main() -> Result<(), navree::kernel::BoxError> {
// `.env`, then `config/app.toml`, then `APP_*` environment variables.
blog::app(ConfigSources::defaults()).run()
}- With no command, it runs
servewhen a module registers it (HttpModuledoes), otherwise it prints the help. help(alsolist,--helpand-h) lists every command, andcheck [--graph]validates the module graph; neither boots the app. Through the CLI, usenavree list:navree helpandnavree --helpprint the CLI's own help.- Every other command boots the whole app (configuration, database, every module), runs, then shuts the app down gracefully. A command therefore needs a working configuration and database, even
key:generate. - An unknown command fails before the app boots. A failing command prints its error and exits with status 1. The error is printed in its
Debugform, becausemainreturns it (Error: UnknownCommand { name: "x" }). Ctrl-C (andSIGTERMon Unix) stops long-running commands such asserveandqueue:workgracefully.
This is list in the reference CRM, which uses most modules:
Usage: <command> [args...]
Built-in commands:
check [--graph] Validate the module graph without constructing providers
help List the available commands
Commands:
key:generate Print a new application key for `app.key` (APP_KEY)
serve Start the HTTP server ([--addr HOST:PORT])
routes List every route with its access declaration ([--json])
down Put the app into maintenance mode ([--retry SECS] [--message TEXT])
up Bring the app out of maintenance mode
migrate Run every pending migration
migrate:rollback Roll back the last batch of migrations
migrate:fresh Drop every table and run every migration ([--seed])
migrate:status Show which migrations have run
db:seed Run the seeders ([name...] to run only some)
cache:clear Remove every entry from the cache store
queue:work Run queued jobs until shutdown ([--queue a,b] [--once] [--stop-when-empty] [--max-jobs N])
queue:failed List the failed jobs
queue:retry Put failed jobs back on their queues (<id> or all)
queue:flush Delete every failed job
schedule:run Run the scheduled tasks due this minute (call it every minute from cron)
schedule:work Run the scheduler in the foreground, ticking every minute until stopped
schedule:list List the scheduled tasks and their next run
access:role Create a role: access:role <name> [description]
access:grant Give a role a permission: access:grant <role> <permission>
access:explain Show the rule deciding a check: access:explain <user> <action> <subject> [id]
audit:prune Delete audit entries older than the retention period ([--days N])
openapi:export Write the OpenAPI document to a file ([path], default openapi.json)
crm:task-reminders Notify everyone of their open tasks due today (UTC)The built-in commands
check validates every import, export and provider dependency without constructing anything, which makes it a cheap CI step:
navree checkmodule graph OK: 33 modules, 84 providerscheck --graph prints the module tree instead: each module with its providers, their dependencies, its exports and its commands.
Commands by module
Each module registers its own commands, so list shows only those of the modules your app uses.
| Command | Module | Example |
|---|---|---|
serve [--addr HOST:PORT] | HttpModule | navree serve --addr 0.0.0.0:8080. Serves until stopped, then drains requests for http.shutdown_grace_secs (30 s). |
routes [--json] | HttpModule | navree routes prints every route with its name, access rule and module. |
down [--retry SECS] [--message TEXT], up | HttpModule | See Maintenance mode. |
key:generate | CryptoModule | Prints a new base64:... key for APP_KEY. See Encryption and hashing. |
migrate, migrate:rollback, migrate:status | DatabaseModule | See Migrations. |
migrate:fresh [--seed] | DatabaseModule | Drops every table, then migrates (and seeds). |
db:seed [name...] | DatabaseModule | Runs every seeder, or only the named ones. See Seeding. |
cache:clear | CacheModule | See Cache. |
queue:work [--queue a,b] [--once] [--stop-when-empty] [--max-jobs N] | QueueModule | See Queues. |
queue:failed, queue:retry <id or all>, queue:flush | QueueModule | Inspect, retry or delete failed jobs. |
schedule:run, schedule:work, schedule:list | ScheduleModule | See Task scheduling. |
access:role, access:grant, access:explain | AccessModule | See Authorization. |
audit:prune [--days N] | AuditModule | See The audit log. |
openapi:export [path] | ApiModule | See OpenAPI. |
A few of them in the CRM, against a freshly seeded database:
navree routesMETHOD PATH NAME ACCESS MODULE
POST /api/auth/login auth.login public AuthModule
GET /api/auth/me auth.me auth AuthModule
GET /api/v1/deals can:read Deal DealsModule
POST /api/v1/deals/{id}/move can:move Deal from id DealsModule
GET /health/ready health.ready public ObserveModule
...
62 routesnavree migrate:statusBatch Status Migration
1 Ran audit/m20261025_000001_create_audit_logs
1 Ran auth/m20261025_000001_create_users_table
1 Ran auth/m20261025_000002_create_refresh_tokens_table
...navree db:seed
navree schedule:list
navree queue:work --stop-when-emptySeeded: crm-roles
Seeded: crm-demo
Expression Next run Task
0 8 * * * 2026-10-27 08:00 UTC crm:task-reminders (without overlapping)
0 3 * * * 2026-10-27 03:00 UTC audit:prune (without overlapping)
Processed 0 job attempt(s): 0 succeeded, 0 released for retry, 0 failed.Writing commands
A command is a CommandSpec: a name, a one-line description for list, and an async function that gets the booted app's AppContext and the arguments after the command name. navree make:command <module> <name> generates one; here is a complete example:
use navree::kernel::{AppContext, BoxError, CommandSpec};
use navree::storage::Disk;
/// `exports:clear [--dry-run]`: deletes every generated export.
pub fn clear_exports() -> CommandSpec {
CommandSpec::new(
"exports:clear",
"Delete every generated export ([--dry-run])",
run,
)
}
async fn run(ctx: AppContext, args: Vec<String>) -> Result<(), BoxError> {
let dry_run = args.iter().any(|arg| arg == "--dry-run");
if let Some(unknown) = args.iter().find(|arg| *arg != "--dry-run") {
return Err(format!("unknown option `{unknown}` (expected --dry-run)").into());
}
let disk = ctx.get::<Disk>().await?;
let files = disk.list("exports").await?;
if !dry_run {
for file in &files {
disk.delete(file).await?;
}
}
println!("{} {} export(s).", if dry_run { "Would delete" } else { "Deleted" }, files.len());
Ok(())
}Register it in the module's wire method with m.command(..):
mod commands;
use navree::prelude::*;
#[module(register = Self::wire)]
pub struct ExportsModule;
impl ExportsModule {
fn wire(&self, m: &mut ModuleBuilder) {
m.command(commands::clear::clear_exports());
// navree:wire
}
}navree exports:clear --dry-runWould delete 3 export(s).- Names are unique across the app; two modules registering the same name fail at boot (and in
check). Prefix them with the module (exports:clear,crm:task-reminders), like Navree's ownqueue:work. - Arguments arrive as plain strings, with no parsing: read flags and values from
argsyourself, and return an error for anything you don't understand, as above. - Services come from the context:
ctx.get::<T>().await?resolves any provider, ignoring module visibility. - Output goes to standard output with
println!; logs go throughtracingas usual. - Failure: return
Err(..)and the binary prints it and exits with status 1.
Scheduling a command
A command can run on a schedule, as the CRM's daily reminders do (see Task scheduling):
fn wire(&self, m: &mut ModuleBuilder) {
m.command(clear_exports());
m.schedule(|s| {
s.command("exports:clear", &[]).daily_at("04:00").without_overlapping();
});
}Testing commands
TestApp::run_command runs a command against the test app, without booting a second one:
use navree::prelude::*;
use navree::storage::{Disk, DiskConfig, StorageOptions};
use navree::testing::TestApp;
use super::ExportsModule;
#[tokio::test]
async fn clearing_exports_deletes_them() {
let app = TestApp::builder()
.module(StorageModule::for_root(StorageOptions {
default: "memory".into(),
disks: [("memory".to_owned(), DiskConfig::Memory)].into(),
..StorageOptions::default()
}))
.module(ExportsModule)
.build()
.await;
let disk = app.get::<Disk>().await;
disk.put("exports/contacts.csv", "id,name\n").await.unwrap();
app.run_command("exports:clear", &[]).await.unwrap();
assert!(disk.list("exports").await.unwrap().is_empty());
assert!(app.run_command("exports:clear", &["--force"]).await.is_err());
app.shutdown().await.unwrap();
}