YmerNode.Scripts.CLI (Ymer Node v0.2.1)

Copy Markdown View Source

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: pushing 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. rel/overlays/bin/ymer-node is a shell script the release carries, and every verb is one bin/ymer_node rpc into the running node — 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 is named ymer-node with a hyphen, and that is not cosmetic: overlays are copied over the release last, so an overlay named ymer_node would silently replace the release's own launcher.

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 a pushed file is read

scripts push with no argument reads the file from stdin — the rpc'd process inherits the caller's group leader, so docker exec -i <container> ymer-node scripts push < file crosses the container boundary with no path inside it. scripts push <file> reads a path on the node's own filesystem instead, which is where the shipped example lives; a host path given there is refused with status 2, because the node cannot see it.

Nothing on stdin — docker exec without -i — is a usage error, status 2, for push 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 push of that file on another node lands the same hash. Its refusal is a line like any other. The release pins the logger level at :info (config/prod.exs) 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 — a push'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 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

main(packed)

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.

run(argv, stdin \\ &read_stdin/0)

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.

unpack(packed)

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("")
[]