Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Working on an app

The commands you use while writing an app: melee new, dev, test, check, push, logs, env.

All of these read melee.toml in the app directory (name, server, url) and take --dir <path> if you are not standing in it. melee new is the exception: it writes that file.

Getting melee

melee is one binary that works in any directory. It carries the melee library inside it, unpacked into ~/.melee/stdlib/<id> the first time it is needed, and builds the Spinel compiler it is pinned to into ~/.melee/spinel/<pin> the first time something compiles — about half a minute on a laptop, and it says so while it happens. Today it is installed from a checkout of the melee repository with mise run install (cargo install --path supervisor/melee-cli --root ~/.local, so the binary lands in ~/.local/bin); there is no download yet.

What has to be on your machine besides it:

ForYou need
melee dev, melee testRuby 4.0 and the sqlite3 gem (gem install sqlite3)
melee check, melee test --spinela C compiler, git, make and curl; on macOS also brew install openssl@3
melee push and the restnothing more
melee --version         # melee 0.1.0 (stdlib 2bc53a960cf5, spinel 06465186): the three things that must agree
melee toolchain         # where ruby, the library and the compiler are coming from, and whether they match
melee toolchain install # build the compiler now rather than on the first melee check

MELEE_CLI_HOME moves ~/.melee. Inside the checkout of the melee repository that melee was built from, that checkout’s own stdlib/ and vendor/spinel are used instead, which is how the library itself is developed; no other directory is treated as a checkout.

melee new

melee new notes && cd notes     # makes notes/
melee new .                     # or fills the directory you are already in
melee dev

Writes a working two-route app: melee.toml, spin.toml, app.rb, views/layout.erb and views/index.erb, public/app.css, a first migration, test/app_test.rb, a .meleeignore, a .gitignore, a README and an AGENTS.md — the dialect and the development loop, for a coding agent working in the directory. The name must be lowercase letters, digits and dashes, not starting with a dash, at most 40 characters — the same rule the server applies.

melee new . is for a directory that already exists: a repository you made first, on your own machine or through a tool. It takes the app’s name from the directory’s own name, spelling it the way the server requires and saying which name it chose — Dinner_Time becomes dinner-time. If nothing sensible is left after that (a directory called ...), it says so rather than guessing.

What is already there is left alone. A .git, a README of your own, a .meleeignore or an AGENTS.md you wrote: kept, and the command names what it kept. Your .gitignore keeps its own rules and gains melee’s at the end, since .melee/ and the app’s SQLite file otherwise turn up in every git status. What it refuses is a directory that is already an app — an app.rb, a melee.toml, a migration — because scaffolding over those would overwrite work. Emptiness itself is not the test, in either form of the command.

melee dev

ADMIN_SECRET=letmein melee dev        # http://127.0.0.1:4567

Compiles the templates and migrations, then runs the app under CRuby with a small development server. It prints one line per request:

melee dev: prepared 5 templates, 1 migrations
melee dev: http://127.0.0.1:4567 (Ctrl-C to stop)
GET /admin -> 200 (4.2 ms)
GET /admin.css -> 200 (public/)
  • Port from PORT, default 4567. One request at a time, no keep-alive, no TLS.
  • request.host is whatever the browser asked for, as it is in production — so http://localhost:4567 and http://127.0.0.1:4567 are not the same thing to your app, any more than they are to the browser. Use whichever one the thing you are building cares about: a browser API keyed to an origin (passkeys refuse an IP address outright), a cookie domain, or an absolute URL you build yourself. A request with no Host header at all falls back to the address the server is bound to.
  • Files under public/ are served straight from disk, as the real server does.
  • The database is .melee/app.sqlite inside the app directory. Delete it to start fresh.
  • Environment variables come from your shell, standing in for melee env.
  • The session secret is MELEE_SESSION_SECRET, defaulting to a fixed development value.
  • No reload. Changing app.rb, a template or a migration means stopping and starting it again.

The error page

Under melee dev only, a 500 renders a page with the exception class and message, the request, the params, the session and a CRuby backtrace. It exists to tell you what happened while you are writing the app. Production has no backtrace at all (see logging.md), so do not build a habit on it.

melee check

melee check

