0.1.0GitHub
Digging DeeperConsole

Digging Deeper

Console

On this page

Introduction

marmeon is the app's command line. It starts the development server, builds and starts the app, runs migrations and workers, writes new files from templates, and runs the commands your modules add. Run it in the app's directory:

pnpm marmeon list
pnpm marmeon route:list
pnpm marmeon make:controller ShowNote --module=notes --view

marmeon list, or marmeon alone, prints every command the app has, grouped by the part before the colon. The list grows with the app: every installed package adds its commands, and so does every module that lists some. With npm, run the commands as npx marmeon ….

Running commands

The marmeon package is a dependency of the app, not a dev dependency. marmeon start, migrate, queue:work and schedule:work run on the server, and a production install leaves dev dependencies out.

Most commands boot the app first: they load .env, check the configuration and register every provider, just as the server does. A command that cannot boot the app prints the error and the known solutions, and exits with 1:

✗ Invalid configuration — the application cannot start:
  • encryption: APP_KEY — Missing — run "marmeon key:generate"
Set these variables in the environment or in .env (see .env.example).

ℹ How to fix it: Generate an application key
  APP_KEY encrypts the cookies …
  $ marmeon key:generate

The generators, build, discover, key:generate, make:lint-config and make:docker work without booting, so they run before the app has a valid .env. route:types registers the app's providers without booting it, as the build does, and needs no secret either.

A command's options are checked before it runs. An unknown option is an error that prints the command's usage line. marmeon <command> --help (or -h) prints the command's description, its usage line and its options, and runs nothing; after a --, --help is an argument like any other. marmeon list shows each command with its description. An unknown command says so and exits with 1.

The environment comes from NODE_ENV in the shell, then from the app's .env. marmeon dev is development without one and marmeon start and marmeon build production. Every other command takes what it finds, and without a NODE_ENV it behaves like production: migrate then asks for --force. A NODE_ENV other than development, test or production stops every command before it runs. The configuration page explains the rules.

The development server

marmeon dev, which pnpm dev runs, serves the app with Vite in front of it and starts what the app needs next to it. The installation page shows it from the outside. Under the hood:

  • Server code is watched by Node: a change to a route, a controller or a provider restarts the server. A change to a page reaches the browser at once, without a restart.
  • A queue worker on every queue starts next to the server, unless the default queue connection is sync.
  • The scheduler starts when a module schedules tasks.
  • bootstrap/routes.ts is written on every start, and again when a view is added or removed. Route names and views are then known to the type checker without running route:types yourself.

The worker and the scheduler stop and start with the server, so a code change reaches them too. One that ends on its own is started again a second later. One that ends right after its start, say with a configuration error, stays down until you save a file. A server that is killed or crashes takes them along: they stop as on SIGTERM, and none is left running beside the next one.

marmeon dev sets NODE_ENV=development when neither the shell nor .env sets it. With another NODE_ENV, from either, it says so and serves the build instead, without Vite, the devtools, the worker and the scheduler. --workers belongs to marmeon start, and marmeon dev refuses it.

marmeon build and marmeon start are covered on the deployment page, --workers under several processes on one machine and make:docker under the Docker image.

Built-in commands

CommandWhat it does
devThe development server, with a worker and the scheduler next to it.
build [--no-typecheck]Builds the app for production. See deployment.
start [--workers=N]Starts the production server on the build.
route:listEvery route with its method, path, name, group, middleware and action. See listing routes.
route:types [--check]Writes bootstrap/routes.ts. --check fails when it is out of date, for CI. See typed route names.
discoverFinds the installed packages and their providers. See discovery.
key:generate [--show] [--force]Sets APP_KEY in .env.
tinker [--sandbox] [--execute "<code>"] [--no-history]A REPL with the booted app. See tinker.
make:lint-config [--boundaries] [--force]Writes the lint preset. See lint.
make:docker [--caddy | --nginx] [--systemd] [--force]Writes the files for a production image. See deployment.
package:build [dirs…] [--clean]Builds framework packages for publishing. See package development.

key:generate copies .env.example to .env when there is no .env yet, then writes a new key. --show only prints a key. An app that has a key keeps it unless you pass --force, which moves the old key to the front of APP_PREVIOUS_KEYS: sessions, encrypted cookies and signed links made with it stay valid. The deployment page explains the rotation on a server.

Generators

The make:* commands write a file into a module from a template and print where to register it. They run without booting the app, and never overwrite a file unless you pass --force:

