diff --git a/CHANGELOG.md b/CHANGELOG.md index e230f56..ccb367c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,12 @@ Go client and the Ruby server. `Ctrl-C` → server-side interrupt (exit 130), stdin piping (`Context.read`/ `read_chunk`), raw input / single-key for REPL/TUI, terminal query, and files / dirs / env / browser behind the client-enforced entitlement policy. -- **Build-your-CLI docs** — Context API reference plus three parsing styles - (raw/`OptionParser`, Optimus, Owl) and the two output rules. +- **`Terminalwire.CLI`** — a Thor-style command router: public functions become + commands, their parameters become arguments, and `@desc` becomes generated help, + with terminal helpers (`puts`/`gets`/`warn`/`env`/…) bound to the session. Sugar + over a plain `run/1` handler, which you can still use directly with any parser. +- **Build-your-CLI docs** — `Terminalwire.CLI` plus the lower-level handler with + three parsing styles (raw/`OptionParser`, Optimus, Owl) and the two output rules. - **Runnable examples** — `examples/self_describing.exs` and `examples/owl_cli.exs`. - **Coverage floor** — `mix test --cover` gates the build at 85%. diff --git a/README.md b/README.md index 948959d..810b024 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,14 @@ **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. Instead of building an API, -generating an SDK, and shipping a separate client, 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. +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 @@ -18,6 +22,10 @@ the user's workstation with their terminal, files, and browser. - **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 .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`). @@ -42,43 +50,63 @@ end ## Use -Write a handler that takes a `Terminalwire.Server.Context` — this is where you -parse args (with any CLI library) and talk to the user's terminal: +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: ```elixir -defmodule MyCLI do - alias Terminalwire.Server.Context - - def run(ctx) do - case Context.args(ctx) do - ["deploy" | _] -> - env = Context.gets(ctx, "Environment? ") |> String.trim() - Context.puts(ctx, "Deploying to #{env}…") - 0 - - _ -> - Context.warn(ctx, "unknown command") - 1 +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 ``` -Upgrade your WebSocket route to the ready-made adapter: +Mount it on a WebSocket route — `use` generated `run/1` for you: ```elixir # Plug / Bandit / Cowboy -WebSockAdapter.upgrade(conn, Terminalwire.WebSock, [handler: &MyCLI.run/1], []) +WebSockAdapter.upgrade(conn, Terminalwire.WebSock, [handler: &MyApp.CLI.run/1], []) ``` -## Building your CLI +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 -Your handler `&MyModule.run/1` is called with a `Terminalwire.Server.Context` 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](https://hexdocs.pm/owl)) stream to the user's -terminal with **no wiring**. The `Context` covers everything that *isn't* standard -IO: args, prompts, the client's terminal, files, env, the browser. +`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](https://hexdocs.pm/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. + +```elixir +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 @@ -172,6 +200,7 @@ printf '#!/usr/bin/env terminalwire-exec\nurl: "ws://localhost:8081/terminal"\n' | 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, diff --git a/lib/terminalwire.ex b/lib/terminalwire.ex index 72b9d1a..b63db09 100644 --- a/lib/terminalwire.ex +++ b/lib/terminalwire.ex @@ -1,5 +1,5 @@ defmodule Terminalwire do - @moduledoc """ + @moduledoc ~S""" Terminalwire v2 server for Elixir. Stream a command-line app from your Phoenix/Plug/Cowboy server to the @@ -8,28 +8,23 @@ defmodule Terminalwire do ## Quick start - Write a handler that takes a `Terminalwire.Server.Context`: + Define your CLI with `Terminalwire.CLI` — public functions are commands, their + parameters are arguments, and `@desc` is the help text: - defmodule MyCLI do - alias Terminalwire.Server.Context + defmodule MyApp.CLI do + use Terminalwire.CLI, name: "my-app" - def run(ctx) do - case Context.args(ctx) do - ["deploy" | _] -> - env = Context.gets(ctx, "Environment? ") - Context.puts(ctx, "Deploying to " <> String.trim(env) <> "…") - 0 - - _ -> - Context.warn(ctx, "unknown command") - 1 - end - end + @desc "Greet someone by name" + def hello(name), do: puts("Hello, #{name}!") end - Upgrade your WebSocket route to `Terminalwire.WebSock` with that handler: + Upgrade your WebSocket route to `Terminalwire.WebSock` with the generated `run/1`: + + WebSockAdapter.upgrade(conn, Terminalwire.WebSock, [handler: &MyApp.CLI.run/1], []) - WebSockAdapter.upgrade(conn, Terminalwire.WebSock, [handler: &MyCLI.run/1], []) + Prefer to parse args yourself? `Terminalwire.CLI` is sugar over a plain handler — + a `run(ctx)` function taking a `Terminalwire.Server.Context`. Use that directly + with any parser (`OptionParser`, Optimus, …). ## Layers @@ -39,6 +34,7 @@ defmodule Terminalwire do * `Terminalwire.Server.Connection` — the sans-IO server state machine. * `Terminalwire.Server.Session` — the process that drives it over a transport. * `Terminalwire.Server.Context` — the CLI-facing API. + * `Terminalwire.CLI` — the Thor-style command router (functions as commands). * `Terminalwire.WebSock` — the ready-made WebSocket adapter. """ end diff --git a/lib/terminalwire/cli.ex b/lib/terminalwire/cli.ex new file mode 100644 index 0000000..cf0472d --- /dev/null +++ b/lib/terminalwire/cli.ex @@ -0,0 +1,231 @@ +defmodule Terminalwire.CLI do + @moduledoc ~S""" + A small command router so your CLI reads like the commands themselves — public + functions become commands, their parameters become arguments, and `@desc` + becomes help. It's the Elixir analog of Ruby's Thor `desc`/`def`. + + 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 + confirm = gets("Deploy to #{env}? [y/N] ") + if String.trim(confirm) == "y", do: puts("Deploying…"), else: puts("Aborted") + end + end + + Mount it like any handler — `use` generates `run/1`: + + WebSockAdapter.upgrade(conn, Terminalwire.WebSock, [handler: &MyApp.CLI.run/1], []) + + Then `my-app hello Ada` calls `hello("Ada")`, `my-app deploy staging` calls + `deploy("staging")`, and `my-app` (or `my-app help`) prints a generated command + list. An unknown command or wrong argument count exits non-zero with a usage hint. + + ## How it dispatches + + * The first argument is the command; it's matched to a `@desc`-annotated public + function with the **same name and the same number of remaining arguments**. + * Functions **without** a `@desc` are ordinary helpers, not commands. + * A command's return value sets the exit code when it's an integer; otherwise 0. + + ## Talking to the terminal + + Inside a command, `use Terminalwire.CLI` imports terminal helpers bound to the + current session — `puts/1`, `print/1`, `warn/1`, `gets/1`, `read_secret/1`, + `env/1` — so you write `puts("hi")` instead of threading a context around. Bare + `IO.puts` and any standard-IO library (like Owl) also stream to the user, because + the handler's group leader is a Terminalwire IO device. For everything else on the + context (files, directories, the browser, the raw terminal), `context/0` returns + the `Terminalwire.Server.Context`: + + @desc "Import a CSV from the user's machine" + def import(path) do + data = Terminalwire.Server.Context.file_read(context(), path) + puts("imported #{byte_size(data)} bytes") + end + + ## Scope + + This is a router, not a full option parser — it handles commands and positional + arguments. For flags, options, and richer parsing, write a plain `run/1` handler + and reach for a library like [Optimus](https://hex.pm/packages/optimus); the two + approaches use the exact same `Context`. + """ + + alias Terminalwire.Server.Context + + @ctx_key :"$terminalwire_cli_ctx" + + defmacro __using__(opts) do + name = Keyword.get(opts, :name, "app") + + quote do + import Terminalwire.CLI, + only: [ + puts: 0, + puts: 1, + print: 1, + warn: 0, + warn: 1, + gets: 0, + gets: 1, + read_secret: 0, + read_secret: 1, + env: 1, + context: 0 + ] + + Module.register_attribute(__MODULE__, :terminalwire_commands, accumulate: true) + @terminalwire_cli_name unquote(name) + @on_definition Terminalwire.CLI + @before_compile Terminalwire.CLI + end + end + + @doc false + # Collect each @desc-annotated public function as a command (name, arity, + # description, argument names) and consume the @desc so the next plain def isn't + # picked up. + def __on_definition__(env, :def, name, args, _guards, _body) do + case Module.get_attribute(env.module, :desc) do + nil -> + :ok + + desc when is_binary(desc) -> + arg_names = Enum.map(args, &arg_name/1) + + Module.put_attribute( + env.module, + :terminalwire_commands, + {name, length(args), desc, arg_names} + ) + + Module.delete_attribute(env.module, :desc) + end + end + + def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: :ok + + defp arg_name({name, _meta, ctx}) when is_atom(name) and is_atom(ctx), do: Atom.to_string(name) + defp arg_name(_), do: "arg" + + defmacro __before_compile__(env) do + commands = env.module |> Module.get_attribute(:terminalwire_commands) |> Enum.reverse() + name = Module.get_attribute(env.module, :terminalwire_cli_name) + + quote do + @doc """ + Generated entry point. Dispatches the client's argv to a command; pass + `&#{inspect(__MODULE__)}.run/1` to `Terminalwire.WebSock`. + """ + def run(ctx), + do: + Terminalwire.CLI.__run__( + __MODULE__, + ctx, + unquote(name), + unquote(Macro.escape(commands)) + ) + + @doc false + def __terminalwire_commands__, do: unquote(Macro.escape(commands)) + end + end + + # --- runtime dispatch ----------------------------------------------------- + + @doc false + def __run__(module, ctx, name, commands) do + Process.put(@ctx_key, ctx) + + case Context.args(ctx) do + [] -> help(ctx, name, commands) + ["help" | _] -> help(ctx, name, commands) + [command | rest] -> invoke(module, ctx, name, commands, command, rest) + end + end + + defp invoke(module, ctx, name, commands, command, args) do + arity = length(args) + + exact = + Enum.find(commands, fn {n, a, _, _} -> Atom.to_string(n) == command and a == arity end) + + by_name = Enum.find(commands, fn {n, _, _, _} -> Atom.to_string(n) == command end) + + cond do + exact -> + {fun, _, _, _} = exact + + case apply(module, fun, args) do + status when is_integer(status) -> status + _ -> 0 + end + + by_name -> + Context.warn(ctx, "usage: " <> signature(name, by_name)) + 1 + + true -> + Context.warn(ctx, "unknown command: #{command}\n") + help(ctx, name, commands) + 1 + end + end + + defp help(ctx, name, commands) do + rows = + Enum.map(commands, fn {_, _, desc, _} = cmd -> {signature(name, cmd), desc} end) ++ + [{"#{name} help", "List available commands"}] + + width = rows |> Enum.map(fn {sig, _} -> String.length(sig) end) |> Enum.max(fn -> 0 end) + + Context.puts(ctx, "Commands:") + + Enum.each(rows, fn {sig, desc} -> + Context.puts(ctx, " #{String.pad_trailing(sig, width)} # #{desc}") + end) + + 0 + end + + # "my-app deploy ENV" from {name, arity, desc, ["env"]} + defp signature(name, {command, _arity, _desc, arg_names}) do + [name, Atom.to_string(command) | Enum.map(arg_names, &String.upcase/1)] + |> Enum.join(" ") + end + + # --- context-bound terminal helpers (imported into the CLI module) -------- + + @doc "The current command's `Terminalwire.Server.Context` (for files, env, browser, the raw terminal)." + def context do + case Process.get(@ctx_key) do + nil -> raise "Terminalwire.CLI helpers must be called from inside a command" + ctx -> ctx + end + end + + @doc "Write a line to the user's stdout." + def puts(data \\ ""), do: Context.puts(context(), data) + + @doc "Write to the user's stdout without a trailing newline." + def print(data), do: Context.print(context(), data) + + @doc "Write a line to the user's stderr." + def warn(data \\ ""), do: Context.warn(context(), data) + + @doc "Prompt (optional) and read a line from the user's stdin." + def gets(prompt \\ nil), do: Context.gets(context(), prompt) + + @doc "Prompt (optional) and read a line without echo (passwords)." + def read_secret(prompt \\ nil), do: Context.read_secret(context(), prompt) + + @doc "Read an environment variable from the user's machine (entitlement-gated)." + def env(name), do: Context.env(context(), name) +end diff --git a/test/cli_test.exs b/test/cli_test.exs new file mode 100644 index 0000000..1c39791 --- /dev/null +++ b/test/cli_test.exs @@ -0,0 +1,188 @@ +defmodule Terminalwire.CLITest do + use ExUnit.Case, async: true + + alias Terminalwire.Server.Context + + # A fake Session that forwards streamed output to the test process and answers + # stdin reads with a canned line, so we can drive a CLI module end to end. + defmodule FakeSession do + use GenServer + def start_link(test), do: GenServer.start_link(__MODULE__, test) + @impl true + def init(test), do: {:ok, test} + + @impl true + def handle_call({:output, stream, bytes}, _from, test) do + send(test, {:out, stream, bytes}) + {:reply, :ok, test} + end + + def handle_call({:request, "stdin", "gets", _}, _from, test), + do: {:reply, {:ok, "yes\n"}, test} + + def handle_call({:request, "stdin", "getpass", _}, _from, test), + do: {:reply, {:ok, "s3cret\n"}, test} + + def handle_call({:request, "env", "read", _}, _from, test), do: {:reply, {:ok, "VALUE"}, test} + + def handle_call({:request, _, _, _}, _from, test), do: {:reply, {:ok, nil}, test} + + @impl true + def handle_cast({:exit, _}, test), do: {:noreply, test} + end + + # The CLI under test: each @desc'd function is a command; `helper` is not. + defmodule DemoCLI do + use Terminalwire.CLI, name: "demo" + + @desc "Greet NAME" + def hello(name), do: puts("Hello, #{name}!") + + @desc "Add A and B" + def add(a, b), do: puts("#{String.to_integer(a) + String.to_integer(b)}") + + @desc "Exit with a specific code" + def boom, do: 3 + + @desc "Ask a question then echo the answer" + def confirm, do: puts("got: " <> String.trim(gets("ok? "))) + + @desc "Write to stdout (no newline) and stderr" + def noisy do + print("a") + warn("b") + puts("c") + end + + @desc "Read a secret without echo" + def secret, do: puts("got " <> String.trim(read_secret("pw? "))) + + @desc "Show an environment variable from the client" + def showenv(name), do: puts("env=" <> to_string(env(name))) + + # No @desc — an ordinary helper, never reachable as a command. + def helper, do: :not_a_command + end + + defp run(argv) do + {:ok, sess} = FakeSession.start_link(self()) + ctx = %Context{session: sess, program: %{"args" => argv}, capabilities: []} + status = DemoCLI.run(ctx) + {status, drain()} + end + + defp drain(acc \\ %{stdout: "", stderr: ""}) do + receive do + {:out, :stdout, b} -> drain(%{acc | stdout: acc.stdout <> b}) + {:out, :stderr, b} -> drain(%{acc | stderr: acc.stderr <> b}) + after + 0 -> acc + end + end + + describe "dispatch" do + test "routes a command to the matching function with its arguments" do + assert {0, %{stdout: "Hello, Ada!\n"}} = run(["hello", "Ada"]) + end + + test "passes multiple positional arguments in order" do + assert {0, %{stdout: "5\n"}} = run(["add", "2", "3"]) + end + + test "an integer return value becomes the exit code" do + assert {3, _} = run(["boom"]) + end + + test "a command can read stdin via the imported gets/1 helper" do + {status, out} = run(["confirm"]) + assert status == 0 + assert out.stdout =~ "ok? " + assert out.stdout =~ "got: yes" + end + end + + describe "help" do + test "no args prints the generated command list" do + {status, out} = run([]) + assert status == 0 + assert out.stdout =~ "Commands:" + assert out.stdout =~ "demo hello NAME" + assert out.stdout =~ "# Greet NAME" + assert out.stdout =~ "demo add A B" + assert out.stdout =~ "demo help" + end + + test "the explicit help command prints the same list" do + {0, out} = run(["help"]) + assert out.stdout =~ "Commands:" + assert out.stdout =~ "demo confirm" + end + end + + describe "errors" do + test "an unknown command exits 1 with a message and the help list" do + {status, out} = run(["nope"]) + assert status == 1 + assert out.stderr =~ "unknown command: nope" + assert out.stdout =~ "Commands:" + end + + test "the wrong number of arguments exits 1 with a usage hint" do + {status, out} = run(["hello"]) + assert status == 1 + assert out.stderr =~ "usage: demo hello NAME" + end + + test "a public function without @desc is not a command" do + {status, out} = run(["helper"]) + assert status == 1 + assert out.stderr =~ "unknown command: helper" + end + end + + describe "terminal helpers" do + test "print writes without a newline, warn goes to stderr, puts adds a newline" do + {0, out} = run(["noisy"]) + assert out.stdout == "ac\n" + assert out.stderr == "b\n" + end + + test "read_secret reads a line (no echo) from stdin" do + {0, out} = run(["secret"]) + assert out.stdout =~ "got s3cret" + end + + test "env reads an environment variable from the client" do + {0, out} = run(["showenv", "HOME"]) + assert out.stdout =~ "env=VALUE" + end + + test "context/0 raises when called outside a command" do + parent = self() + + Task.start(fn -> + msg = + try do + Terminalwire.CLI.context() + :no_raise + rescue + e in RuntimeError -> {:raised, e.message} + end + + send(parent, msg) + end) + + assert_receive {:raised, message} + assert message =~ "inside a command" + end + end + + describe "introspection" do + test "__terminalwire_commands__/0 lists only the @desc'd commands" do + names = DemoCLI.__terminalwire_commands__() |> Enum.map(fn {n, _, _, _} -> n end) + assert :hello in names + assert :add in names + refute :helper in names + end + end +end