The build without the deploy: the same template and migration steps, then a full Spinel compile of the app and the stdlib. Nothing is sent anywhere. It is the fastest way to find out whether the code compiles.

It does run the app’s own code, though: the migrations are executed against a scratch database, and any C the app carries is compiled. Treat melee check on a repository you have not read the way you would treat bundle install or cargo build on one.

Every diagnostic is one line, file:line: message, and template errors are mapped back from the generated Ruby to the .erb you wrote:

views/broken.erb:2: unexpected end-of-input, assuming it is closing the parent top level context
db/migrations/002_bad.sql:1: near "TABEL": syntax error
app.rb:30: views/login.erb requires `error`

What is caught here: Ruby syntax in app.rb and lib/**/*.rb (reported against the file you edited, before Spinel runs), template syntax, every render/partial call against the template’s declared locals, migration SQL (each file is run against a scratch database), unknown requires, eval, thread primitives, a rescue clause that reaches its class through a constant alias, the other things dialect.md forbids, and a call to a top-level helper that does not exist. What is not caught: wrong arity, calling a method on nil, and any undefined method reached through an explicit receiver — db.frist(...), "x".nope and Household.get("home").refrsh all compile and raise NoMethodError when the line runs. So click through the app under melee dev before pushing.

melee test

melee test              # test/app_test.rb under CRuby, in one process: what you run while writing
melee test --spinel     # compile first, then the same file against the binary, over the wire
melee test --both       # both, and a diff of the two transcripts

Runs the app’s own tests, test/app_test.rb, which melee new starts for you. Each test gets a fresh browser session, and the app and its database are shared across the file, so a test must not depend on what another left behind; get and post go through the routes the way a browser would, and a POST carries the CSRF token. The testing guide has the rest — reaching into a durable object, firing its timer — and --spinel is the one that runs the code the way the server will.

--both compares a digest of every response between the two runtimes, masking what legitimately differs between two runs: object ids and the CSRF token. A value of your own that does the same — a key generated per database, say — is declared once at the top of the file as a pattern for where it appears, so it is masked too:

mask(/name="vapid-public-key" content="\K[^"]+/)

A pattern rather than the value, because under --spinel the binary has a database of its own: a value read in the test file would be the CRuby side’s, not the one on the binary’s pages. A plain String works for a value that is the same on both sides.

melee push

melee push

Tars the app directory and sends it to the server named in melee.toml; the server builds it, and on success activates the new release and prints the URL. build/, .git/, .melee* and spin.lock are never sent, and a .meleeignore file (one path or name per line) excludes more.

A failed build answers with the same file:line: message diagnostics and deploys nothing; the previous release keeps serving.

melee logs, env, restart

melee logs             # last 50 lines
melee logs -f          # follow
melee logs --tail 200
melee env ADMIN_SECRET letmein     # set a variable; the app restarts on its next request
melee restart                      # stop the warm process; the next request starts it again
melee apps                         # what is deployed on this server
melee open                         # print and open the app's URL

melee env is the only place secrets belong. They arrive in the app as ENV["ADMIN_SECRET"].

Generated files

The build step writes three files. They are regenerated on every dev, check and push; never edit them and do not commit them.

FileFrom
.melee/views.rbviews/*.erb — one method per template plus the render/partial dispatch
.melee/migrations.rbdb/migrations/*.sql embedded as Melee.migrate([[name, sql], ...])
bin/<name>.rbthe entry point: requires the two above, then app.rb, then starts the runtime

The same thing by hand

melee new writes these; this is what they are.

melee.toml:

name = "hello"
server = "http://127.0.0.1:7070"
url = "http://hello.localhost:8080"

The control API wants a token on every request. melee looks for it in MELEE_TOKEN, else a token_file path named here — which has to be outside the app directory, because melee push uploads everything in it. The development server writes one for you, so the local loop needs nothing here.

spin.toml:

[package]
name = "hello"
version = "0.1.0"

[dependencies]
melee = { path = ".melee/stdlib" }

.melee/stdlib is a link the build step keeps pointing at the library melee carries, so this file is the same everywhere; a manifest naming any other path is brought back to the link on the next build.

app.rb:

# frozen_string_literal: true
title "Hello"

get "/" do
  "<h1>Hello from #{app_title}</h1>"
end

Then melee dev and open http://127.0.0.1:4567.