Skip to content

terminalwire/elixir

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Terminalwire (Elixir)

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

args Context.args(ctx) → the argv list you parse
stdout Context.puts/print — or just IO.puts / Owl.IO.puts (group leader)
stderr Context.warn(ctx, msg) (see the stderr rule below)
input Context.gets(ctx, prompt), Context.read_secret(ctx, prompt)
piped stdin Context.read(ctx) (drain to EOF), Context.read_chunk(ctx)
terminal Context.terminal(ctx)%{cols, rows, color, *_tty}
files Context.file_read/file_write/file_append/file_delete
dirs Context.dir_list/dir_create/dir_delete
env Context.env(ctx, "NAME")
browser Context.browser_launch(ctx, url)
raw input Context.raw_input(ctx, fun), Context.read_key(ctx) — REPL/TUI
exit code return 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

layer module
sans-IO protocol core Terminalwire.Protocol, Terminalwire.Codec, Terminalwire.Negotiator, Terminalwire.Frames
sans-IO server state machine Terminalwire.Server.Connection
process that drives it Terminalwire.Server.Session
CLI-facing API Terminalwire.Server.Context
command router (Thor-style) Terminalwire.CLI
WebSocket adapter Terminalwire.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.

Documentation

License

The Terminalwire server (this package) is open source under the Apache License 2.0 — free to use, modify, and self-host, including commercially.

The Terminalwire client your users install is proprietary, and only Terminalwire distributes it. Shipping a commercial CLI to your users needs a subscription, priced per active client install:

See LICENSE for the full Apache-2.0 text.

About

Terminalwire v2 server for Elixir (Phoenix/Plug/Cowboy <-> CLI)

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages