The operator's verbs, run on the machine the node runs on — the node's own human door, beside the client's approval.
A worker reaches scripts through MCP. A person reaches them through here, and the two doors exist for different reasons: importing a file from a repository, setting a secret whose value must never travel through a model's context, resetting a throttle's breaker, which no session may reset, and looking at what a node holds when no client is connected are all things the wire is the wrong shape for.
How it arrives
There is no escript and no separate binary. The release that carries the node
provides the overlay, a shell script beside its own launcher, and every verb
is one rpc into the running node through that launcher — so the CLI talks to
the node that is already serving rather than booting a second VM against the
same database. Two VMs on one SQLite file is the thing this arrangement exists
to avoid.
The overlay needs a name of its own, never the release's: overlays are copied over the release last, so an overlay named after the release would silently replace the release's own launcher.
The release names the command too, in this module's app env key :command.
Every instruction the node gives a person — this module's hints and help
banner, an MCP tool's error naming the verb that accepts a script, a run's
missing-secret and open-breaker errors — is written through instruction/1,
so it names the command that release provides, and with none set, the verb
alone. The node names no command of its own: no install of it provides one.
The argv seam
rpc takes an Elixir expression as a string, so the shell's arguments have to
survive being pasted into one. They are packed by the overlay — NUL-joined,
then base64 — and main/1 unpacks them. Nothing an operator types can then
close a quote or inject an expression, which a naive interpolation would
allow for any argument carrying a double quote or an interpolation marker.
Where an imported file is read
scripts import with no argument reads the file from stdin — the rpc'd
process inherits the caller's group leader, so scripts import < file hands
the node the file's bytes with no path the node has to see.
scripts import <file> reads a path on the node's own filesystem instead,
which is where the shipped example lives; a path that names no file there is
refused with status 2.
Nothing on stdin is a usage error, status 2,
for import and for secrets set alike. What arrives then is an empty string,
which would otherwise be compiled as an empty script and refused with a
diagnostic about code that never came, or stored as an empty secret that a
run then presents to the remote as a credential.
Talking back
Everything goes to stdout. A process started by rpc has the calling
shell as its group leader, so IO.puts/1 reaches the operator's terminal —
but IO.puts(:stderr, …) does not: stderr belongs to the node's process,
wherever that was started, so a message written there is lost. That is why
refusals are printed rather than logged, and why the exit code carries the
verdict.
Exit status is exit({:shutdown, status}), which is the one channel rpc
propagates: 0 for a verb that did what it said, 1 for a refusal the node
made, 2 for a usage error the operator made.
One verb prints bytes rather than a line: scripts export <name> writes the
code the node holds and nothing else — not even the newline every other
answer ends with — so > file holds exactly those bytes and a
scripts import of that file on another node lands the same hash. Its
refusal is a line like any other. The release carrying the node must pin the
logger level at :info or above so no query log rides that stdout: unpinned,
a release logs at :debug, and Ecto's query lines then print into the
operator's terminal beside the answer — an import's INSERT carrying the
whole script.
Testing shape
main/1 is the only function that touches the world. run/2 takes an argv
list and a function that supplies stdin, and answers {output, status} — so
every verb is exercised without a release, a shell or a terminal, which is
what the whole rest of this module's tests do.
Summary
Functions
The command the release carrying the node names (config :ymer_node, YmerNode.Scripts.CLI, :command), nil when nothing sets it, in which case
every instruction names the verb alone.
What a person types on this machine to run verb: the carrying release's
command, then the verb, or the verb alone where the release names no command.
The entry point the overlay calls: unpacks argv, runs the verb, prints, exits.
Runs one verb, answering {output, status}.
Unpacks the overlay's argv encoding — base64 of the NUL-joined arguments.
Functions
The command the release carrying the node names (config :ymer_node, YmerNode.Scripts.CLI, :command), nil when nothing sets it, in which case
every instruction names the verb alone.
What a person types on this machine to run verb: the carrying release's
command, then the verb, or the verb alone where the release names no command.
The entry point the overlay calls: unpacks argv, runs the verb, prints, exits.
Takes the base64 of the NUL-joined arguments. Exits rather than returning, because the exit status is how a shell learns what happened.
Runs one verb, answering {output, status}.
stdin is a function so a test can supply a value without a terminal; the
default reads the operator's own stdin, which is how secrets set takes a
value that must never appear in a process list.
Unpacks the overlay's argv encoding — base64 of the NUL-joined arguments.
Examples
iex> YmerNode.Scripts.CLI.unpack(Base.encode64("scripts\0list\0"))
["scripts", "list"]
iex> YmerNode.Scripts.CLI.unpack("")
[]