CommandWrites
make:module <name>modules/<name>/index.ts and routes.ts. Then add the module to bootstrap/app.ts.
make:controller <Name> --module=<m> [--view] [--request]A controller. --view adds its page, --request a query or body schema. See controllers.
make:event <Name> --module=<m>An event class in events/. See events.
make:listener <Name> --event=<Event> --module=<m>A listener in listeners/.
make:job <Name> --module=<m>A queued job in jobs/. See queues.
make:notification <Name> --module=<m>A notification in notifications/. See notifications.
make:middleware <Name> --module=<m>A middleware class in middleware/.
make:migration <snake_case_name> --module=<m>A timestamped migration in migrations/. See migrations.

--module names a module that exists: make:module comes first. -m is short for it.

Commands of the packages

The installed packages add their commands when the app boots:

PackageCommands
@marmeon/databasemigrate, migrate:rollback, migrate:reset, migrate:fresh, migrate:status, db:seed, db:show, db:types. See migrations and seeding.
@marmeon/queuequeue:work, queue:size, queue:failed, queue:retry, queue:forget, queue:flush, queue:prune-failed, make:queue-table. See queues.
@marmeon/schedulerschedule:work, schedule:run, schedule:list, schedule:test. See task scheduling.
@marmeon/cachecache:prune, cache:clear, make:cache-table. See cache.
@marmeon/sessionsession:prune, make:session-table. See session.
@marmeon/storageuploads:prune, make:uploads-table. See file storage.
@marmeon/notificationsnotifications:prune, make:notifications-table. See notifications.
@marmeon/authmake:auth, auth:prune-tokens. The module make:auth writes adds auth:prune-unverified. See starter kit.
@marmeon/devtoolsdevtools:clear, devtools:prune. See devtools.

Writing commands

A command is a class with a name, a description and handle(). It is built through the container like a controller, so its constructor gets what it asks for:

modules/notes/commands/PruneArchivedNotesCommand.ts
import { Clock } from '@marmeon/core';
import type { CommandInput } from 'marmeon';
import { NoteRepository } from '../NoteRepository.ts';

const DAY = 86_400_000;

export class PruneArchivedNotesCommand {
  static command = 'notes:prune-archived';
  static description = 'Delete notes archived more than --days ago';
  static usage = '[--days=30]';
  static options = { days: { type: 'string' } } as const;

  readonly #notes: NoteRepository;
  readonly #clock: Clock;

  constructor(notes: NoteRepository, clock: Clock) {
    this.#notes = notes;
    this.#clock = clock;
  }

  async handle({ options, output }: CommandInput): Promise<number> {
    const days = Number(options.days ?? 30);
    if (!Number.isInteger(days) || days < 1) {
      output.error(`--days must be a whole number of days, not ${String(options.days)}.`);
      return 1;
    }
    const deleted = await this.#notes.deleteArchivedBefore(new Date(this.#clock.now() - days * DAY));
    output.success(`Deleted ${deleted} archived notes.`);
    return 0;
  }
}

A module lists its commands, and they appear in marmeon list:

modules/notes/index.ts
import { defineModule } from '@marmeon/core';
import { PruneArchivedNotesCommand } from './commands/PruneArchivedNotesCommand.ts';
import routes from './routes.ts';

export default defineModule({
  name: 'notes',
  routes,
  commands: [PruneArchivedNotesCommand],
});
  • static command is the name you type. A part before a colon groups it in the list.
  • static usage is shown in errors, after the name: Usage: marmeon notes:prune-archived [--days=30].
  • static options declares the options in the format of Node's parseArgs: { type: 'string' } or { type: 'boolean' }, with short: 'd' for a one-letter form. An option the command does not declare is an error before the command runs.
  • static hidden = true keeps a command out of marmeon list.
  • handle(input) gets the positional arguments as args, the options as options, the console as output and the app's directory as root, and spawn(...args) runs another marmeon command in a fresh process. It returns the exit code, and nothing means 0.

output writes line(), info(), success() and table(header, rows) to stdout, and warn() and error() to stderr. Colours are dropped when the output is no terminal.

Each run gets a scope of its own, as a request does. A scoped service, such as the Translator, is fresh for each command and speaks APP_LOCALE. The app shuts down after the command, so open connections never keep the process alive. An error the command throws prints its message and the first lines of its stack, and the exit code is 1.

The scheduler runs a command as a task: s.command('notes:prune-archived', ['--days=60']).daily(). Each run again gets a scope of its own. The task scheduling page covers the schedule.

Tinker

marmeon tinker opens a REPL with the booted app. It is Node's own REPL: await works at the top level, input may span several lines, and you type TypeScript with the same erasable syntax as the app's files:

