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

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: 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

command()

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.

instruction(verb)

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.

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