Yeesh

Yeesh

A LiveView terminal component with sandboxed command execution.

Yeesh provides a browser-based CLI with fish/zsh-like features (tab completion, command history, prompt customization) and Dune-powered sandboxed Elixir evaluation.

Features

Installation

Add yeesh to your dependencies in mix.exs:

def deps do
  [
    {:yeesh, "~> 0.1.0"}
  ]
end

Install the library, but do not compile it yet:

mix deps.get

Install the JavaScript dependencies into the library, then compile the library:

npm install --prefix deps/yeesh/assets
mix deps.compile yeesh

Import the Yeesh terminal web component into your app.js:

import "phoenix-colocated/yeesh"

Insert the import line high above in the app.js, ideally immediately after the import {LiveSocket} from "phoenix_live_view" line.

Under the <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> line in your root.html.heex add the following line:

<Yeesh.Live.TerminalComponent.xterm_style/>

Quick Start

Add the terminal component to any LiveView:

<.live_component
  module={Yeesh.Live.TerminalComponent}
  id="terminal"
  commands={[]}
  prompt="app> "
/>

By default, only the help built-in command is registered.

Built-in Commands

Yeesh ships with several built-in commands: help, clear, history, echo, env, and elixir (sandboxed REPL). The :builtins assign controls which of these are available:

Value Effect
:help (default) Only the help command
:all All built-in commands
:none No built-in commands at all
list of modules Exactly those modules
<%!-- All built-ins --%>
<.live_component
  module={Yeesh.Live.TerminalComponent}
  id="terminal"
  builtins={:all}
/>

<%!-- Only help + history --%>
<.live_component
  module={Yeesh.Live.TerminalComponent}
  id="terminal"
  builtins={[Yeesh.Builtin.Help, Yeesh.Builtin.History]}
/>

<%!-- No built-ins at all --%>
<.live_component
  module={Yeesh.Live.TerminalComponent}
  id="terminal"
  builtins={:none}
  commands={[MyApp.Commands.Status]}
/>

Custom Commands

Implement the Yeesh.Command behaviour:

defmodule MyApp.Commands.Deploy do
  @behaviour Yeesh.Command

  @impl true
  def name, do: "deploy"

  @impl true
  def description, do: "Deploy the application"

  @impl true
  def usage, do: "deploy [environment]"

  @impl true
  def execute([], session), do: {:error, "specify an environment", session}

  def execute([env], session) do
    # Your deployment logic here
    {:ok, "Deployed to #{env}", session}
  end
end

Register it in the component:

<.live_component
  module={Yeesh.Live.TerminalComponent}
  id="terminal"
  builtins={:all}
  commands={[MyApp.Commands.Deploy]}
/>

Multi-word command names

A command name may contain whitespace, in which case the command is invoked by typing all of its words in order. Any run of whitespace -- whether in the name returned by name/0 or in the user's input -- is treated as a single separator, and leading/trailing whitespace is ignored:

defmodule MyApp.Commands.MixRun do
  @behaviour Yeesh.Command

  @impl true
  def name, do: "mix run"

  @impl true
  def description, do: "Run a Mix task"

  @impl true
  def usage, do: "mix run <task> [args...]"

  @impl true
  def execute([task | args], session) do
    {:ok, "running #{task} with #{inspect(args)}", session}
  end
end
$ mix run my_task arg1 arg2
running my_task with ["arg1", "arg2"]

When dispatching, the registry is consulted first and the longest registered multi-word name that matches a prefix of the input wins. So if both mix and mix run are registered, mix run foo dispatches to mix run with ["foo"], while mix foo dispatches to mix with ["foo"]. Quoting still works the usual way for individual arguments, e.g. mix run "hello world".

Command Grouping

The help command groups output automatically based on command names. Command names may contain dots (.), dashes (-), and underscores (_) as separators. The text before the first separator determines the group:

Groups are displayed in order: Built-in first, Generic second, then custom groups alphabetically.

Explicit groups

Implement the optional group/0 callback to override automatic grouping:

defmodule MyApp.Commands.Migrate do
  @behaviour Yeesh.Command

  @impl true
  def name, do: "db.migrate"

  @impl true
  def group, do: "Database"

  @impl true
  def description, do: "Run database migrations"

  @impl true
  def usage, do: "db.migrate [--step N]"

  @impl true
  def execute(_args, session), do: {:ok, "Migrated", session}
end

Without group/0, this command would appear under "Db" (derived from the name prefix). With it, it appears under "Database" instead.

Example output

Built-in:
  help            Show available commands or help for a specific command
  clear           Clear the terminal screen

Generic:
  deploy          Deploy the application

Database:
  db.migrate      Run database migrations
  db.seed         Seed the database

Sys:
  sys.info        Show system information
  sys.health      Run health checks

Elixir REPL

The built-in elixir command provides a sandboxed Elixir evaluation environment powered by Dune:

$ elixir 1 + 2
3
$ elixir
Entering sandboxed Elixir REPL (powered by Dune).
Type 'exit' to return to the shell.
iex> x = 42
42
iex> x * 2
84
iex> exit
$

Variables persist within the session. Dangerous functions (file system, network, code loading) are restricted by Dune's allowlist.

Configure the sandbox:

<.live_component
  module={Yeesh.Live.TerminalComponent}
  id="terminal"
  sandbox_opts={[timeout: 10_000, max_reductions: 100_000]}
/>

Configuration

Execution Model

Command execution is currently synchronous -- the LiveView process blocks until the command completes (with a configurable timeout, default 5s).

Async streaming execution is planned for Milestone 3.

Roadmap

License

MIT