Command Execution
Execute external commands and shell scripts with full control over I/O streams.
For executor configuration, see Executor.
Loading
local exec = require("exec")
Acquiring an Executor
Get a process executor resource by ID:
local executor, err = exec.get("app:exec")
if err then
return nil, err
end
-- Use executor
local proc = executor:exec("ls -la")
-- ...
-- Release when done
executor:release()
| Parameter | Type | Description |
|---|---|---|
id |
string | Resource ID |
Returns: Executor, error
Creating a Process
Create a new process with the specified command:
-- Simple command
local proc, err = executor:exec("echo 'Hello, World!'")
-- With working directory
local proc = executor:exec("npm install", {
work_dir = "/app/project"
})
-- With environment variables
local proc = executor:exec("python script.py", {
work_dir = "/scripts",
env = {
PYTHONPATH = "/app/lib",
DEBUG = "true",
API_KEY = api_key
}
})
-- Run shell script
local proc = executor:exec("./deploy.sh production", {
work_dir = "/app/scripts",
env = {
DEPLOY_ENV = "production"
}
})
| Parameter | Type | Description |
|---|---|---|
cmd |
string | Executable and literal arguments |
options.work_dir |
string | Working directory |
options.env |
table | Environment variables |
options.pty |
table | Allocate a pseudo-terminal for the child |
options.process_group |
boolean | Start the child in its own process group so signals also reach descendants; unsupported on Windows |
Returns: Process, error
The process is created but not started.
Command Parsing
cmd is split into an executable and literal arguments using shell-like quoting: single and double quotes group a word, and a backslash escapes the following character. There is no shell, so no variable expansion, globbing, pipes, or redirection happens. An unclosed quote returns errors.INVALID.
-- One argument containing a space, passed literally
local proc = executor:exec("grep 'hello world' notes.txt")
-- $HOME is passed as the five characters $HOME, not expanded
local proc = executor:exec("echo $HOME")
To use shell features, invoke a shell explicitly:
local proc = executor:exec("/bin/sh -c 'ls *.log | wc -l'")
PTY Options
Allocating a PTY gives the child a real terminal: line editing, job control, and full-screen programs work as they do in a shell.
local proc = executor:exec("/bin/bash --noprofile --norc", {
pty = {width = 100, height = 30, term = "xterm-256color"},
})
| Field | Type | Default | Description |
|---|---|---|---|
width |
number | 80 | Initial PTY columns, 1 to 65535 |
height |
number | 24 | Initial PTY rows, 1 to 65535 |
term |
string | none | Child TERM value |
Width times height may not exceed 262,144 cells. A PTY-backed process merges the child's output into a single terminal stream; drive it with resize and attach_terminal rather than the stdin/stdout pipe methods.
start / wait
Start the process and wait for completion.
local proc = executor:exec("./build.sh")
local ok, err = proc:start()
if err then
return nil, err
end
local exit_code, err = proc:wait()
if err then
return nil, err
end
if exit_code ~= 0 then
return nil, errors.new({ kind = errors.INTERNAL, message = "Build failed with exit code: " .. exit_code })
end
stdout_stream / stderr_stream
Get streams to read process output.
local proc = executor:exec("./process-data.sh")
local stdout = proc:stdout_stream()
local stderr = proc:stderr_stream()
proc:start()
-- Read all stdout
local output = {}
while true do
local chunk = stdout:read(4096)
if not chunk then break end
table.insert(output, chunk)
end
local result = table.concat(output)
-- Check for errors
local err_output = {}
while true do
local chunk = stderr:read(4096)
if not chunk then break end
table.insert(err_output, chunk)
end
local exit_code = proc:wait()
stdout:close()
stderr:close()
if exit_code ~= 0 then
return nil, errors.new({ kind = errors.INTERNAL, message = table.concat(err_output) })
end
return result
write_stdin
Write data to process stdin.
local proc = executor:exec("head -n 3")
local stdout = proc:stdout_stream()
proc:start()
proc:write_stdin("banana\napple\ncherry\n")
local lines = stdout:read()
proc:wait()
stdout:close()
Each call writes the given bytes and returns. Call close_stdin() when the child
must see EOF:
local proc = assert(executor:exec("sort"))
local stdout = assert(proc:stdout_stream())
assert(proc:start())
assert(proc:write_stdin("banana\napple\n"))
assert(proc:close_stdin())
local sorted = assert(stdout:read())
close_stdin() is idempotent. Later writes fail because the input side is
closed. PTY-backed processes do not expose this pipe operation.
done
Use done() to observe exit without consuming the process handle:
local proc = assert(executor:exec("./worker"))
assert(proc:start())
local exits = assert(proc:done())
local status, open = exits:receive()
if open then
print(status.code, status.signal, status.error)
end
The returned channel delivers one exit record and then closes. Repeated calls
return the same channel. The record contains code, optional signal, and an
error only when the runtime could not observe the exit. A signal exit uses
128 + signal as its code. Unlike wait(), done() leaves the handle usable,
so streams, signal(), and close() remain available. wait() after delivery
returns the recorded code.
signal / close
Send signals or release the process.
local proc = executor:exec("./long-running-server.sh")
proc:start()
-- ... later, need to stop it ...
-- Send SIGTERM and release the handle
proc:close()
-- Send SIGKILL and release the handle
proc:close(true)
-- Or send a specific signal and keep the handle
local SIGINT = 2
proc:signal(SIGINT)
close(force?) signals a started child with SIGTERM, or SIGKILL when force is true, then reaps it in the background so the call does not block. A child still running after a grace period is killed so the reap always completes. An unstarted handle is simply invalidated, and closing twice is not an error. When process_group is enabled, signals target the group and still reach descendants after the leader exits.
Streams acquired before reaping remain readable until their last writer closes,
including a descendant that inherited the pipe. After close(), process methods
report process closed; use done() when the exit matters and the handle must
remain usable.
resize
Resize the PTY of a PTY-backed process. A pipe-backed process returns an error.
local ok, err = proc:resize(120, 40)
| Parameter | Type | Description |
|---|---|---|
width |
number | Columns, 1 to 65535 |
height |
number | Rows, 1 to 65535 |
Returns: boolean, error
Use it to set the initial geometry before handing the process to a terminal session. Once a session owns the process, send it a resize event instead.
attach_terminal
Attach an unstarted PTY-backed process to the calling process's terminal and return a TerminalSession.
local exec = require("exec")
local tty = require("tty")
local executor = assert(exec.get("app:exec"))
local proc = assert(executor:exec("/bin/bash --noprofile --norc", {
pty = {term = "xterm-256color"},
}))
local session = assert(proc:attach_terminal())
Returns: TerminalSession, error
The call consumes the process: the session becomes its sole lifecycle owner and the original handle can no longer be used. The session opens a surface on the current terminal port and owns PTY emulation, input encoding, resize, graceful and forced termination, and reaping. It needs a terminal port — a terminal host process, or a process spawned with a viewport grant — and fails when the port has no input controller or already has an open surface.
TerminalSession
| Method | Returns | Description |
|---|---|---|
send(event) |
boolean, error |
Forward one canonical TTY event to the child |
done() |
channel | Channel that fires once when the child finishes |
status() |
string, error |
"running" or "done", with the failure error when it failed |
close() |
boolean, error |
Request termination of a running child |
send accepts the key, mouse, resize, focus, and paste records described in TTY. Sending after the child has finished returns an error.
local channel = require("channel")
local events = assert(tty.events())
assert(tty.start())
local done = session:done()
while true do
local selected = channel.select({
events:case_receive(),
done:case_receive(),
})
if not selected.ok or selected.channel == done then break end
if selected.value.type == "close" then break end
assert(session:send(selected.value))
end
assert(session:close())
Permissions
Exec operations are subject to security policy evaluation.
| Action | Resource | Description |
|---|---|---|
exec.get |
Executor ID | Acquire an executor resource |
exec.run |
Command | Execute a specific command |
exec.run is evaluated against the raw command string, with the requested options as metadata:
| Key | Type | Description |
|---|---|---|
work_dir |
string | Requested working directory, empty when unset |
env_names |
string[] | Names of the environment variables passed, sorted; values are not exposed |
pty.requested |
boolean | Whether a PTY was requested |
pty.width |
number | Resolved PTY columns, present when requested |
pty.height |
number | Resolved PTY rows, present when requested |
pty.term |
string | Requested TERM value, present when requested |
A policy can therefore allow plain commands while restricting the ones that ask for a terminal or a particular working directory.
Errors
| Condition | Kind | Retryable |
|---|---|---|
| Invalid ID | errors.INVALID |
no |
| Permission denied | errors.INVALID |
no |
| Process closed | errors.INVALID |
no |
| Process not started | errors.INVALID |
no |
| Already started | errors.INVALID |
no |
| Unclosed quote in command | errors.INVALID |
no |
| No PTY on the process | errors.INVALID |
no |
| Terminal port unavailable | errors.UNAVAILABLE |
no |
See Error Handling for working with errors.
See Also
- Executor — executor configuration
- TTY — terminal events, surfaces, and viewports
- Terminal UI — a shell that hosts a PTY child in a viewport