Terminalwire (Elixir)

Copy Markdown View Source

Ship a CLI for your web app. No API required.

Terminalwire streams a command-line app straight from your Phoenix/Plug server to your users' machines over a single WebSocket. You write your CLI in your app — calling your contexts, Ecto, and business logic directly — and it runs on the user's workstation with their terminal, files, and browser.

A CLI usually costs you three things to build: a REST API to back it, an SDK or client binary to ship, and a release-and-auto-update pipeline to keep that client current. Terminalwire is all three. Users install one small, self-updating client with a single curl … | bash; you ship features by deploying your server.

 Terminalwire client  WebSocket endpoint  Terminalwire.WebSock
                                             Server.Session (protocol)
                                             Server.Context  your CLI handler

Why this is nice

  • No API to build or version. Your CLI calls your app's code directly — no serializers, no SDK, no client/server version skew.
  • Nothing to distribute or update. Users install one small client (curl <app>.terminalwire.sh | bash) that self-updates through a signed channel. You ship a change by deploying your server — no per-release client build, no app-store round trip.
  • It feels local. Output streams in real time, prompts and passwords work, it's color/TTY-aware, resizes with the window, Ctrl-C interrupts the server-side command, and you can pipe into it (cat data.csv | your-app import).
  • Secure by construction. The client is the trust boundary: the server requests access to a file/env var/the browser and the client enforces a per-app entitlement policy. Your server never touches the user's machine.
  • One BEAM process per session. Each connection is a supervised process; the CLI handler runs in its own task. Natural fit for Phoenix.
  • Same protocol, any client. This server speaks the exact wire protocol the Go client and the Ruby server do — proven by a shared conformance corpus.

Install

def deps do
  [
    {:terminalwire, "~> 0.1"},
    {:websock_adapter, "~> 0.5"}   # to upgrade a Plug/Phoenix conn to a socket
  ]
end

Use

Define your CLI as a module. Public functions are commands, their parameters are the command's arguments, and @desc is the help text — like Ruby's Thor:

defmodule MyApp.CLI do
  use Terminalwire.CLI, name: "my-app"

  @desc "Greet someone by name"
  def hello(name) do
    puts("Hello, #{name}!")
  end

  @desc "Deploy to an environment"
  def deploy(env) do
    if String.trim(gets("Deploy to #{env}? [y/N] ")) == "y" do
      puts("Deploying #{env}…")          # call your app's code right here
    else
      puts("Aborted")
    end
  end
end

Mount it on a WebSocket route — use generated run/1 for you:

# Plug / Bandit / Cowboy
WebSockAdapter.upgrade(conn, Terminalwire.WebSock, [handler: &MyApp.CLI.run/1], [])

That's a working CLI: my-app hello Ada runs hello("Ada"), my-app deploy staging runs deploy("staging"), and my-app (or my-app help) prints a generated command list. Inside a command, puts/print/warn/gets/read_secret/env talk to the user's terminal; context/0 reaches files, the browser, and the rest.

Want flags, options, or your own parsing? Terminalwire.CLI is a thin layer over a plain run/1 handler — drop down to it and use any parser. That's the next section.

The handler API

Terminalwire.CLI is a thin layer over a plain handler: a one-argument function that takes a Terminalwire.Server.Context. Use it directly when you want full control over parsing. It's called once the handshake completes, in its own BEAM task whose group leader is a Terminalwire IO device, so plain IO.puts/IO.gets, IO.ANSI, and any library that writes to standard IO (like Owl) stream straight to the user's terminal. The Context covers everything that isn't standard IO: args, prompts, the client's terminal, files, env, the browser.

def run(ctx) do
  case Context.args(ctx) do
    ["deploy", env] -> deploy(ctx, env)
    _ -> Context.warn(ctx, "unknown command"); 1
  end
end

The Context API

