Commands

The userland — 60+ lazily-loaded coreutils plus node, npm, and git — and how to write your own.

Commands are Lifo's userland: ls, grep, sed, curl, node, npm, git, and dozens more. Each is a plain async function over a CommandContext, held in a CommandRegistry.

The command contract

A command is just:

type Command = (ctx: CommandContext) => Promise<number>; // resolves to an exit code

CommandContext gives it everything it needs and nothing it doesn't:

interface CommandContext {
  args: string[];               // argv (args[0] is the command name)
  env: Record<string, string>;
  cwd: string;
  vfs: VFS;                     // the filesystem
  stdout: { write(t: string): void };
  stderr: { write(t: string): void };
  stdin?: { read(): Promise<string | null>; readAll(): Promise<string> };
  signal: AbortSignal;          // cancellation / kill
  setRawMode?: (on: boolean) => void;      // interactive TTY only
  executeCapture?: (line: string, o?) => Promise<string>;          // shell out
  executeCaptureResult?: (line: string, o?) => Promise<{ stdout; stderr; code }>;
}

Because a command only touches ctx, it's isolated and testable — it reads args/stdin, writes stdout/stderr, honors signal, and returns an exit code. executeCapture / executeCaptureResult let a command run other commands, which is how child_process shells out.

The registry (lazy by default)

Commands are registered on a CommandRegistry, almost always lazily — the implementation is a dynamic import() that only loads when the command first runs, so unused commands never enter the bundle:

registry.registerLazy("ls", () => import("./fs/ls.js"));
registry.register("my-tool", async (ctx) => {
  ctx.stdout.write("hello\n");
  return 0;
});

createDefaultRegistry() wires up the standard set. Sandbox.create() builds on it and adds the system commands.

What's built in

Roughly 60+ commands, grouped by area:

  • Filesystemls, cat, cp, mv, rm, mkdir, touch, find, tree, stat, ln, du, df, chmod, chown, realpath, file, …
  • Textgrep, sed, awk, head, tail, wc, sort, uniq, cut, tr, diff, nl, rev, tac, base64, seq, nano, less, …
  • Archivetar, gzip/gunzip, zip/unzip.
  • I/Otee, xargs, yes, printf.
  • Systemenv, uname, date, whoami, which, free, uptime, ps/top/kill, node, npm/npx, systemctl, lifo, …
  • Networkcurl, ifconfig, route, netstat, host, ip, ports, tunnel, forward.
  • Git — a full git via isomorphic-git, in every shell.

Adding your own

Register a handler on the sandbox and it's immediately available in the shell:

sandbox.commands.register("greet", async (ctx) => {
  const who = ctx.args[1] ?? "world";
  ctx.stdout.write(`hello, ${who}\n`);
  return 0;
});

await sandbox.commands.run("greet there"); // → "hello, there"