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:
| For | You need |
|---|---|
melee dev, melee test | Ruby 4.0 and the sqlite3 gem (gem install sqlite3) |
melee check, melee test --spinel | a C compiler, git, make and curl; on macOS also brew install openssl@3 |
melee push and the rest | nothing 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.hostis whatever the browser asked for, as it is in production — sohttp://localhost:4567andhttp://127.0.0.1:4567are 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 noHostheader 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.sqliteinside 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.
| File | From |
|---|---|
.melee/views.rb | views/*.erb — one method per template plus the render/partial dispatch |
.melee/migrations.rb | db/migrations/*.sql embedded as Melee.migrate([[name, sql], ...]) |
bin/<name>.rb | the 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.