Denox.CLI.Run (denox v0.7.0)

Copy Markdown View Source

Run Deno programs as managed subprocesses using the bundled CLI.

Same API as Denox.Run, but spawns the bundled deno binary from Denox.CLI as a subprocess instead of using the in-process NIF runtime. The primary use case is npm: and jsr: packages, which require Deno's full Node.js-compatible resolver and are not supported by the NIF backend. Also useful when Deno CLI features such as --unstable-kv are needed.

Additional Options

All options from Denox.Run are supported. Additionally:

  • :deno_flags - extra flags inserted after deno run and before the specifier (e.g. ["--no-check", "--unstable-kv"])

Examples

{:ok, pid} = Denox.CLI.Run.start_link(
  package: "@modelcontextprotocol/server-github",
  permissions: :all,
  env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token}
)

:ok = Denox.CLI.Run.send(pid, data)
{:ok, line} = Denox.CLI.Run.recv(pid, timeout: 5000)

Convenience Functions

All convenience helpers from Denox.Run.Base are available:

# One-shot capture
{:ok, lines} = Denox.CLI.Run.capture(
  package: "@modelcontextprotocol/server-github",
  permissions: :all,
  env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token}
)

# Bracket-style with automatic cleanup
Denox.CLI.Run.with_runtime(
  [package: "npm:cowsay", permissions: :all],
  fn pid ->
    :ok = Denox.CLI.Run.send(pid, "hello")
    {:ok, line} = Denox.CLI.Run.recv(pid, timeout: 5000)
    line
  end
)

Telemetry Events

Denox.CLI.Run emits the following telemetry events:

  • [:denox, :run, :start] — emitted when the runtime starts

    • Measurements: %{system_time: integer}
    • Metadata: %{package: string | nil, file: string | nil, backend: :cli}

  • [:denox, :run, :stop] — emitted when the runtime exits

    • Measurements: %{system_time: integer}
    • Metadata: %{package: string | nil, file: string | nil, exit_status: integer, backend: :cli}

  • [:denox, :run, :recv] — emitted for each stdout line received

    • Measurements: %{system_time: integer}
    • Metadata: %{line_bytes: integer, backend: :cli}

Summary

Functions

Check if the runtime is still running.

Run a script and capture all stdout output.

Returns a specification to start this module under a supervisor.

Return the OS PID of the process, if available.

Receive the next line from stdout.

Send data to stdin of the running process.

Send data to stdin and wait for the next stdout line.

Start a managed Deno runtime.

Stop the runtime.

Stream stdout lines from a script as a lazy Stream.

Stream stdout lines from an already-running server as a lazy Stream.

Subscribe the calling process to stdout messages.

Unsubscribe from stdout messages.

Execute a function with a managed runtime, ensuring cleanup on exit.

Types

t()

@type t() :: %Denox.CLI.Run{
  backend_state: term(),
  exit_status: term(),
  file: term(),
  package: term(),
  recv_waiters: term(),
  stdout_buffer: term(),
  subscribers: term()
}

Functions

alive?(server)

@spec alive?(GenServer.server()) :: boolean()

Check if the runtime is still running.

capture(opts)

@spec capture(keyword()) :: {:ok, [String.t()]} | {:error, term()}

Run a script and capture all stdout output.

Starts a managed runtime, collects all output lines until the script exits or the per-line timeout is reached, then stops the runtime.

Returns {:ok, lines} where lines is a list of stdout lines in order, or {:error, reason} if the runtime failed to start.

Options

Same as start_link/1, plus:

  • :timeout - per-line receive timeout in milliseconds (default: 30_000). If no new line arrives within this window, collection stops and the lines collected so far are returned.

Example

{:ok, lines} = Denox.Run.capture(
  file: "scripts/generate.ts",
  permissions: :all
)
# lines => ["line 1", "line 2", ...]

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

os_pid(server)

@spec os_pid(GenServer.server()) ::
  {:ok, non_neg_integer()} | {:error, :not_available | :not_running}

Return the OS PID of the process, if available.

Returns {:ok, pid} for CLI-backed runtimes or {:error, :not_available} for NIF-backed runtimes where no separate OS process exists.

recv(server, opts \\ [])

@spec recv(
  GenServer.server(),
  keyword()
) :: {:ok, String.t()} | {:error, :timeout | :closed}

Receive the next line from stdout.

Options

  • :timeout - milliseconds to wait (default: 5000)

send(server, data)

@spec send(GenServer.server(), String.t()) :: :ok | {:error, term()}

Send data to stdin of the running process.

A newline (\n) is automatically appended if data does not already end with one.

Returns :ok on success, or {:error, :closed} if the process has already exited.

send_and_recv(server, data, opts \\ [])