argsContext.args(ctx) → the argv list you parse
stdoutContext.puts/print — or just IO.puts / Owl.IO.puts (group leader)
stderrContext.warn(ctx, msg) (see the stderr rule below)
inputContext.gets(ctx, prompt), Context.read_secret(ctx, prompt)
piped stdinContext.read(ctx) (drain to EOF), Context.read_chunk(ctx)
terminalContext.terminal(ctx)%{cols, rows, color, *_tty}
filesContext.file_read/file_write/file_append/file_delete
dirsContext.dir_list/dir_create/dir_delete
envContext.env(ctx, "NAME")
browserContext.browser_launch(ctx, url)
raw inputContext.raw_input(ctx, fun), Context.read_key(ctx) — REPL/TUI
exit codereturn an integer from run/1 (or Context.exit(ctx, n))

Files / env / browser are requests the client enforces against its per-app entitlement policy — your server can't touch the user's machine unless they grant it.

Parsing args — pick any style

Terminalwire hands you raw argv (Context.args/1); parsing is pure, so use whatever you like. All three below work unmodified.

Raw / stdlib. Pattern-match, or use stdlib OptionParser for flags:

{opts, args, _} = OptionParser.parse(Context.args(ctx), strict: [verbose: :boolean])

Optimus — subcommands, typed args, generated --help. Use Optimus.parse, never Optimus.parse!: the bang version calls System.halt on --help/errors, which would take down your server. Handle the result and render it yourself:

case Optimus.parse(spec(), Context.args(ctx)) do
  {:ok, [:deploy], %{args: %{env: env}}} -> deploy(ctx, env)
  :help          -> Context.puts(ctx, Optimus.help(spec())); 0
  {:error, errs} -> Enum.each(errs, &Context.warn(ctx, &1)); 1
end

Owl — rich UI (tables, color, prompts, spinners, progress). It writes to the group leader, so it streams over the wire for free — and it's width-aware: it asks the group leader for :io.columns, which Terminalwire answers with the client's terminal width.

Owl.IO.puts(Owl.Table.new(rows))                    # a table, rendered on the client
Owl.IO.puts(Owl.Data.tag("done ✓", :green))         # color
Owl.Spinner.run(fn -> deploy() end, labels: [...])  # live spinner

The standard "nice Elixir CLI" stack — Optimus to parse + Owl to render — works as-is over the wire.

Two rules (both about output, not parsing)

  1. Never System.halt (or Optimus.parse!, or escript-style exits). Your handler runs inside the server; halting kills the BEAM. Return an exit code from run/1 instead.
  2. stdout is the group leader; stderr is not. IO.puts / Owl.* / Context.puts reach the client (stdout). Bare IO.puts(:stderr, …) goes to the server's console — use Context.warn/2 for the client's stderr. (This is just Erlang's IO model: :stderr is a separate device from the group leader, not a Terminalwire quirk.)

Runnable examples

Run either, then point a launcher at it:

elixir examples/owl_cli.exs
printf '#!/usr/bin/env terminalwire-exec\nurl: "ws://localhost:8081/terminal"\n' > app && chmod +x app
./app apps        # an Owl table, streamed from Elixir to your terminal

Architecture

layermodule
sans-IO protocol coreTerminalwire.Protocol, Terminalwire.Codec, Terminalwire.Negotiator, Terminalwire.Frames
sans-IO server state machineTerminalwire.Server.Connection
process that drives itTerminalwire.Server.Session
CLI-facing APITerminalwire.Server.Context
command router (Thor-style)Terminalwire.CLI
WebSocket adapterTerminalwire.WebSock

The protocol core mirrors the Ruby Terminalwire server and the Go client, and is validated against the same language-neutral conformance corpus in terminalwire/protocol — run mix test with TERMINALWIRE_CORPUS pointed at it. That corpus is the cross-implementation contract: pass it and this server interoperates on the wire with the client and every other server.

License

Apache-2.0 (source-available — safe to install on your own servers).