Process & Env
runtime:process gives you the environment, CLI arguments, working directory, signals, and exit.
It is an ES module, gated on the Env capability — except the signal exports, which need Signals, and platform, arch, exit, and permissions, which need none. Import only what you need.
Reading process info
env is a plain object; args is the script's own arguments.
import { env, args, cwd } from "runtime:process"; console.log(cwd()); // "/srv/app" console.log(args); // ["build", "--watch"] console.log(env.HOME); // "/home/app"
Writing or deleting an env key changes it for this run only — it never touches the host or child processes.
Environment files
Load variables from a .env file with --env-file.
# .env DATABASE_URL=postgres://localhost/app PORT=8080 API_TOKEN=tok_live_123
esrun --env-file=.env app.js # Let the file beat the OS environment (default: OS wins): esrun --env-file=.env --env-override app.js
A .env is read only when you pass --env-file. Nothing on disk is loaded implicitly from the working directory.
The OS environment wins on a conflict by default, so a committed .env can't clobber production config. Pass --env-override to flip it.
A single --env-file is supported — production config comes from one .env or the orchestrator. There is no .env.* layering.
Secrets are masked
Secret-bearing keys are wrapped so they print as [redacted] in logs, strings, and JSON.
A key qualifies if it ends in _KEY, _TOKEN, _SECRET, _PASS(WORD), or contains CREDENTIAL/AUTH.
import { env, unmask } from "runtime:process"; console.log(env.API_TOKEN); // [redacted] console.log(`${env.API_TOKEN}`); // [redacted] JSON.stringify(env); // ..."API_TOKEN":"[redacted]"... const token = unmask(env.API_TOKEN); // real value, explicit
Call unmask(value) to get the real string. Plain values pass through, so unmask(env.ANY) is always safe.
It stops secrets leaking into logs by mistake. It is not a sandbox — code you run can call unmask itself.
Signals
Watching a signal suppresses its default action — that is how a graceful shutdown is written, and why it needs its own Signals capability.
import { onSignal, offSignal } from "runtime:process"; import { serve } from "runtime:http"; const server = serve(handler); const shutdown = async (signal) => { offSignal(signal, shutdown); // a second ^C should kill, not queue await server.stop(); // stop accepting, drain in-flight await pool.close(); }; onSignal("SIGINT", shutdown); onSignal("SIGTERM", shutdown);
| Platform | Deliverable |
|---|---|
| Unix | SIGINT, SIGTERM, SIGHUP, SIGUSR1, SIGUSR2 |
| Windows | SIGINT, SIGBREAK |
signals() reports the set. Asking for one the platform cannot deliver throws, rather than registering a handler that would never fire.
While anything is watched the program stays running to receive it, as in Node and Deno. Removing the last handler releases it. Repeated deliveries coalesce: a burst of SIGHUPs arrives once.
Permissions
permissions reports what this run may reach. The policy is fixed at launch by esrun's denial flags, so has() is a synchronous boolean — there is nothing to request.
import { permissions } from "runtime:process"; permissions.denied; // ["read", "write"] permissions.has("net"); // true if (permissions.has("write")) await fs.write("cache.json", data);
Names: read, write, imports, net, listen, env, run, signals — the same words the --deny-<name> and --allow-<name> flags take. Any other name throws a TypeError.
A scoped grant answers true: under --allow-env=PORT you may read an environment variable, and the ones outside the list are simply absent. has() reports whether the capability is granted, not whether a particular value is in its list — and there is no second argument for asking about one (has("read", "/etc/passwd") throws). Scoping is the deployment's business; to find out about one value, do the thing and catch ERR_PERMISSION_DENIED.
Exit
exit(code) records the status and halts immediately — nothing after it runs.
Full export list and types: runtime:process API reference. CLI flags: esrun CLI.