$ pnpm marmeon tinker
Marmeon tinker · my-app · NODE_ENV=development · db sqlite (storage/database.sqlite) · .ls lists helpers
> const user = await repos.users.find(1)
> url('notes.index')
'/notes'
> await jobs.dispatch(SendWelcomeMailJob, { userId: user!.id })
HelperWhat it is
appThe Application: its container, modules and root.
make(Key), make('Name')Resolves a class or token in the session's scope, also by its name.
dbThe query builder of the default connection.
repos.<table>The app's repositories, by their table, or by class name where two share a table.
jobsThe Queue.
eventsThe EventDispatcher.
mailThe Mailer.
url(name, params?, query?)The path of a named route.
t(key, params?)A text in APP_LOCALE.
config.<name>The resolved configuration: config.app, config.mail, …

Every class and token the container binds, every class a module lists, such as jobs, listeners and seeders, and every repository is there by its name: make(Mailer) and jobs.dispatch(SendWelcomeMailJob, …) need no import. A helper is built on first use, and one whose package the app lacks says what to install. A result that is a promise is awaited. Tab completes the helpers, the names, repos., config. and route names in url('. .ls lists them all, .help the REPL's commands.

The session has one scope from start to end. The log carries no request context.

Trying things in a sandbox

--sandbox runs the whole session in one transaction on the default connection and rolls it back when the session ends: on .exit, Ctrl-D, Ctrl-C twice, a signal or a crash. A session that dies never commits. Each input is a savepoint of its own, so a failing input takes back only its own writes. So does an input whose value is an err, such as a tryInsert() of a taken address: an err rolls its savepoint back, as the transactions page explains, and tinker still prints it. Jobs on the database queue join the transaction and are gone with it, and jobs on other queues and mails that wait for the commit never go out.

A sandbox does not undo a mail sent directly, a file written to a disk, a Redis command or an HTTP call. Nor does it undo what the cache, its locks and rate limits, and the sessions write: they use a connection of their own, also inside the sandbox. On SQLite, when they share the app's file, they cannot write inside the transaction at all. Tinker says so and suggests giving them a file of their own, which the cache page explains.

Running code from a script

--execute, or -e, evaluates one input and exits:

pnpm marmeon tinker --execute "await repos.users.count()"
pnpm marmeon tinker --sandbox -e "await repos.notes.insert({ user_id: 1, title: 'Draft', body: '' })"

Only the value goes to stdout: a string as it is, anything else inspected, nothing for undefined. An error prints its stack to stderr and the exit code is 1. A signal ends it with 130.

On a server

Tinker works in every environment: whoever has a shell on the server can already do anything the app can. Where the app is protected, in production, a staging server included, or without a NODE_ENV, the banner says so in red and names the database: PRODUCTION — NODE_ENV=production, APP_ENV=staging, db pgsql (db.internal:5432/app): every change is real. It names the driver, host and database, never a user or a password. With --execute the same line goes to stderr, so a script's stdout keeps only the value. --sandbox is the safe way to look around.

History

The inputs of interactive sessions are kept in storage/framework/tinker_history, the last 1000 lines. A new file is readable only by its owner. It is plain text: a password you hash or a token you paste is in it. --no-history keeps none. The history holds inputs only, never what the REPL printed.

Lint

A new app has a lint preset, .oxlintrc.json, and pnpm lint runs oxlint with it. The preset is yours to change. marmeon make:lint-config writes it, and --force writes it anew over your changes.

Its main rule: client code imports server code as types only. A view needs its controller's type, never the class:

import type { ShowNoteController } from '../controllers/ShowNoteController.ts'; // fine
import { ShowNoteController } from '../controllers/ShowNoteController.ts';      // a lint error in a view

A value import would bundle the controller, and everything it imports, into the browser. In views, layouts and the client entry, the preset refuses value imports of controllers, repositories, service providers and module entries. Everywhere it asks for import type { X } instead of import { type X }, since the inline form still loads the module.

The lint sees what an import says, not the file it resolves to. The marmeon() Vite plugin is the stricter check: it follows every import of client code, also through a helper in between, and fails the build with the file and the line. The preset shows the common case in the editor first.

The preset also turns off the warning for unused private class members, in every class. That lets an event class carry readonly #event = true, which make:event writes: the field is never read, and it makes the class a type of its own. The events page explains why. A private member your own classes no longer use is not reported either.

--boundaries adds a second rule: a module imports another module only through its entry, #modules/<name>. The modules page explains it. Without the flag, nothing about the structure of your modules is checked.