@spec send_and_recv(GenServer.server(), String.t(), keyword()) ::
  {:ok, String.t()} | {:error, term()}

Send data to stdin and wait for the next stdout line.

A convenience wrapper around send/2 + recv/2 for the common request-response pattern (e.g. JSON-RPC over stdio, MCP servers).

Returns {:ok, response_line} or {:error, reason} where reason is :timeout, :closed, or the send error.

Options

  • :timeout - milliseconds to wait for a response (default: 5000)

Example

request = Jason.encode!(%{jsonrpc: "2.0", method: "ping", id: 1})
{:ok, response_line} = Denox.Run.send_and_recv(pid, request, timeout: 5000)
response = Jason.decode!(response_line)

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Start a managed Deno runtime.

Options

  • :package - JSR/npm package specifier
  • :file - local file path to run
  • :permissions - permission mode; defaults to :none (deny all) when omitted:
    • :all — allow all permissions (-A in Deno CLI)
    • :none — deny all permissions (Deno's default behaviour)
    • keyword list — granular permissions, e.g. [allow_net: true, allow_read: ["/tmp"], deny_env: true]. Supports both allow_* and deny_* keys.
  • :env - map of environment variables
  • :args - extra arguments after the specifier
  • :name - GenServer name for registration
  • :buffer_size - (NIF backend only) stdout channel capacity in lines; controls how many lines the NIF can buffer before applying backpressure. Range [1, 100_000]; 0 or omitted uses the default of 1024 lines.

stop(server)

@spec stop(GenServer.server()) :: :ok

Stop the runtime.

stream(opts)

@spec stream(keyword()) :: Enumerable.t()

Stream stdout lines from a script as a lazy Stream.

Starts a managed runtime and returns an Enumerable that yields stdout lines one at a time. The runtime is automatically stopped when the stream is fully consumed or halted (e.g. via Stream.take/2 or Enum.take/2).

This is useful for processing large outputs without buffering all lines in memory, or for early termination once a condition is met.

Options

Same as start_link/1, plus:

  • :timeout - per-line receive timeout in milliseconds (default: 30_000). If no new line arrives within this window, the stream halts.

Example

# Collect the first 5 lines of a long-running script
Denox.Run.stream(file: "server.ts", permissions: :all)
|> Enum.take(5)

# Filter lines matching a pattern
Denox.Run.stream(file: "generate.ts", permissions: :all)
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.to_list()

Errors on start

If the runtime fails to start, the stream raises a RuntimeError when enumerated. Use capture/1 if you prefer an {:error, reason} tuple instead.

stream_from(server, opts \\ [])

@spec stream_from(
  GenServer.server(),
  keyword()
) :: Enumerable.t()

Stream stdout lines from an already-running server as a lazy Stream.

Unlike stream/1, which starts a new runtime, stream_from/2 works with an existing server PID. The stream halts when the runtime exits or a per-line timeout is reached. The server is not stopped when the stream halts — the caller retains ownership.

This pairs naturally with with_runtime/2 when you want lazy output enumeration after sending input:

Denox.Run.with_runtime([file: "script.ts", permissions: :all], fn pid ->
  :ok = Denox.Run.send(pid, Jason.encode!(request))
  Denox.Run.stream_from(pid) |> Enum.to_list()
end)

Options

  • :timeout - per-line receive timeout in milliseconds (default: 5000). If no new line arrives within this window, the stream halts.

subscribe(server)

@spec subscribe(GenServer.server()) :: :ok

Subscribe the calling process to stdout messages.

After subscribing, the calling process will receive:

  • {:denox_run_stdout, server_pid, line} for each stdout line
  • {:denox_run_exit, server_pid, exit_status} when the process exits

If the subscribing process dies, it is automatically removed from the subscriber list without needing an explicit unsubscribe/1 call.

unsubscribe(server)

@spec unsubscribe(GenServer.server()) :: :ok

Unsubscribe from stdout messages.

The calling process will stop receiving {:denox_run_stdout, ...} and {:denox_run_exit, ...} messages from this server.

with_runtime(opts, fun)

@spec with_runtime(
  keyword(),
  (GenServer.server() -> result)
) :: result | {:error, term()}
when result: term()

Execute a function with a managed runtime, ensuring cleanup on exit.

Starts a runtime with opts, calls fun with the server PID, then stops the runtime — even if fun raises an exception.

Returns the value returned by fun. If fun returns an error tuple, that tuple is passed through as-is. Returns {:error, reason} if the runtime failed to start.

Example

Denox.Run.with_runtime([file: "scripts/tool.ts", permissions: :all], fn pid ->
  :ok = Denox.Run.send(pid, Jason.encode!(%{method: "init"}))
  {:ok, line} = Denox.Run.recv(pid, timeout: 10_000)
  Jason.decode!(line)
end)