Three ways in, one behaviour: the CLI verbs, the HTTP control API and the
Elixir library all speak to the same route table. Anything the CLI can do,
an agent can do with --json, and any HTTP client can do directly.
CLI verbs
cherrypicker start [--port N] # run the proxy (foreground)
cherrypicker route NAME PORT # name → 127.0.0.1:PORT
cherrypicker unroute NAME
cherrypicker ls
cherrypicker version
Every verb takes --json for a machine-readable envelope. Exit codes: 0
success, 1 the command ran and failed, 2 usage error.
start
Runs the proxy in the foreground. The default port is 80, which gives bare
URLs; any --port works where 80 needs privileges or is taken.
$ cherrypicker start --port 7777
proxy up — routes serve at http://<name>.localhost:7777 (Ctrl-C to stop)
On start the daemon writes its bound port to the state file
(~/.cherrypicker/daemon.json, or under $CHERRYPICKER_HOME when set) and
removes it on clean shutdown. --port 0 binds an ephemeral free port, which
the state file then advertises.
route
Registers a name against a loopback port and prints the named URL.
$ cherrypicker route docs 8080
http://docs.localhost
Names must be lowercase DNS labels: letters, digits and inner hyphens, with
dots allowed for subdomain-style names (api.myapp). cherrypicker itself
is reserved for the control API. Registering an existing name replaces its
port, which is exactly what a dev server restarting on a new port wants.
unroute, ls, version
$ cherrypicker ls
cherry.localhost → 127.0.0.1:4000
docs.localhost → 127.0.0.1:8080
$ cherrypicker unroute docs
docs unrouted
$ cherrypicker version
cherrypicker 0.1.0
JSON envelopes
Every verb takes --json; envelopes always carry ok and command.
$ cherrypicker ls --json
{"command":"ls","ok":true,"routes":[{"name":"cherry","port":4000}]}
Failures put the reason in error and exit 1:
{
"ok": false,
"command": "route",
"error": "no daemon is running — start one with: cherrypicker start"
}
HTTP control API
The control API lives on the proxy's own listener, selected by the Host
header cherrypicker.localhost, so with a running daemon these URLs work
from a browser or curl as written. Requests and responses are JSON. The
route table is in memory: routes are gone when the daemon stops.
GET /healthz
Liveness probe, used by clients to detect stale state files.
{ "ok": true, "version": "0.1.0" }
GET /routes
Every registered route.
{
"routes": [
{ "name": "cherry", "port": 4000 },
{ "name": "docs", "port": 8080 }
]
}
PUT /routes/:name
Registers or replaces a route. The body names the loopback port; the response carries the named URL (bare when the proxy holds port 80).
$ curl -X PUT http://cherrypicker.localhost/routes/docs \
-H "content-type: application/json" -d '{"port":8080}'
{"name":"docs","ok":true,"port":8080,"url":"http://docs.localhost"}
Invalid names and ports return 400 with the reason:
{ "ok": false, "error": "invalid name: Bad_Name" }
DELETE /routes/:name
Removes a route. Deleting an absent route succeeds.
{ "ok": true, "name": "docs" }
Proxy behaviour
- Requests to
http://<name>.localhost[:port]stream to127.0.0.1:<port>chunk by chunk in both directions, so server-sent events and live-reload connections stay open through the named URL. X-Forwarded-Hostcarries the original named host, so upstreams can emit browser-correct links. Hop-by-hop headers are stripped per RFC 9110.- An unrouted name gets a
404naming what is routed; a routed name whose app is down gets a502saying nothing answered there.
Elixir library
The Cherrypicker module is the client: it finds the daemon through the
state file and speaks the control API using only the standard library, so
depending on it adds no processes, no pool and no open ports to your app.
{:cherrypicker, "~> 0.1"}
Cherrypicker.register(name, port)
Registers name to proxy to 127.0.0.1:port, returning the named URL.
@spec register(String.t(), :inet.port_number()) ::
{:ok, String.t()} | {:error, :no_daemon | String.t()}
{:error, :no_daemon} means no daemon is reachable; callers treat that as
"fall back to the port URL", never as a failure. A String.t() error is the
daemon refusing the route (invalid or reserved name, bad port).
Cherrypicker.unregister(name)
Removes a route. Absent routes and absent daemons are fine; always :ok.
@spec unregister(String.t()) :: :ok
Cherrypicker.routes()
Every registered route.
@spec routes() ::
{:ok, [%{name: String.t(), port: :inet.port_number()}]}
| {:error, :no_daemon | String.t()}
Cherrypicker.daemon_port()
The daemon's bound proxy port, when one is answering. Reads the state file,
then probes /healthz, so a stale file reports :error rather than a wrong
answer.
@spec daemon_port() :: {:ok, :inet.port_number()} | :error
Embedding the daemon
A host app can run the proxy in its own supervision tree instead of the CLI:
children = [
{Cherrypicker.Daemon, port: 8080}
]
Options: port (default 80; 0 binds an ephemeral port),
shutdown_timeout (milliseconds of grace for in-flight requests on
shutdown, default 15000) and state_file? (default true; set false for
an embedded proxy that other clients should not discover).
Cherrypicker.Daemon.port/0 returns the port actually bound.