About
- Project goals
- Problems Ward solves
- What Ward is not
- Core design principles you can rely on
- A quick taste: “shell replacement” style
- Importing modules
Ward is a Lua runtime for writing system scripts that are pleasant and safe like a real language, but still feel as direct as bash/sh for day-to-day automation.
Think of Ward as:
- A scripting runtime (you write Lua)
- A standard library for systems work (fs, env, process, net, term, time, etc.)
- A “shell-like” UX for pipelines, exit codes, and stdout/stderr — without shell footguns
Project goals
Ward is designed to help you write scripts that are:
- Readable: explicit modules, explicit options, explicit error handling
- Composable: small primitives that chain well (process pipelines, fs + fetch, etc.)
- Predictable: fewer implicit behaviors than shells; structured results instead of string parsing
- Practical: excellent at orchestrating external tools (git, tar, curl-like flows, build tools)
- Cross-platform by default: consistent APIs across Linux/macOS/Windows where feasible (in progress)
Problems Ward solves (and why not just bash/sh)
Shell scripting is powerful, but it tends to degrade quickly as complexity grows:
- Error handling is subtle (
set -e, pipelines, subshells, traps) - Quoting/escaping is fragile, especially with spaces/newlines and binary data
- Data is usually “strings all the way down” (hard to validate, hard to refactor)
- Concurrency and streaming require non-trivial patterns
- Portability across
sh/bash/dashand different OSes is painful
Ward addresses these by offering shell-like primitives with language-level structure:
- Explicit process execution (
ward.process) with structured results (ok, exit code, stdout/stderr) - Filesystem utilities (
ward.fs) with consistent return conventions for mutating operations - Environment overlay (
ward.env) so scripts can modify env safely without global side effects - Network fetch + HTTP (
ward.net) that composes withward.fs - Terminal UI (
ward.term) for prompts, progress, and ANSI utilities
What Ward is not
Ward is intentionally not trying to become:
- A full interactive shell replacement (job control, shell syntax, glob semantics everywhere)
- A configuration-management/orchestration framework (inventory/targeting is out of scope for now)
- A CPU-parallel compute runtime for Lua (Ward focuses on I/O overlap and orchestration)
Ward is a tool for writing robust scripts that run external programs and manipulate files reliably.
Core design principles you can rely on
1) Explicit modules
Ward APIs live under ward.* and are imported via require. This makes scripts easy to audit and refactor.
2) Structured boundaries Where shells typically return strings and exit codes, Ward usually returns either:
- a structured userdata (e.g.,
CmdResult) - or a result table like
{ ok, err }for mutating filesystem operations
3) Script-friendly errors Errors are meant to be understandable and printable. Many APIs are designed so you can adopt a “set -e” style by asserting success early and failing fast.
4) Async-capable runtime (without forcing you into a new programming model) Ward runs on an async-capable runtime. Many operations internally use async I/O, but most APIs are still called in a straightforward way. Some operations return awaitable objects when the operation must be explicitly awaited (interactive input, some timers, streaming reads, etc.).
A full explanation of awaitables, tasks, channels, and select is in the async section (see ward.async).
A quick taste: “shell replacement” style
A Ward script typically reads like “do X, assert it worked, then proceed”:
local process = require("ward.process")
process.cmd("git", "rev-parse", "--is-inside-work-tree")
:output()
:assert_ok("not a git repo")
local r = (process.cmd("printf", "hello\nworld\n") | process.cmd("wc", "-l")):output()
r:assert_ok()
print("lines:", r.stdout)
Importing modules
You can import either the root module or submodules:
local ward = require("ward")
local fs = require("ward.fs")
local path = require("ward.fs.path")
local io = require("ward.io")
local proc = require("ward.process")
local crypto = require("ward.crypto")
local retry = require("ward.helpers.retry")
local time = require("ward.time")
local term = require("ward.term")
Tip: Prefer importing the specific submodules you use; it keeps scripts explicit and makes the dependency surface obvious.
Conventions
Common
nil|stringmeans the function returns eithernilor a Lua string.- “bytes string” means a Lua string whose contents are raw bytes (binary-safe).
- Paths are typically accepted as strings (and in most ward.fs APIs also as ward.fs.path objects).
- Most option tables are optional; when omitted, defaults apply.
CLI sandbox limits (ward run, ward eval, ward repl)
Ward commands that execute Lua (ward run, ward eval, ward repl) support a
configurable sandbox. The most commonly used switches are:
--memory-limit BYTES- maximum Lua memory usage--instruction-limit N- approximate instruction budget (see notes below)--timeout SECONDS- wall-clock timeout (also accepts duration strings like500ms,2s,1m)--threads N- Tokio worker threads
Notes:
- Instruction limiting is intentionally coarse. Ward installs a Lua VM hook every 1024 instructions (or less if the configured limit is smaller), so a script may exceed the configured limit by up to 1023 instructions.
- The instruction hook does not execute while awaiting Rust async operations (e.g., I/O). Long-running I/O does not consume the instruction budget.
- It helps prevent runaway scripts (memory/time/instructions). This is not a secure sandbox for untrusted code.
CLI
Ward ships a single executable, ward, which provides a small set of commands
for running Lua-based system scripts.
This page documents the CLI behavior (command syntax, argument forwarding, exit codes, sandbox options) and includes usage examples.
Common concepts
Argument forwarding (arg)
All Ward commands that execute Lua (run, eval, repl) forward trailing CLI
arguments to the Lua program and expose them via the standard Lua global arg.
arg[0]identifies the entry being executed:run: the script patheval:(eval)repl:(repl)
arg[1]..arg[n]are the forwarded arguments.
It is recommended to use -- to terminate Ward’s CLI parsing when you are
forwarding arbitrary flags.
Exit codes
- If the Lua program finishes normally, Ward exits with code
0. - If Lua raises an unhandled error, Ward prints the error and exits with a non-zero code.
- If a script calls
require("ward.process").exit(code), Ward exits with exactly that code. - In
repl,Ctrl-Cexits with code130.
Sandbox options
run, eval, and repl share the same sandbox limit switches:
-m, --memory-limit <bytes>: memory cap for the Lua VM (bytes)-i, --instruction-limit <n>: approximate instruction cap (enforced via VM hook every ~1024 instructions)-t, --threads <n>: Tokio worker thread count (also used for the Lua async pool)-T, --timeout <duration>: wall-clock timeout (supports seconds or duration strings like500ms,2s,1m)
For details on instruction-limit behavior, see the notes in Conventions.
Logging
Ward’s runtime logging level can be configured through:
WARD_LOG=trace|debug|info|warn|error|fatal
ward run
Run a Lua file.
Synopsis
ward run [OPTIONS] FILE [--] [ARGS...]
Examples
Run a script and forward positional args:
ward run ./script.lua -- foo bar
Forward flags without ambiguity:
ward run ./script.lua -- --flag --other-flag=value
Shebang mode (Ward strips a leading #! line):
#!/usr/bin/env -S ward run
print("hello from ward")
ward eval
Evaluate a Lua chunk provided either via --expr or via stdin.
Synopsis
ward eval [OPTIONS] [-e|--expr LUA] [--] [ARGS...]
If --expr is omitted, ward eval reads code from stdin unless
--no-stdin is provided.
Options
-e, --expr <lua>: Lua code to evaluate.--no-stdin: do not read code from stdin when--expris not provided.
All sandbox options described above are supported.
Examples
Evaluate a one-liner:
ward eval -e 'print("hello")'
Use forwarded arguments:
ward eval -e 'print(arg[0]); print(arg[1])' -- foo
# prints:
# (eval)
# foo
Read code from stdin:
echo 'print("from stdin")' | ward eval
Explicitly refuse stdin (useful for CI scripts that should fail fast):
ward eval --no-stdin
# error: no code provided (use --expr or pipe into stdin)
ward repl
Start an interactive Lua REPL inside the Ward runtime.
The REPL keeps a single Lua state for the entire session, so definitions persist between inputs.
Synopsis
ward repl [OPTIONS] [--] [ARGS...]
Options
--no-prompt: disable prompts and banner (useful when piping input).
All sandbox options described above are supported.
Exiting the REPL
You can exit in any of the following ways:
- Press
Ctrl-D(EOF) - Type one of:
exit,quit,:q,:quit - Call
require("ward.process").exit(code)
Ctrl-C exits with code 130.
Multi-line input
The REPL supports multi-line chunks: if the current input is syntactically
incomplete, Ward switches the prompt to >> and keeps reading until the
chunk is complete.
Convenience: =expr shorthand
If you enter a single line that starts with =, Ward rewrites it as print(<expr>).
Example:
> =1+2
3
Examples
Start a REPL and inspect the platform:
$ ward repl
> local p = require("ward.host.platform")
> print(p.os())
linux
Use forwarded args in a REPL session:
$ ward repl -- hello
> print(arg[0])
(repl)
> print(arg[1])
hello
Use the REPL as a non-interactive evaluator (use --no-prompt to keep output clean):
printf 'print("hi")\n' | ward repl --no-prompt
ward.async - tasks and channels
- Overview
- Awaitables contract (important)
- Tasks
- Channels
- Selecting across awaitables
async.await(awaitable) -> ...
local async = require("ward.async")
Overview
Ward scripts run in an async-capable Lua runtime. Most operations that involve I/O (process execution, HTTP, timers) are implemented using async Rust internally, but can typically be called from Lua in a straightforward way because Ward drives the runtime for you.
ward.async provides user-level concurrency primitives:
- Tasks: run Lua functions concurrently via
async.spawn(...). - Channels: communicate between tasks with bounded queues via
async.channel(...). - Await helpers: a single awaitable contract (
:wait()/__call()) used consistently byasync.awaitandasync.select.
These primitives are intended for local concurrency (I/O overlap, worker pools, fan-out/fan-in), not for CPU-parallel Lua execution.
Awaitables contract (important)
Ward uses a single user-facing await protocol:
- An awaitable is a userdata that implements
:wait()(preferred) and may also implement__call()so it can be awaited viaa(). async.await(awaitable)andasync.select(list)operate on this protocol.TaskandChannelare awaitables viaTask:wait()andChannel:wait().
This contract exists to keep concurrency composable: you can pass awaitables
into async.select without “eagerly awaiting” them first.
Tasks
async.spawn(fn, ...) -> Task
Spawns a concurrent Lua task that runs fn(...).
local t = async.spawn(function(x)
return x + 1, "ok"
end, 41)
Lifetime and cancellation semantics
Tasks are structured by default: if the Task handle becomes unreachable
and is garbage-collected (or dropped by losing scope), the underlying task may
be aborted.
If you intentionally want a “fire-and-forget” task, call t:detach() to prevent
abort-on-drop. Prefer structured tasks unless you have a clear reason to detach.
Task:wait() -> ...
Waits for the task to finish and returns the function’s return values.
local n, s = t:wait()
print(n, s) -- 42 ok
This makes Task compatible with the awaitables contract and with async.select.
Errors:
- Raises
"task already joined"ifwait()is called more than once. - Raises
"cancelled"if the underlying task was aborted (for example, because the handle was dropped withoutdetach()).
Task:cancel() -> boolean
Requests task cancellation.
- Returns
trueif cancellation was requested. - Returns
falseif the task is already finished (or already cancelled).
Task:done() -> boolean
Returns true when the task has finished.
Task:detach() -> boolean
Detaches the task from structured cancellation-on-drop/GC.
- Returns
trueafter switching the task into detached mode. - After detaching, dropping the
Taskhandle will not abort the underlying task.
Channels
async.channel(opts?) -> Channel
Creates a bounded channel.
Accepted opts:
nil(defaults apply)- a number (capacity)
- a table:
{ capacity = N }
If omitted, capacity defaults to 64.
local ch = async.channel({ capacity = 16 })
Channel:send(value) -> true | nil, err
Async. Sends a value into the channel.
- Returns
trueon success. - Returns
nil, "closed"if the channel is closed.
Channel:try_send(value) -> true | nil, err
Sync. Attempts to send without waiting.
- Returns
trueon success. - Returns
nil, "full"if the buffer is full. - Returns
nil, "closed"if the channel is closed.
Channel:wait() -> value | nil, err
Async. Receives a value from the channel.
- Returns the value on success.
- Returns
nil, "closed"after the sender is closed and the queue is drained.
This makes Channel compatible with the awaitables contract and with async.select.
Notes:
Channel:wait()returnsnil, "closed"only after the sender is closed and the queue is drained.- If your channel can carry
nil, always checkerrto distinguish anilmessage from closure.
Channel:try_recv() -> value | nil, err
Sync. Attempts to receive without waiting.
- Returns the value on success.
- Returns
nil, "empty"if no value is available. - Returns
nil, "closed"if the channel is disconnected.
Note: if another task is currently blocked in wait(), try_recv() may return
nil, "busy" (implementation detail). Treat both "empty" and "busy" as
retryable states.
Channel:close() -> true
Closes the sender side of the channel.
Important: close() does not discard queued items. Receivers can continue
calling wait() until the channel is fully drained and then observe nil, "closed".
Selecting across awaitables
async.select(list) -> idx, ...
Races multiple awaitables concurrently and returns the first one that completes.
list must be an array-like table of userdata awaitables. For convenience,
Ward also accepts:
Taskuserdata (waited via:wait())Channeluserdata (waited via:wait())
The return value is:
idx- the 1-based index intolistthat completed first- followed by that awaitable’s return values
Note: async.select cancels the non-winning internal waiters
(the race participants), but it does not automatically cancel arbitrary
underlying work unless that work is itself cancellation-aware. For
example, if you race a long-running task against a timeout, the task
will continue running unless you explicitly cancel it (or allow it to
be aborted via structured drop/GC behavior).
Example: race a task against a timeout
local async = require("ward.async")
local time = require("ward.time")
local t = async.spawn(function()
time.sleep("200ms"):wait()
return "task"
end)
local idx, v = async.select({ t, time.sleep("50ms") })
print("winner", idx, v)
async.await(awaitable) -> ...
Awaits a single awaitable userdata using the awaitables contract
(:wait() preferred, or __call()).
This is mostly a convenience wrapper for readability and for writing higher-level helpers.
local async = require("ward.async")
local time = require("ward.time")
async.await(time.sleep("100ms"))
ward.convert
local json = require("ward.convert.json")
local yaml = require("ward.convert.yaml")
local toml = require("ward.convert.toml")
local ini = require("ward.convert.ini")
Common patterns:
encode(value) -> stringdecode(string) -> valueencode_async(value) -> stringdecode_async(string) -> value
Example (JSON):
local json = require("ward.convert.json")
local s = json.encode({ a = 1, b = { true, false } })
local t = json.decode(s)
ward.convert.json
Functions:
json.encode(value, opts?) -> stringjson.decode(text) -> valuejson.encode_async(value, opts?) -> string(runs on a blocking thread)json.decode_async(text) -> value(runs on a blocking thread)
opts for encode/encode_async:
pretty(boolean, defaultfalse) - pretty-print.indent(integer, default2) - spaces per indent level (must be > 0).
ward.convert.yaml
Functions:
yaml.encode(value) -> stringyaml.decode(text) -> valueyaml.encode_async(value) -> string(blocking thread)yaml.decode_async(text) -> value(blocking thread)
ward.convert.toml
Functions:
toml.encode(value) -> stringtoml.decode(text) -> valuetoml.encode_async(value) -> string(blocking thread)toml.decode_async(text) -> value(blocking thread)
ward.convert.ini
Functions:
ini.encode(table) -> stringini.decode(text) -> tableini.encode_async(table) -> string(blocking thread)ini.decode_async(text) -> table(blocking thread)
ini.encode expects a table shaped like:
{
[""] = { root_key = "value" }, -- optional default section
section = { key1 = "v1", flag = true }
}
All values are stringified; booleans/numbers are accepted and converted to strings.
ward.crypto
local crypto = require("ward.crypto")
Byte-string functions (Lua strings are binary-safe):
crypto.sha256(bytes) -> string(hex)crypto.sha1(bytes) -> string(hex)crypto.md5(bytes) -> string(hex)
File functions (async, streamed):
crypto.sha256_file(path) -> string(hex)crypto.sha1_file(path) -> string(hex)crypto.md5_file(path) -> string(hex)
Examples:
local crypto = require("ward.crypto")
local digest = crypto.sha256("abc")
print("sha256(abc) =", digest)
local file_digest = crypto.sha256_file("Cargo.toml")
print("sha256(Cargo.toml) =", file_digest)
ward.env
env.get(key, default?) -> string|nilenv.set(key, value) -> booleanenv.export(key, value?) -> booleanenv.unset(key) -> booleanenv.clear() -> nilenv.list() -> tableenv.is_exists(key) -> booleanenv.hostname() -> stringenv.which(name) -> string|nilenv.is_in_path(path_or_name) -> boolean
Ward uses an environment overlay:
env.set/env.unset/env.clearmodify Ward’s overlay only (they do not mutate the process-global OS environment).- Read operations (
env.get/env.list/env.is_exists/env.which/env.is_in_path) resolve the effective environment: the process environment plus overlay modifications (overlay wins). - The overlay is applied to child processes spawned via
ward.processand to the git invocations used byward.net.fetch.git. For child processes, precedence is: process env → Ward overlay → per-command overrides (Cmd:env / Cmd:envs).
Used to inspect and modify environment/Ward variables. Mutations are applied to the current process via local Ward env variables overlay to keep it safe in async contexts.
local env = require("ward.env")
env.get(key, default?) -> string|nil
Get environment variable key. If missing (or key is empty), returns default.
local home = env.get("HOME")
local port = env.get("PORT", "8080")
env.set(key, value) -> boolean
Set an environment variable in the Ward overlay. Returns false if the key
is invalid (empty, contains =, or contains \0) or the value contains \0.
env.set("FOO", "bar")
env.export(key, value?) -> boolean
Mutate the process environment (not just Ward’s overlay). This mirrors
export in shells and affects concurrently running scripts in the same
process, so use it sparingly.
valueomitted /nil⇒ removes the variable from the process environment and overlay.- Returns
falseon invalid keys.
-- Prefer env.set for isolation; use export only when you must change
-- the process env.
env.export("PATH", "/custom/bin:" .. (env.get("PATH") or ""))
env.unset(key) -> boolean
Remove an environment variable (from the overlay). Returns false if the key
is invalid.
env.unset("FOO")
env.clear() -> nil
Clears all overlay modifications (restores the effective environment back to the base process environment).
env.list() -> table
Returns a table of the effective environment (base process env with overlay applied).
Example (print all):
local t = env.list()
for k, v in pairs(t) do
print(k, v)
end
env.is_exists(key) -> boolean
Returns true if variable exists in the effective environment.
if env.is_exists("CI") then
print("running in CI")
end
env.hostname() -> string
Returns hostname.
env.which(name) -> string|nil
Searches PATH (and Windows PATHEXT) for an executable.
local git = env.which("git")
assert(git, "git not found")
env.is_in_path(path_or_name) -> boolean
Returns whether a candidate is reachable via PATH search.
ward.fs
- Existence and type checks
- Path utilities
- Directory listing and globbing
- Directories and removal
- Permissions and links
- Timestamps
- File IO
- Copy and move
- Temporary directories
local fs = require("ward.fs")
4.1 Existence and type checks
fs.is_exists(path) -> booleanfs.is_dir(path) -> booleanfs.is_file(path) -> booleanfs.is_link(path) -> booleanfs.is_symlink(path) -> booleanfs.is_block_device(path) -> boolean(Unix)fs.is_char_device(path) -> boolean(Unix)fs.is_fifo(path) -> boolean(Unix)fs.is_socket(path) -> boolean(Unix)fs.is_executable(path) -> booleanfs.is_readable(path) -> booleanfs.is_writable(path) -> boolean
Notes:
- For files,
fs.is_readable/fs.is_writablecheck whether the file can be opened for read/write. - For directories,
fs.is_readablechecks whether the directory can be listed (read_dir), andfs.is_writablechecks whether a temporary file can be created and removed inside the directory.
Example:
if fs.is_file("./build.sh") and fs.is_executable("./build.sh") then
print("runnable")
end
Path utilities
fs.readlink(path) -> string|nilfs.realpath(path) -> string|nilfs.dirname(path) -> stringfs.basename(path) -> stringfs.join(a, b, ...) -> string
Example:
local p = fs.join("build", "out", "app.bin")
print(fs.dirname(p))
print(fs.basename(p))
fs.path` - pure path manipulation (Path userdata)
fs.path provides a Path userdata type for manipulating paths without
touching the filesystem.
It is useful for building paths safely and passing them to ward.fs APIs.
Constructors:
fs.path.new(path) -> Pathfs.path.cwd() -> Pathfs.path.join(a, b) -> Path(both arguments may be strings orPath)
Methods on Path:
Path:is_abs() -> booleanPath:normalize() -> PathPath:parts() -> table(array-like, path components)Path:split() -> (dirname: string, basename: string)Path:join(segment) -> PathPath:dirname() -> stringPath:basename() -> stringPath:extname() -> nil|stringPath:stem() -> nil|stringPath:as_string() -> string
Interoperability:
Most ward.fs functions accept either a path string or a fs.path object.
Example:
local fs = require("ward.fs")
local path = require("ward.fs.path")
local p = path.new("build/../out/app.bin"):normalize()
assert(fs.mkdir(p:dirname(), { recursive = true, force = true }).ok)
assert(fs.write(p, "hello\n").ok)
print("wrote:", tostring(p))
Directory listing and globbing
fs.list(path, opts?) -> table
Returns an array-like table of paths.
Options (opts table):
recursive(boolean, defaultfalse)depth(integer, default0) - recursion depth limit;0means unlimiteddirs(boolean, defaultfalse) - include directoriesfiles(boolean, defaultfalse) - include filesregex(string|nil) - regex filter applied to the full path string
Notes:
- If both
dirsandfilesarefalse(the default), both directories and files are included. - Ordering is OS-dependent (Ward does not currently sort results).
Examples:
-- list everything
for _, p in ipairs(fs.list(".")) do
print(p)
end
-- only files, recursive, depth-limited
local files = fs.list("src", { recursive = true, depth = 3, files = true })
fs.glob(pattern) -> table
Returns an array-like table of paths matching a glob pattern.
for _, p in ipairs(fs.glob("src/**/*.rs")) do
print(p)
end
Directories and removal
Result convention for mutating operations: most functions that change the
filesystem return a table { ok, err } where ok is a boolean and err is
a string (or nil on success). When ok is false, err is intended to be
human-readable and suitable for printing/logging.
fs.mkdir(path, opts?) -> { ok, err }
Create directory.
Options:
recursive(boolean, defaultfalse)mode(number, unix-only; default0o755)force(boolean, defaultfalse) - treat “already exists” as success
assert(fs.mkdir("build", { recursive = true, mode = 0o755 }).ok)
fs.rm(path, opts?) -> { ok, err }
Remove file or directory.
Options:
recursive(boolean, defaultfalse) - required for directoriesforce(boolean, defaultfalse) - treat missing-path as success
assert(fs.rm("build", { recursive = true, force = true }).ok)
fs.unlink(path, opts?) -> { ok, err }
Remove a file (like rm -f file).
Permissions and links
fs.chmod(path, mode) -> { ok, err }fs.chown(path, uid, gid) -> { ok, err }(Unix)fs.rename(from, to) -> { ok, err }fs.link(from, to) -> { ok, err }(hard link)fs.symlink(from, to) -> { ok, err }(symbolic link)
Timestamps
fs.touch(path, opts?) -> { ok, err }
Create file if missing and update timestamps.
Options:
recursive(boolean, defaultfalse) - create parent directories first
assert(fs.touch("logs/app.log", { recursive = true }).ok)
File IO
fs.read(path, opts?) -> bytes string
Reads a file and returns a Lua string or raw bytes.
Options:
mode("text"|"binary", default"text")
local data = fs.read("README.md")
fs.write(path, data, opts?) -> { ok, err }
Write data to a file.
Options (selected):
mode("overwrite"|"append"|"prepend"|"binary", default"overwrite")binary(boolean, defaultfalse) - convertdataas bytes
Notes:
- Ward does not automatically create parent directories; combine with
fs.mkdir(fs.dirname(path), {recursive=true}).
assert(fs.write("out.txt", "hello\n").ok)
assert(fs.write("out.txt", "more\n", { mode = "append" }).ok)
Copy and move
fs.copy(from, to, opts?) -> { ok, err }fs.move(from, to, opts?) -> { ok, err }
Notes:
fs.copyoperates on regular files; it does not copy directories.fs.moveusesrenamewhen possible. On cross-device moves it falls back to copy+remove for regular files; moving directories or symlinks across devices is currently unsupported.
Temporary directories
fs.tempdir(prefix?) -> string
Creates a temporary directory and returns its path.
local dir = fs.tempdir("ward-")
print("tmp:", dir)
ward.helpers
local helpers = require("ward.helpers")
local retry = require("ward.helpers.retry")
local n = require("ward.helpers.number")
local s = require("ward.helpers.string")
local t = require("ward.helpers.table")
helpers.number
Numeric predicates and aggregates. All functions return errors if arguments have the wrong type.
Functions:
is_integer(n) -> boolean-truewhennis finite and has no fractional component.is_float(n) -> boolean-truefor finite, non-integer numbers.is_number(v) -> boolean-truefor Lua integers or floats.is_nan(v) -> boolean-truewhenvis a float NaN.is_infinity(v) -> boolean-truewhenvis+/-inf.is_finite(v) -> boolean-truefor integers and finite floats.round(n, precision) -> number- roundsntoprecisiondecimal places.ceil(n) -> number- ceiling.floor(n) -> number- floor.abs(n) -> number- absolute value.clamp(n, min, max) -> number- clampsninto[min, max].sign(n) -> integer- returns1,0, or-1.random(min, max) -> number- uniform random betweenminandmax(inclusive for integers).avg(...) -> number- average of variadic arguments (at least one required).min(...) -> number- minimum of variadic arguments (at least one required).max(...) -> number- maximum of variadic arguments (at least one required).sum(...) -> number- sum of variadic arguments (at least one required).
helpers.string
String utilities and regex helpers. Patterns use Rust regex syntax.
Functions:
trim(s) -> string- trim both ends.ltrim(s) -> string- trim start.rtrim(s) -> string- trim end.contains(s, substr) -> boolean- substring test.starts_with(s, substr) -> boolean- prefix test.ends_with(s, substr) -> boolean- suffix test.replace(s, substr, repl) -> string- replace first occurrence.replace_all(s, substr, repl) -> string- replace all occurrences.split(s, sep) -> table- splits into an array table.join(sep, ...) -> string- joins variadic strings withsep.to_lower(s) -> string- lowercase.to_upper(s) -> string- uppercase.to_title(s) -> string- title-case words.to_snake(s) -> string- snake_case.to_camel(s) -> string- camelCase.to_kebab(s) -> string- kebab-case.to_pascal(s) -> string- PascalCase.to_slug(s) -> string- slug (alias of kebab-case).match(s, pattern) -> table- first regex match captures as array (empty table if none).match_all(s, pattern) -> table- list of capture arrays for all matches.match_replace(s, pattern, repl) -> string- replace first regex match.match_replace_all(s, pattern, repl) -> string- replace all regex matches.
helpers.table
Functional-style table helpers. Unless noted, sequence order is preserved and
non-sequence keys are ignored. Functions that expect an array treat numeric
keys from 1..n as the sequence.
Functions:
is_empty(tbl) -> boolean-truewhen table has no key/value pairs.contains(tbl, value) -> boolean-trueif any value equalsvalue.concat(tbl, ...) -> table- concatenates array tables.merge(tbl, ...) -> table- shallow merge (later values overwrite).deep_merge(tbl, ...) -> table- recursive merge for nested tables.map(tbl, fn) -> table- appliesfn(value, key); returns table with same keys.filter(tbl, fn) -> table- keeps entries wherefn(value, key)returnstrue.reduce(tbl, fn, acc) -> any- folds withfn(acc, value, key).each(tbl, fn) -> table- callsfn(value, key)for side effects; returns original table.find(tbl, fn) -> any|nil- first value wherefn(value, key)returnstrue, elsenil.findall(tbl, fn) -> table- array of values where predicate istrue.sort(tbl, fn) -> table- returns sorted copy using comparatorfn(a, b) -> boolean(truewhena < b).reverse(tbl) -> table- reversed array copy.shuffle(tbl) -> table- shuffled array copy.flatten(tbl) -> table- recursively flattens nested array tables.uniq(tbl) -> table- removes duplicates (first occurrence wins).uniq_by(tbl, fn) -> table- uniqueness by computed keyfn(value, key).count(tbl, fn) -> integer- number of entries where predicate istrue.keys(tbl) -> table- array of keys.values(tbl) -> table- array of values.push(tbl, value) -> nil- append to end.append(tbl, value) -> nil- alias ofpush.pop(tbl) -> any|nil- remove and return last element (ornil).shift(tbl) -> any|nil- remove and return first element (ornil).prepend(tbl, value) -> nil- insert at start.join(tbl, sep) -> string- stringifies sequence values with separator.
helpers.retry
helpers.retry implements an async retry loop for functions that may intermittently fail.
retry.run(fn, opts?) -> any
Calls fn() and returns its result. If fn() errors, Ward retries until success or the attempt limit is reached.
Options (opts table):
- attempts (integer, default 3) - total attempts (minimum 1)
- delay (duration, default
100ms) - base delay between retries; accepts strings like50ms,1.5s,2m - max_delay (duration|nil) - optional cap on the delay; accepts the same duration strings
- backoff (number, default 2.0) - multiplier applied to the delay after each failed attempt (minimum 1.0)
- jitter (boolean, default false) - randomize delay to reduce thundering herd
- jitter_ratio (number, default 0.2) - maximum relative jitter, clamped to 0..1
Example:
local retry = require("ward.helpers.retry")
local net = require("ward.net.http")
local res = retry.run(function()
...
end, { attempts = 5, delay = "200ms", backoff = 2.0, jitter = true })
print("ok:", res.status)
ward.host
local host = require("ward.host")
local platform = require("ward.host.platform")
local resources = require("ward.host.resources")
host.platform
Platform inspection helpers (OS, arch, etc.).
platform.is_windows() -> booleanplatform.is_macos() -> booleanplatform.is_linux() -> booleanplatform.is_bsd() -> booleanplatform.is_unix() -> booleanplatform.os() -> string(e.g.,"linux")platform.arch() -> string(e.g.,"x86_64")platform.platform() -> string("<os>-<arch>")platform.version() -> string(long OS version, best-effort)platform.release() -> string(kernel version, best-effort)platform.hostname() -> stringplatform.exe_suffix() -> string(".exe"on Windows, else"")platform.path_sep() -> string("/"or"\\")platform.env_sep() -> string(":"or";")platform.newline() -> string("\r\n"on Windows, else"\n")platform.endianness() -> string("little"or"big")platform.shell() -> table- array-like command for the default shell:- Unix:
{ "sh", "-lc" } - Windows:
{ "cmd", "/C" }
- Unix:
platform.info() -> table- one-shot snapshot containing the fields above plusis_windows,is_unix,is_bsd,platform,shell.
host.resources
Resource inspection (memory, CPU) for the host process.
resources.get() -> tablereturns:memory.total,memory.available,memory.used,memory.free(bytes)cpu.load["1m"],cpu.load["5m"],cpu.load["15m"](load averages)cpu.cores.logical(integer),cpu.cores.physical(integer|nil)uptime(seconds)hostname(string, best-effort)
ward.io
io.read_all(opts?) -> bytes stringio.read_line() -> byts string|nilio.read_lines() -> function- Output
local io = require("ward.io")
Ward serializes reads/writes with internal mutexes so concurrent operations do not interleave unpredictably.
io.read_all(opts?) -> bytes string
Reads all remaining stdin into a string.
Optional opts:
max_bytes(number|integer) - if provided, fails when stdin exceeds this limit.
local s = io.read_all()
-- hard cap (1 MiB)
local s2 = io.read_all({ max_bytes = 1024 * 1024 })
io.read_line() -> byts string|nil
Reads one line from stdin.
- Returns
nilon EOF.
local line = io.read_line()
if line == nil then return end
print("got:", line)
io.read_lines() -> function
Returns an iterator-like function. Each call reads one line from stdin and
returns bytes string|nil (nil on EOF).
local next_line = io.read_lines()
while true do
local line = next_line()
if line == nil then break end
print(line)
end
Output
io.write_stdout(data) -> trueio.write_stderr(data) -> trueio.flush_stdout() -> nilio.flush_stderr() -> nil
io.write_stdout("hello")
io.write_stderr("warn\n")
io.flush_stdout()
ward.ipc.unix
Fast local IPC using Unix domain sockets. Available on Unix platforms (Linux, macOS). This module integrates with Ward’s awaitable I/O model.
Connecting and listening
-
unix.connect(path, opts?) -> UnixStream- Connects to
pathand returns a stream. Options are currently reserved; unknown fields raise errors to avoid silent misconfiguration.
- Connects to
-
unix.listen(path, opts?) -> UnixListener- Default behavior removes stale socket paths: if a prior server crashed and left the path behind, Ward attempts a connection; a refused/not-found result removes the path, while a successful connection errors with “address in use”.
- Options:
backlog: positive integer (default 128).mode: permission bits (use0o660ortonumber("660", 8)).owner/group: numeric uid/gid to chown after bind.unlink(defaulttrue): whenfalse, existing paths abort instead of being removed.unlink_on_close(defaulttrue): unlink the path when the listener is closed/dropped (only for paths created by Ward).mkdir: create parent directories before binding.
Streams
UnixStream methods (all return awaitables yielding {value}|nil, err):
read(n?)(default 16384 bytes),read_exact(n)write(bytes)(returns bytes written),write_all(bytes)shutdown()(close write half),close()(drop both halves)- Awaitable helpers:
wait(n?)and calling the stream itself delegates toread(n?). - Field:
stream.closed(boolean snapshot).
UnixListener:
accept() -> UnixStream|nil, errclose() -> boolean(unlinks if owned and configured).
Examples
Echo server + client:
local async = require("ward.async")
local unix = require("ward.ipc.unix")
local json = require("ward.convert.json")
local socket = "/tmp/ward-echo.sock"
local listener = assert(unix.listen(socket, {
backlog = 8,
mode = tonumber("660", 8),
unlink_on_close = true,
}))
async.spawn(function()
while true do
local stream, err = listener:accept()
if not stream then return print("accept failed: " .. tostring(err)) end
async.spawn(function()
while true do
local data, rerr = stream:read(1024)
if not data then return stream:close() end
stream:write_all(data):wait()
end
end)
end
end):detach()
local client = assert(unix.connect(socket):wait())
assert(client:write_all("ping"):wait())
local resp = assert(client:read(4):wait())
print(json.encode({ response = resp }))
See samples/ipc_unix_echo.lua for a complete sample including mkdir, stale
cleanup, and permission settings.
ward.lifecycle
local lifecycle = require("ward.lifecycle")
Lifecycle provides:
- Shutdown request detection (signals, cancellation)
- Hooks you can register to run before exit
Common pattern:
local lifecycle = require("ward.lifecycle")
lifecycle.on_shutdown(function(reason)
-- flush files, cleanup temp dirs
end)
Functions:
lifecycle.on(signal, fn) -> id- register a handler for a signal name/number ("HUP","INT","QUIT","USR1","USR2","PIPE","TERM", or a numeric code). Returns a handler id.lifecycle.on_shutdown(fn) -> id- register a shutdown callback (LIFO order).lifecycle.off(id) -> boolean- remove a previously registered handler.lifecycle.request(code?) -> ()- request shutdown;codeis an optional exit code.lifecycle.requested() -> boolean- whether shutdown has been requested.lifecycle.code() -> integer|nil- shutdown exit code (if known).
Notes:
- Ctrl-C / SIGINT requests shutdown by default.
- Shutdown callbacks run once in LIFO order; failures are best-effort and do not prevent later callbacks.
ward.log
local log = require("ward.log")
log.info("hello", "world")
log.trace(...)
log.debug(...)
log.warn(...)
log.error(...)
log.fatal(...)
Ward log is intentionally minimal; use it for script-friendly logs.
ward.module
ward.module is Ward’s external Lua module manager. It downloads third-party
Lua code into Ward’s data directory and makes it available to the current
process by extending package.path, so you can require("<name>.<submodule>")
(and other repo-local module names) directly. Downloads are stored in a
content-addressed layout so multiple versions can coexist and be reused across runs.
Import
local module = require("ward.module")
Where modules live
Ward keeps external modules under an “externals” directory in the user data home:
- Base directory:
${XDG_DATA_HOME}/ward/externals- If
XDG_DATA_HOMEis not set:~/.local/share/ward/externals
- If
- Content-addressed store:
${base}/.store/<id>
The store id is derived from the normalized source URL and the selected
revision (for git sources), so changing tag/branch/rev produces a
different <id>.
Ward maintains a per-process binding map so that once you bind <name>
to an id, future require(...) calls in that same run resolve consistently.
The binding operation also injects the selected store root into package.path
(idempotently), so standard Lua resolution works. Rebinding is rejected unless
you pass force=true, in which case Ward also clears matching package.loaded
entries and updates package.path to point at the new store root.
Naming rules
If you don’t specify a name, it is derived from the URL:
- Basename is taken from the URL path; common suffixes are removed
(
.git,.lua,.zip,.tar.gz,.tgz), and query/fragment parts are ignored. - The resulting name is canonicalized:
- lowercase
- non-alphanumeric separators collapse to
_ - names starting with a digit are prefixed with
_
Submodules are supported:
require("<name>.<submodule>")
(Each segment must be alphanumeric or _.)
API
module.dir() -> string
Returns the absolute path to the externals base directory.
local module = require("ward.module")
print(module.dir())
module.git(url, opts?) ->
Fetches a git repository, checks out a selected revision, and binds it as
<name> for this process.
Arguments
url(string): git repository URL (HTTPS/SSH, etc.)opts(table, optional):name(string): override derived module name / binding name- Exactly one selector:
rev(string): commit hashbranch(string): branch nametag(string): tag name- If none provided, defaults to “head”
force(boolean, defaultfalse): allow rebinding<name>to a different id (also clears cachedrequire)depth(integer): shallow clone depthrecursive(boolean): fetch submodulestimeout(number, seconds): overall fetch timeoutmax_bytes(integer): abort if transfer exceeds this sizefilter_blobs(boolean): request partial clone with blob filtering (when supported)
Return fields
ok(boolean): whether fetch/checkout succeededname(string): canonicalized namerequire(string): require target (e.g.my_lib)path/store_path(string): checkout directory pathid(string): content-addressed id for this URL+selector
Example
local module = require("ward.module")
local result = module.git("https://github.com/org/repo.git", {
name = "my_lib",
tag = "v1.2.3",
depth = 1,
recursive = true,
})
assert(result.ok, "git fetch failed")
-- Require a submodule from the checked out repo
local api = require(result.require .. ".api")
print("loaded", result.require, "from", result.path, "id", result.id)
module.url(url, opts?) -> { ok, name, require, path, store_path, id }
Downloads a single Lua file and binds it as <name>. The downloaded
content is stored as init.lua inside the store directory for its id.
Arguments
url(string): HTTP(S) URL to a.luafile (or any endpoint returning Lua content)opts(table, optional):name(string): override derived module nameinsecure(boolean, defaultfalse): allow invalid TLS certificatesfollow_redirects(boolean, defaulttrue)retries(integer, default5, minimum1)retry_delay(integer, milliseconds, default2000)force(boolean, defaultfalse): allow rebinding<name>to a new idtimeout(number, seconds): overall request timeoutmax_bytes(integer): abort if download exceeds this sizemethod(string): HTTP method (defaultGET)headers(table<string, string>): additional request headers
Return fields Same shape as module.git.
Example
local module = require("ward.module")
local result = module.url("https://example.com/plugin.lua", {
headers = { Authorization = "Bearer TOKEN" },
method = "POST",
retry_delay = 500,
})
assert(result.ok, "download failed")
local plugin = require(result.require)
print("plugin loaded:", plugin.version and plugin.version() or "<no version()>")
Notes and best practices
- Prefer pinning git modules with
tagorrevto get reproducible behavior. - Use
force=trueonly when you intentionally want to rebind a name within a single run. - If you need multiple versions at once, bind them under different names
(e.g.
my_lib_v1,my_lib_v2) rather than forcing rebinding.
ward.net
ward.net.http- HTTP request primitivesward.net.fetch- fetch into a file/dirward.module- external module manager
local net = require("ward.net")
local http = require("ward.net.http")
local fetch = require("ward.net.fetch")
ward.net groups network-related helpers. Today it exposes two submodules:
ward.net.http- in-process HTTP requests viareqwestward.net.fetch- higher-level “fetch into a file/dir” helpers
ward.net.http - HTTP request primitives
Functions
All functions below are async (implemented with create_async_function).
Call them normally and receive a HttpResponse userdata.
http.get(url, opts?) -> HttpResponsehttp.delete(url, opts?) -> HttpResponsehttp.options(url, opts?) -> HttpResponsehttp.post(url, opts?) -> HttpResponsehttp.put(url, opts?) -> HttpResponse
Options (opts table)
opts is optional. When omitted or not a table, defaults are applied.
query(table) - query parameters.- Keys are strings.
- Values must be
string,number,integer, orboolean(they are converted to strings).
headers(table) - header map:string -> string.timeout(number) - request timeout in seconds (float accepted). Must be positive and finite.follow_redirects(boolean, defaulttrue) - when enabled, redirects are followed (limited to 10).allow_error(boolean, defaultfalse) -false: non-2xx responses raise a runtime error.true: non-2xx responses are returned asHttpResponse.
Body options (used by post and put):
json(any) - serializable Lua value encoded as JSON.form(table) - form fieldsstring -> string.
If both json and form are present, JSON takes precedence.
HttpResponse userdata
Returned by http.* functions.
Fields (also available via methods):
resp.status(integer) - HTTP status code.resp.headers(table) - header mapstring -> string.- Note: duplicate header names will be overwritten in the table (last one wins).
resp.body(string|nil) - response body decoded as text.
Methods:
resp:is_ok() -> boolean- true for 2xx.resp:get_status() -> integerresp:get_headers() -> tableresp:get_body() -> string|nil
Examples:
local http = require("ward.net.http")
-- Basic GET
local r = http.get("https://example.com", { follow_redirects = true })
print(r.status)
print(r:is_ok())
-- Query + headers
local r2 = http.get("https://httpbin.org/get", {
query = { q = "ward", page = 1, debug = true },
headers = { ["User-Agent"] = "ward" },
timeout = 10,
allow_error = true,
})
print(r2.status)
print(r2:body())
-- POST JSON
local r3 = http.post("https://httpbin.org/post", {
json = { hello = "world", n = 1 },
headers = { ["Content-Type"] = "application/json" },
})
assert(r3:is_ok())
ward.net.fetch - fetch into a file/dir
fetch is for “download/checkout into a path” workflows that compose well with ward.fs.
fetch.url(url, opts?) -> FetchResponse
Downloads the response body as bytes into a file (streaming), then returns metadata.
Options (opts table):
method(string, default"GET") - one of:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS.headers(table) - header mapstring -> string.timeout(number) - request timeout in seconds.follow_redirects(boolean, defaulttrue) - redirects are followedinsecure(boolean, defaultfalse) - allow invalid TLS certificates. (limited to 10).into(string|nil) - destination file path.- If omitted, Ward creates a unique file path under the OS temp directory.
max_bytes(integer|number|nil) - maximum allowed response size.- Values
<= 0disable the limit. - If the limit is exceeded, Ward removes the partial file and returns
ok=falsewithstatus=413andpath=nil.
- Values
FetchResponse userdata fields/methods:
resp.ok/resp:is_ok() -> booleanresp.status/resp:get_status() -> integer- HTTP status code (or413ifmax_bytesexceeded).resp.path/resp:get_path() -> string|nil- destination path.resp.size/resp:get_size() -> integer- bytes written.
Example:
local fetch = require("ward.net.fetch")
local fs = require("ward.fs")
local r = fetch.url("https://example.com/file.tar.gz", {
into = "./downloads/file.tar.gz",
max_bytes = 50 * 1024 * 1024,
})
if not r.ok then
error("fetch failed: status=" .. tostring(r.status))
end
print("saved to", r.path, "bytes", r.size)
assert(fs.is_file(r.path))
fetch.git(url, opts?) -> FetchResponse
Clones a Git repository into a directory (using the external git command),
then optionally checks out a revision.
Notes:
- Requires
gitto be installed and discoverable inPATH(it is relies on a systemgitbinary). gitstdout/stderr are suppressed; useok/statusto handle errors.
Options (opts table):
into(string|nil) - destination directory.- If omitted, Ward creates a unique directory under the OS temp directory.
depth(integer|nil) - shallow clone depth (must be > 0). Defaults to1.filter_blobs(boolean, defaulttrue) - when true, uses--filter=blob:none.branch(string|nil) - clone a specific branch.tag(string|nil) - clone a specific tag (used as--branch <tag>).- If both
branchandtagare set,branchtakes precedence.
- If both
recursive(boolean, defaultfalse) - when true, uses--recurse-submodules.rev(string|nil) - if set, runsgit checkout <rev>after cloning.timeout(number|nil) - command timeout in seconds.max_bytes(integer|number|nil) - maximum allowed on-disk size for the cloned directory.- If exceeded, Ward removes the directory and returns
ok=falsewithstatus=413andpath=nil.
- If exceeded, Ward removes the directory and returns
On success, FetchResponse.status is 0 and ok=true. On failure, status is
the git exit code.
Example:
local fetch = require("ward.net.fetch")
local r = fetch.git("https://github.com/user/repo.git", {
into = "./vendor/repo",
depth = 1,
rev = "v1.2.3",
recursive = false,
timeout = 120,
})
if not r.ok then
error("git fetch failed: exit=" .. tostring(r.status))
end
print("checked out into", r.path, "bytes", r.size)
ward.module - external module manager
ward.module downloads third-party Lua modules into Ward’s data directory and
exposes them via require("<name>") for the lifetime of the
process. Downloads are content-addressed so multiple revisions can coexist and
be reused across runs.
local module = require("ward.module")
Where modules live
- Base directory:
${XDG_DATA_HOME}/ward/externals(or~/.local/share/ward/externalsifXDG_DATA_HOMEis unset). - Content-addressed store:
${base}/.store/<id>, where<id>issha256(normalized_url + "\n" + selector). - A per-process binding map ensures
require("<name>")resolves to the revision selected earlier in the same run. Rebinding to a different id is rejected unlessforce=true, in which case the nextrequirereloads the module.
Naming rules
- Default name comes from the URL basename (extensions,
.git,.lua,.zip,.tar.gz,.tgz, and query/fragment removed). - Names are canonicalized to lowercase with non-alphanumeric separators folded
into
_. Leading digits are prefixed with_. require("<name>.<submodule>")is supported; segments must be alphanumeric or_.
module.dir() -> string
Returns the absolute path to the externals base directory.
print(module.dir())
module.git(url, opts?) -> { ok, name, require, path, store_path, id }
Clone a Git repository into the externals store and bind it for require.
Options (all optional):
name(string) - override derived name and<name>binding.- Exactly one of
rev,branch, ortagselects a revision; default ishead. Different selectors produce different store ids. force(bool, defaultfalse) - allow rebinding an already-bound name to a new id (also clears cachedrequired module).depth(integer) - shallow clone depth (passed toward.net.fetch.git).recursive(bool) - fetch submodules (git --recurse-submodules).timeout(number, seconds) - overall fetch timeout.max_bytes(integer) - abort if transfer exceeds this many bytes.filter_blobs(bool) - request partial clone with blob filtering when supported by the remote.
Return table:
ok(bool) - whether the checkout succeeded.name(string) - canonicalized name.require(string) - require target (<name>).path/store_path(string) - final checkout directory.id(string) - content-addressed id.
Example (pin to a tag and require a submodule):
local module = require("ward.module")
local json = require("ward.convert.json")
local result = module.git("https://github.com/org/repo.git", {
name = "my_lib",
tag = "v1.2.3",
depth = 1,
recursive = true,
})
assert(result.ok, "git fetch failed")
local api = require(result.require .. ".api")
print(json.encode({ id = result.id, path = result.path, ok = result.ok }))
module.url(url, opts?) -> { ok, name, require, path, store_path, id }
Download a single Lua file and bind it as <name>. The URL content is
stored as init.lua inside its store directory.
Options (all optional):
name(string) - override derived name.insecure(bool, defaultfalse) - allow invalid TLS certificates.follow_redirects(bool, defaulttrue).retries(integer, default5, minimum1).retry_delay(milliseconds, default2000) - delay between retries.force(bool, defaultfalse) - rebind an existing name to the downloaded id.timeout(number, seconds) - overall request timeout.max_bytes(integer) - abort if download exceeds this size.method(string) - HTTP method (defaultGET).headers(table<string, string>) - additional request headers.
Return table fields match module.git.
Example (with custom headers and POST body):
local module = require("ward.module")
local result = module.url("https://example.com/plugin.lua", {
headers = { Authorization = "Bearer TOKEN" },
method = "POST",
retry_delay = 500,
})
if not result.ok then error("download failed") end
local plugin = require(result.require)
print(plugin.version())
ward.process
- Constructors
CmdResultuserdata (result of run/output)CmduserdataPipelineuserdata- Examples
- Streaming example: watch a long-running command and react to new output
local process = require("ward.process")
Constructors
process.cmd(program, ...args) -> Cmd
Create a command.
Arguments may be provided either as varargs or as a single array-table:
local a = process.cmd("git", "status", "--porcelain")
local b = process.cmd("git", { "status", "--porcelain" })
process.sh(script) -> Cmd
Run a shell fragment using the platform default shell:
- Unix:
sh -lc <script> - Windows:
cmd /C <script>
local cmd = process.sh("echo $HOME")
process.exit(code?) -> (raises)
Request script termination with an exit status.
- This does not terminate the host process immediately.
- Ward requests shutdown, unwinds execution, runs shutdown handlers, and then the CLI exits with the given status.
codedefaults to0. Negative values are coerced to1. Values abovei32::MAXare clamped.
Use this for early returns from scripts that still need cleanup handlers to run.
process.shell_defaults(opts?) -> table
Set or read defaults that mimic common set -euo pipefail toggles:
pipefail(boolean) - when true, new commands/pipelines treat any non-zero step as failure by default.timeout(number|string|nil) - pipeline-level timeout for new commands/pipelines. Accepts integers (ms) or human strings (500ms,2s,1m).nilclears the default.
Returns a table with the active defaults. Example:
local process = require("ward.process")
-- Enable pipefail + a 30s default timeout for all new cmds/pipelines.
process.shell_defaults({ pipefail = true, timeout = "30s" })
Process middleware (command wrappers)
Ward exposes a small, composable middleware stack that is applied to every process spawn:
Cmd:run()/Cmd:output()/Cmd:spawn()Pipeline:run()/Pipeline:output()/Pipeline:spawn()
This is the intended core building block for wardlib wrappers (for example, sudo / doas) so users do not need to repeat a prefix in every process.cmd(...) call.
Semantics
- Middleware is task-local (per
ward.asynctask). When youasync.spawn(...), the current middleware stack is inherited by the new task. - Middleware runs synchronously right before the OS process is spawned.
- Each middleware is a Lua function
mw(spec).- It may mutate the passed
spectable and returnnil, or - return a new spec table.
- It may mutate the passed
Spec contract
The spec table contains the following fields (future versions may add more):
spec.argv(table) - array of strings:{ program, arg1, arg2, ... }(must remain non-empty)spec.cwd(string|nil)spec.env(table) - string-to-string map of per-command overrides
API
process.push_middleware(fn) -> integer- pushfnto the current task’s stack (returns new depth)process.pop_middleware() -> boolean- pop the last middleware (returnsfalseif the stack is empty)
Example: wrap commands with sudo/doas (non-interactive by default)
local process = require("ward.process")
local function with_middleware(mw, body)
process.push_middleware(mw)
local ok, err = pcall(body)
process.pop_middleware()
if not ok then error(err) end
end
local function sudo_middleware(opts)
opts = opts or {}
local tool = opts.tool or "sudo" -- or "doas"
local non_interactive = opts.non_interactive ~= false
return function(spec)
local argv = spec.argv or {}
if argv[1] == tool then return nil end
local out = { tool }
if non_interactive then
table.insert(out, "-n")
end
table.insert(out, "--")
for i = 1, #argv do table.insert(out, argv[i]) end
spec.argv = out
return nil
end
end
-- If you want password prompting, pre-auth once with inherited stdio.
-- (Captured `output()` calls cannot safely prompt.)
process.cmd("sudo", "-v"):run():assert_ok("sudo -v failed")
with_middleware(sudo_middleware({ tool = "sudo" }), function()
process.cmd("id", "-u"):run():assert_ok()
end)
CmdResult userdata (result of run/output)
Fields:
result.ok(boolean)result.code(integer)result.signal(integer|nil)result.stdout(bytes string|nil)result.stderr(bytes string|nil)result.steps(table of integers) - per-step exit codes
Methods:
result:is_ok() -> booleanresult:assert_ok(msg?) -> ()- throws a Lua error if not ok
Example:
local r = process.cmd("echo", "hi"):output()
print(r.ok, r.code)
print(r.stdout)
r:assert_ok("echo failed")
Cmd userdata
In addition to one-shot execution (run / output), Cmd supports spawning
long-running processes and streaming their stdio.
Builder methods (fluent):
cmd:cwd(path) -> Cmdcmd:env(key, value) -> Cmdcmd:envs(tbl) -> Cmd(reads key/value pairs)cmd:timeout(ms|duration_string|nil) -> Cmdcmd:stdin(data) -> Cmd(bytes string)cmd:stdin_file(path) -> Cmdcmd:stdin_null() -> Cmdcmd:stderr_to_stdout(true|false) -> Cmdcmd:spawn(opts?) -> ProcChildcmd:disown(opts?) -> table
Notes:
-
cmd:stdin(data),cmd:stdin_file(path), andcmd:stdin_null()configure stdin forrun()/output()/spawn(). This is one-shot input: Ward will feed the configured input (or connect stdin to the file/null stream) and then close stdin. -
cmd:stdin(v)accepts only a bytes string, ornil/falseto reset to inherited stdin. Other values raise an error. -
cmd:stdin_file(path)fails at spawn-time if the file cannot be opened. -
cmd:stderr_to_stdout(true)merges stderr into stdout best-effort. Ordering may differ from shell2>&1. In capture mode (output()), merged data is returned instdoutandstderrisnil. -
cmd:timeout(...)accepts whole seconds (number) or human strings like"500ms","2s","1m". Passnilto clear. -
cmd:stdin_null()sets stdin to a closed stream (equivalent to shell< /dev/null). -
cmd:pipe(other_cmd_or_pipeline) -> Pipeline
Terminal operations:
cmd:run() -> CmdResult- inherits stdiocmd:output() -> CmdResult- captures stdout/stderr
Pipeline operator:
cmd1 | cmd2produces aPipeline(via__bor).
Example:
local r = process.cmd("git", "rev-parse", "HEAD"):output()
r:assert_ok()
print(r.stdout)
Cmd:spawn(opts?) -> ProcChild
Spawns a long-running child process and returns a ProcChild handle. This is
used for:
- Streaming stdout/stderr incrementally (lines or raw bytes)
- Interactive processes (writing to stdin)
- Long-running daemons/subscriptions (e.g.,
pw-mon,tail -f, etc.)
opts is an optional table:
-
stdin(boolean or"pipe"|"inherit"|"null")true/"pipe": pipe stdin so Lua can write viaProcChild:stdin()false/"inherit": inherit parent stdin"null": connect stdin to a closed stream- Default: inferred. If you set
cmd:stdin(data), Ward pipes stdin to feed the bytes; if you setcmd:stdin_null(), stdin is null; if you setcmd:stdin_file(path), Ward pre-opens the file and uses it as stdin. Otherwise stdin defaults to inherited stdin unless you request piping.
-
stdout(boolean or"pipe"|"inherit"|"null", defaulttrue)true/"pipe": pipe stdout so you can stream itfalse/"inherit": inherit parent stdout"null": discard stdout
-
stderr(boolean or"pipe"|"inherit"|"null")true/"pipe": pipe stderr so you can stream itfalse/"inherit": inherit parent stderr"null": discard stderr- Default:
truewhencmd:stderr_to_stdout(true)is set, otherwisefalse("inherit").
Important:
- If you call
cmd:stderr_to_stdout(true), Ward merges stderr into the stdout stream (similar to2>&1). In this case, stderr is not available separately and must be read from stdout. Ordering is best-effort and may not exactly match OS-level2>&1interleaving. - Choose either one-shot stdin via
cmd:stdin(...)/cmd:stdin_file(...)/cmd:stdin_null(), or interactive stdin viaspawn({ stdin = true })+ProcChild:stdin(). Combiningcmd:stdin(...)/cmd:stdin_file(...)withspawn({ stdin = true })is invalid and raises an error.
Example (spawn + line streaming):
local p = require("ward.process")
local child = p.cmd("sh", "-lc", "printf 'a\\nb\\n' && sleep 1")
:spawn({ stdout = true })
local out = assert(child:stdout_lines())
while true do
local line, err = out:wait()
if not line then break end
print("line:", line)
end
child:wait()
Cmd:disown(opts?) -> table
Spawn a process and do not wait for it.
This is the equivalent of cmd & disown in a shell:
- Ward starts the process and immediately returns.
- Ward does not keep a
ProcChildhandle alive. - Ward reaps the process in the background (best-effort) to avoid zombies.
Return value:
{ pid = <integer>, pids = { <integer>, ... } }
opts is the same shape as spawn(opts?) (stdio modes), but with different
defaults:
stdindefault:"null"stdoutdefault:"null"stderrdefault:"null"
Notes:
stdin = "pipe"/trueis not supported fordisown()(usespawn()for interactive processes).- If you explicitly set
stdout = "pipe"orstderr = "pipe", Ward will drain those pipes in the background so the child cannot block on a full buffer.
Example (start a GUI app and continue immediately):
local p = require("ward.process")
-- Start and forget.
local info = p.cmd("firefox"):disown()
print("started pid", info.pid)
-- Continue doing other work...
p.cmd("sh", "-lc", "echo still running"):run()
ProcChild userdata
Represents a spawned child process.
Methods:
child:pid() -> integerchild:pids() -> table- array of PIDs for all stages (for pipelines)child:stdin() -> ProcStdin | nil, err- Returns
nil, "not_piped"if stdin is not piped.
- Returns
child:stdout_lines() -> LineStream | nil, err- Returns
nil, "not_piped"if stdout is not piped. - Returns
nil, "mode_conflict"if stdout was already opened as bytes. - You may call this multiple times; each handle reads from the same underlying pipe.
- Returns
child:stderr_lines() -> LineStream | nil, err- Returns
nil, "not_piped"if stderr is not piped. - Returns
nil, "merged"if stderr was merged into stdout viacmd:stderr_to_stdout(true). - Returns
nil, "mode_conflict"if stderr was already opened as bytes. - You may call this multiple times; each handle reads from the same underlying pipe.
- Returns
child:stdout_bytes() -> ByteStream | nil, err- Returns
nil, "not_piped"if stdout is not piped. - Returns
nil, "mode_conflict"if stdout was already opened as lines. - You may call this multiple times; each handle reads from the same underlying pipe.
- Returns
child:stderr_bytes() -> ByteStream | nil, err- Returns
nil, "not_piped"if stderr is not piped. - Returns
nil, "merged"if stderr was merged into stdout viacmd:stderr_to_stdout(true). - Returns
nil, "mode_conflict"if stderr was already opened as lines. - You may call this multiple times; each handle reads from the same underlying pipe.
- Returns
child:kill() -> boolean(async)child:wait() -> CmdResult(async)
ProcChild:wait() returns a CmdResult with:
ok,code,signalstdout/stderrare typicallynilbecause streaming consumption is incremental, not captured.
ProcStdin userdata (interactive stdin)
Returned by ProcChild:stdin() when stdin is piped.
Methods:
stdin:write(bytes_string) -> true | nil, err(async)stdin:writeln(string) -> true | nil, err(async) - writes string +\\nstdin:flush() -> true | nil, err(async)stdin:close() -> true(async)stdin:is_closed() -> boolean(sync)
Example (interactive stdin):
local p = require("ward.process")
local child = p.cmd("cat"):spawn({ stdin = true, stdout = true })
local stdin = assert(child:stdin())
local out = assert(child:stdout_lines())
stdin:writeln("hello")
stdin:writeln("world")
stdin:close()
while true do
local line, err = out:wait()
if not line then break end
print("echo:", line)
end
child:wait()
LineStream userdata (line-by-line streaming)
Returned by ProcChild:stdout_lines() or ProcChild:stderr_lines().
Note: Multiple coroutines reading the same LineStream/ByteStream will
compete for data (load-balancing). Avoid creating multiple readers unless
that is intended.
Methods:
stream:wait() -> line | nil, errerris"eof"when the stream ends.
This object follows Ward’s “awaitable” contract (:wait()), so it can be
used with async.select(...).
ByteStream userdata (raw byte streaming)
Returned by ProcChild:stdout_bytes() or ProcChild:stderr_bytes().
Note: Multiple coroutines reading the same LineStream/ByteStream will
compete for data (load-balancing). Avoid creating multiple readers unless
that is intended.
Methods:
stream:wait(n?) -> bytes | nil, errndefaults to 16384- returns a bytes string (binary-safe; may contain
\\0) erris"eof"when the stream ends.
Aliases:
stream:read(n?)is the same asstream:wait(n?).stream(n?)callsstream:wait(n?).
This object follows Ward’s “awaitable” contract, so it can be used with async.select(...).
Example (bytes):
local p = require("ward.process")
local child = p.cmd("sh", "-lc", "printf 'A\\0B'"):spawn({ stdout = true })
local bs = assert(child:stdout_bytes())
local chunk, err = bs:wait(3)
assert(chunk, err)
print("len:", #chunk)
print("bytes:", string.byte(chunk, 1, #chunk))
child:wait()
Pipeline userdata
Builder methods:
pl:pipefail(true|false) -> Pipeline- if true, pipeline ok requires all steps succeedpl:timeout(ms|duration_string|nil) -> Pipelinepl:pipe(cmd_or_pipeline) -> Pipeline
Timeouts accept whole seconds (number) or human strings ("500ms", "2s",
"1m"). Pass nil to clear.
Terminal operations:
pl:run() -> CmdResultpl:output() -> CmdResultpl:spawn(opts?) -> ProcChildpl:disown(opts?) -> table
pl:spawn(opts?) starts the pipeline and returns a ProcChild for streaming.
The returned child refers to the last stage in the pipeline; use
child:pids() to get all stage PIDs.
pl:disown(opts?) starts the pipeline and returns the same { pid, pids }
table as Cmd:disown(opts?). It is useful for fire-and-forget pipelines
where you do not need streaming or interaction.
Operator:
pl | cmdextends the pipeline
Example: pipe + capture
local p = process.cmd("cat", "README.md") | process.cmd("wc", "-l")
local r = p:output()
r:assert_ok()
print("lines:", r.stdout)
Examples
Fan-out / fan-in
local async = require("ward.async")
local process = require("ward.process")
local workers = 4
local ch = async.channel({ capacity = 32 })
for i = 1, workers do
async.spawn(function()
local r = process.cmd("sh", "-lc", "echo worker=" .. i):output()
ch:send({ i = i, ok = r.ok, out = r.stdout })
end)
end
for _ = 1, workers do
local msg = ch:wait()
print(msg.i, msg.ok, msg.out)
end
ch:close()
Worker pool
See samples/async.worker_pool.lua for a complete runnable example.
Streaming example: watch a long-running command and react to new output
This pattern is common in bash/sh scripting
(e.g., tail -f ... | while read ...; do ...; done).
In Ward, do it with spawn() and stdout_lines():
local async = require("ward.async")
local p = require("ward.process")
local str = require("ward.helpers.string")
local NEEDLE = "PipeWire:Interface:Device"
local child = p.cmd("pw-mon", "-oap"):spawn({ stdout = true })
local out = assert(child:stdout_lines())
while true do
local line, err = out:wait()
if not line then break end
-- Only reacts to *new* lines, not the initial process output snapshot.
if str.contains(line, NEEDLE) then
local r = p.cmd("wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"):output()
if r.ok then
print(str.trim(r.stdout or ""))
end
end
end
child:wait()
ward.template
MiniJinja
MiniJinja is a fast, Rust-native Jinja2-like template engine.
local tpl = require("ward.template.minijinja")
minijinja.render(template, context, opts?) -> string
Render a template string using the given context (Lua table).
local out = tpl.render("Hello {{ user.name }}!", {
user = { name = "Ward" }
})
print(out) -- Hello Ward!
minijinja.render_async(template, context, opts?) -> string
Same as render, but runs the render on a blocking thread so it will not
block the async runtime.
local out = tpl.render_async("{{ n }}", { n = 42 })
print(out)
minijinja.render_file(path, context, opts?) -> string
Read a file from path and render its contents as a template.
local out = tpl.render_file("./hello.tmpl", { name = "world" })
print(out)
minijinja.render_file_async(path, context, opts?) -> string
Same as render_file, but runs on a blocking thread.
Options
All functions accept an optional opts table:
undefined: one of"strict","lenient","chainable"(default:"strict")trim_blocks: boolean (default:false)lstrip_blocks: boolean (default:false)keep_trailing_newline: boolean (default:false)auto_escape: boolean (default:false)loader: table configuring{% include %}/{% import %}resolutionpaths: array of strings; additional search paths for templates
Example:
local out = tpl.render_file("./templates/main.j2", {
title = "Hello",
items = { "a", "b", "c" },
}, {
undefined = "strict",
trim_blocks = true,
lstrip_blocks = true,
loader = {
paths = { "./templates" },
},
})
print(out)
ward.term
- Input (returns awaitables)
- Printing
- Screen control and tty
term.ansisubmoduleterm.progress(args?) -> Progress
local term = require("ward.term")
Input (returns awaitables)
All input helpers return an InputAwaitable userdata.
term.prompt(args) -> InputAwaitable(returnsstring|nilwhen awaited)term.confirm(args) -> InputAwaitable(returnsbooleanwhen awaited)term.password(args) -> InputAwaitable(returnsstring|nilwhen awaited)term.choose(args) -> InputAwaitable(returnsstring|nilwhen awaited)
Awaitable methods:
a:wait() -> valuea() -> value(via__call)
term.prompt{ question, default?, trim? }
local name = term.prompt({ question = "Name", default = "guest" }):wait()
print("hello", name)
term.confirm{ question, default? }
Accepted answers: y/yes and n/no (case-insensitive). Empty input returns
the default if provided.
local ok = term.confirm({ question = "Continue?", default = false }):wait()
if not ok then return end
term.password{ prompt, trim? }
Reads a line with no echo (TTY).
local secret = term.password({ prompt = "Password:" }):wait()
term.choose{ question, options, default? }
options is an array-like table.
local choice = term.choose({
question = "Pick one",
options = { "dev", "staging", "prod" },
default = "dev",
}):wait()
print(choice)
Printing
term.print(...) -> trueterm.println(...) -> trueterm.eprint(...) -> trueterm.eprintln(...) -> true
Screen control and tty
term.clear() -> trueterm.isatty(stream?) -> boolean-streamcan be"stdout"or"stderr"
term.ansi submodule
term.ansi is a table of ANSI escape-code strings you can concatenate into output.
Common style fields:
ansi.resetansi.bold,ansi.dim,ansi.italic,ansi.underline,ansi.blink,ansi.reverse,ansi.hidden,ansi.strike
Clear / cursor fields:
ansi.clear_line,ansi.clear_screen,ansi.home
Colors (foreground):
ansi.black,ansi.red,ansi.green,ansi.yellow,ansi.blue,ansi.magenta,ansi.cyan,ansi.white,ansi.defaultansi.bright_black,ansi.bright_red,ansi.bright_green,ansi.bright_yellow,ansi.bright_blue,ansi.bright_magenta,ansi.bright_cyan,ansi.bright_white
Colors (background):
ansi.bg_black,ansi.bg_red,ansi.bg_green,ansi.bg_yellow,ansi.bg_blue,ansi.bg_magenta,ansi.bg_cyan,ansi.bg_white,ansi.bg_defaultansi.bg_bright_black,ansi.bg_bright_red,ansi.bg_bright_green,ansi.bg_bright_yellow,ansi.bg_bright_blue,ansi.bg_bright_magenta,ansi.bg_bright_cyan,ansi.bg_bright_white
Example:
local ansi = term.ansi
term.println(ansi.bold .. ansi.green .. "OK" .. ansi.reset)
term.progress(args?) -> Progress
Create a progress renderer for TTY output.
Constructor args (table):
total(integer|nil)message(string|nil)stream("stdout"|"stderr", default"stderr")
Progress methods (getter/setter style):
p:tick(delta?) -> nil- increment bydelta(default 1)p:value(v?) -> integer|nil- get current when called without args; set whenvprovidedp:total(t?) -> integer|nil- get total when called without args; set whentprovidedp:message(s?) -> string|nil- get message when called without args; set whensprovidedp:finish(final_msg?) -> true- render final line + newline (TTY only)
Example:
local p = term.progress({ total = 10, message = "Working" })
for _ = 1, 10 do
time.sleep("150ms"):wait()
p:tick(1)
end
p:finish("Done")
ward.time
local time = require("ward.time")
Wall clock
time.now() -> TimePointtime.now_table() -> table- returns a table with timestamp components
Parsing
time.parse_rfc3339(s) -> TimePoint|niltime.parse_rfc2822(s) -> TimePoint|niltime.parse(s) -> TimePoint|nil- best-effort parser
Construction
time.from_timestamp(seconds, nanos?) -> TimePointtime.utc(y, m, d, hh?, mm?, ss?, nanos?) -> TimePoint
Durations
time.duration(x) -> Duration
Accepts:
- number: whole seconds
- string: human readable duration (e.g.
500ms,1.5s,2h) - Duration userdata
Examples:
local d1 = time.duration(2)
local d2 = time.duration("250ms")
local d3 = time.duration("2h")
Monotonic time
time.instant_now() -> InstantPoint
Timers (return awaitables)
These return userdata you must call () or :wait().
time.sleep(duration) -> SleepAwaitabletime.after(duration, callback?) -> AfterAwaitabletime.interval(duration) -> IntervalTimertime.timeout(awaitable, duration) -> TimeoutAwaitable
Examples:
-- sleep
time.sleep("200ms"):wait()
-- after
local v = time.after("1s", function() return "done" end):wait()
print(v)
-- interval
local it = time.interval("1s")
for _ = 1, 3 do
print("tick", it())
end
-- timeout
local a = time.sleep("5s")
local ok, err = pcall(function()
time.timeout(a, "200ms"):wait()
end)
print(ok, err)
Blocking sleep
time.sleep_blocking(duration) -> true
Ward shell examples
“set -e” style: assert on failures
local process = require("ward.process")
process.cmd("git", "rev-parse", "--is-inside-work-tree")
:output()
:assert_ok("not a git repo")
Download to temp dir and process
local fs = require("ward.fs")
local process = require("ward.process")
local term = require("ward.term")
local time = require("ward.time")
local dir = fs.tempdir("ward-")
term.println("tmp:", dir)
-- Example pipeline
local p = process.cmd("printf", "hello\nworld\n") | process.cmd("wc", "-l")
local r = p:output()
r:assert_ok()
term.println("lines:", r.stdout)
-- progress demo
local prog = term.progress({ total = 5, message = "Working" })
for _ = 1, 5 do
time.sleep("200ms"):wait()
prog:tick(1)
end
prog:finish("Done")
LuaRocks Notes
Ward is designed to be self-contained and predictable. For that reason, our compatibility target with LuaRocks is intentionally conservative.
What is supported
Ward works well with LuaRocks packages that are pure Lua
(i.e., they install only *.lua source files and do not require
any compiled native components).
To use pure-Lua rocks with Ward, install them into a LuaRocks tree
(local or global) and extend package.path so Ward can locate the modules.
Example (local tree):
-- Install with:
-- luarocks --lua-version 5.4 --tree ./.rocks install <rock>
local tree = "./.rocks"
package.path = table.concat({
tree .. "/share/lua/5.4/?.lua",
tree .. "/share/lua/5.4/?/init.lua",
package.path,
}, ";")
local mod = require("some_rock_module")
Notes:
- Ward embeds Lua 5.4, so use
--lua-version 5.4. - Using a project-local tree (e.g., ./.rocks) is recommended for reproducibility.
What is not supported (for now)
Ward does not currently provide reliable support for LuaRocks packages that include
native C extensions (e.g., *.so, *.dll) or depend on external Lua C modules.
You may be able to experiment by extending package.cpath, but compatibility is not guaranteed and is not considered part of Ward’s supported surface area.
Why C-extension rocks are out of scope currently
Ward embeds Lua as part of the executable (via an embedded Lua runtime), which is a deliberate choice to keep Ward:
- self-contained (no system Lua dependency),
- consistent across target environments,
- simple to install and run.
Most LuaRocks C modules are built under the assumption that they will load into a process that links against a compatible, dynamically available Lua runtime, and that their compiled binary can resolve the expected Lua symbols and ABI at runtime. When the interpreter is embedded/self-contained, those assumptions may not hold consistently across platforms, toolchains, and build modes.
Roadmap stance
Supporting pure-Lua rocks provides most of the practical value for scripting and library reuse while preserving Ward’s simplicity. If and when Ward introduces an optional build mode that links against a system-provided shared Lua runtime, C-extension LuaRocks compatibility may become a supported configuration. Until then, Ward’s LuaRocks support should be considered pure-Lua only.