npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nixparse

v0.1.0

Published

Type-safe parsers for Unix command output (ps, df, du, free, lsof, mount, who, uptime, ip, ss) with runtime validation via Zod

Readme

nixparse

Type-safe parsers for classic Unix command output — ps, df, du, free, lsof, mount, who, uptime, ip, ss — validated at runtime with Zod.

日本語版 README はこちら

Most of these commands have no --json flag (unlike docker or kubectl), so their output has historically been scraped with one-off regexes. nixparse gives you a single, typed, pluggable interface for all of them.

  • Runtime-validated: every parser result is checked against a Zod schema, so malformed input throws a ZodError instead of silently producing garbage.
  • No shell, no injection risk: run() uses child_process.execFile, never a shell — arguments are never string-interpolated into a command line.
  • Pluggable: register your own parser for any command with registerParser().
  • Dual ESM/CJS build with full .d.ts types.

Table of contents

Install

npm install nixparse

Requires Node.js 18+. Commands are run on the host system (Linux assumed — see Scope), so the underlying binaries (ps, df, etc.) must already be installed and on PATH.

Quick start

import { run, type ProcessInfo } from "nixparse";

const processes = await run<ProcessInfo[]>("ps");
console.log(processes[0].pid, processes[0].command);

That's it — run() executes ps aux under the hood, parses stdout, validates it against a Zod schema, and hands back a fully-typed array.

Core concepts

There are two ways to get data out of nixparse, depending on whether you already have the raw command output or want nixparse to fetch it for you:

| | You already have the text | You want nixparse to execute the command | |---|---|---| | Function | parse(name, raw) | run(name, args?) | | Use case | Output piped in from elsewhere, captured by an agent, read from a log/fixture file | Normal application code | | Side effects | None — pure function | Spawns a child process |

Both funnel through the same registry of parsers, so the returned types and validation behavior are identical.

API reference

parse(name, raw)

function parse<T = unknown>(name: string, raw: string): T;

Parses a raw string with the parser registered under name, validates the result against that parser's Zod schema, and returns it.

  • name — the parser name (see the command table below). For built-ins, the string literal type BuiltinCommandName gives you autocomplete.
  • raw — the exact stdout text of the corresponding command (see the "Command run by run()" column per parser for which exact invocation each parser expects).
  • Throws UnknownParserError if name isn't registered.
  • Throws a Zod ZodError if the parsed shape doesn't match the schema (e.g. you passed the wrong command's output by mistake).
import { parse, type DiskUsage } from "nixparse";
import { execSync } from "node:child_process";

const raw = execSync("df -k").toString();
const disks = parse<DiskUsage[]>("df", raw);

run(name, args?)

function run<T = unknown>(
  name: BuiltinCommandName,
  args?: string[],
): Promise<T>;

Executes the underlying binary for a built-in parser and parses its output in one step. Only works with the 11 built-in command names (custom parsers registered via registerParser are not executable — run() has no way to know what binary/args they'd need).

  • name — one of "ps" | "df" | "du" | "free" | "lsof" | "mount" | "who" | "uptime" | "ip-addr" | "ip-route" | "ss".
  • args — optional. Overrides the parser's default arguments (e.g. du needs a path, so you'll almost always pass args for it). When omitted, each parser's documented default is used (see the table below).
  • Internally uses child_process.execFile(binary, args)no shell is invoked, so there is no command-injection risk even if args includes user-controlled strings.
  • Throws CommandNotFoundError if the binary isn't on PATH.
  • Throws the same Zod errors as parse() if the output doesn't match the schema (e.g. an unexpected OS/output format).
import { run, type DirSize } from "nixparse";

// override the default args — du requires an explicit path
const sizes = await run<DirSize[]>("du", ["-k", "/var/log"]);
const biggest = sizes.sort((a, b) => b.sizeKb - a.sizeKb)[0];

registerParser(name, definition)

function registerParser<T>(
  name: string,
  definition: { schema: ZodType<T>; parse: (raw: string) => unknown },
): void;

Adds (or overwrites) a parser in the global registry, immediately usable via parse(). This is how you extend nixparse to cover a command it doesn't ship with.

import { registerParser, parse } from "nixparse";
import { z } from "zod";

const UserSchema = z.object({ name: z.string(), shell: z.string() });

registerParser("getent-passwd", {
  schema: z.array(UserSchema),
  parse: (raw) =>
    raw
      .trim()
      .split("\n")
      .map((line) => {
        const fields = line.split(":");
        return { name: fields[0], shell: fields[6] };
      }),
});

const users = parse("getent-passwd", rawGetentOutput);

Note: registerParser only makes the parser usable via parse(). If you also want one-call execution like run() provides, write your own thin wrapper that shells out and calls parse().

Supported commands (full field reference)

All built-in parsers are based on Linux (GNU coreutils / iproute2 / util-linux) output. See Scope and limitations for other platforms.

ps

  • Name: "ps" · Default command: ps aux · Returns: ProcessInfo[]

| Field | Type | Source column | Notes | |---|---|---|---| | user | string | USER | | | pid | number | PID | | | cpu | number | %CPU | | | mem | number | %MEM | | | vsz | number | VSZ | Virtual memory size, KB | | rss | number | RSS | Resident set size, KB | | tty | string | TTY | ? if no controlling terminal | | stat | string | STAT | Process state code, e.g. Ss, R+ | | start | string | START | As printed by ps (time or date) | | time | string | TIME | Cumulative CPU time | | command | string | COMMAND | Full command line, including all arguments — never truncated at a space |

const procs = await run<ProcessInfo[]>("ps");
const highCpu = procs.filter((p) => p.cpu > 50);

df

  • Name: "df" · Default command: df -k · Returns: DiskUsage[]

| Field | Type | Source column | Notes | |---|---|---|---| | filesystem | string | Filesystem | | | blocksKb | number | 1K-blocks | Total size in KB | | usedKb | number | Used | KB | | availableKb | number | Available | KB | | usePercent | number | Use% | Numeric, % sign stripped (e.g. 42, not "42%") | | mountedOn | string | Mounted on | |

const disks = await run<DiskUsage[]>("df");
const full = disks.filter((d) => d.usePercent >= 90);

du

  • Name: "du" · Default command: du -k (no path — you should pass one) · Returns: DirSize[]

| Field | Type | Notes | |---|---|---| | sizeKb | number | Size in KB | | path | string | Path as printed by du |

du requires a target path as an argument, so you'll virtually always call run("du", ["-k", "/some/path"]) rather than relying on the (path-less) default.

const sizes = await run<DirSize[]>("du", ["-k", "-d", "1", "/home/me/projects"]);

free

  • Name: "free" · Default command: free -b · Returns: MemoryInfo

-b (bytes) is the default specifically so values stay plain numbers — -h ("human readable", e.g. "3.8Gi") is not supported, since it mixes units into the string.

| Field | Type | Notes | |---|---|---| | mem.total | number | Bytes | | mem.used | number | Bytes | | mem.free | number | Bytes | | mem.shared | number | Bytes | | mem.buffCache | number | Bytes (buff/cache column) | | mem.available | number | Bytes | | swap.total | number | Bytes | | swap.used | number | Bytes | | swap.free | number | Bytes |

const mem = await run<MemoryInfo>("free");
const usedPercent = (mem.mem.used / mem.mem.total) * 100;

lsof

  • Name: "lsof" · Default command: lsof -F pcufTtn · Returns: OpenFile[]

Uses lsof's -F field-output mode rather than scraping the human-readable table — far more robust against filenames with spaces, long command names, etc. Each open file descriptor becomes one entry, carrying the PID/command/user of the process that owns it.

| Field | Type | Notes | |---|---|---| | pid | number | Owning process ID | | command | string | Owning process's command name | | user | string | Owning process's user (numeric UID as printed by lsof) | | fd | string | File descriptor, e.g. cwd, txt, mem, or a number | | type | string | File type, e.g. DIR, REG, IPv4, unknown | | name | string | Path, or socket/device description |

// all processes with files open under /var/log
const open = await run<OpenFile[]>("lsof");
const logUsers = open.filter((f) => f.name.startsWith("/var/log"));

Without root, lsof can only see your own processes' files — this is an OS permission limit, not a nixparse limitation.

mount

  • Name: "mount" · Default command: mount · Returns: MountPoint[]

| Field | Type | Notes | |---|---|---| | device | string | e.g. /dev/sda1, none, tmpfs | | path | string | Mount point | | fsType | string | e.g. ext4, overlay, tmpfs | | options | string[] | Mount options split on ,, e.g. ["rw", "relatime"] |

const mounts = await run<MountPoint[]>("mount");
const readOnly = mounts.filter((m) => m.options.includes("ro"));

who

  • Name: "who" · Default command: who · Returns: LoggedInUser[]

| Field | Type | Notes | |---|---|---| | user | string | | | tty | string | e.g. pts/1 | | loginTime | string | As printed by who (locale-dependent format, kept as a raw string rather than parsed into a Date) |

uptime

  • Name: "uptime" · Default command: uptime · Returns: UptimeInfo

| Field | Type | Notes | |---|---|---| | currentTime | string | Wall clock time as printed, e.g. "14:32:10" | | upDays | number | 0 if uptime is under a day | | upHours | number | | | upMinutes | number | | | users | number | Number of logged-in users | | loadAverage1m | number | | | loadAverage5m | number | | | loadAverage15m | number | |

const up = await run<UptimeInfo>("uptime");
if (up.loadAverage1m > 4) console.warn("system is under heavy load");

ip-addr

  • Name: "ip-addr" · Default command: ip -j addr · Returns: NetworkInterface[]

ip -j already emits native JSON. This parser does not do any text scraping — it's JSON.parse followed by Zod validation, so you get a guaranteed shape (and full TypeScript types) on top of a command that already happened to support JSON.

| Field | Type | Notes | |---|---|---| | ifindex | number | | | ifname | string | e.g. eth0, lo | | flags | string[] | e.g. ["BROADCAST", "MULTICAST", "UP"] | | mtu | number | | | qdisc | string | | | operstate | string | e.g. UP, DOWN, UNKNOWN | | group | string | | | txqlen | number? | Optional — not present on all interface types | | link_type | string | e.g. ether, loopback | | address | string | MAC address | | broadcast | string? | Optional | | addr_info | AddrInfo[] | See below |

AddrInfo:

| Field | Type | Notes | |---|---|---| | family | string | "inet" or "inet6" | | local | string | The IP address | | prefixlen | number | | | broadcast | string? | Optional | | scope | string | e.g. global, host, link | | label | string? | Optional | | valid_life_time | number | | | preferred_life_time | number | |

const ifaces = await run<NetworkInterface[]>("ip-addr");
const eth0 = ifaces.find((i) => i.ifname === "eth0");
const ipv4 = eth0?.addr_info.find((a) => a.family === "inet")?.local;

ip-route

  • Name: "ip-route" · Default command: ip -j route · Returns: RouteEntry[]

Same approach as ip-addr: native JSON, validated rather than parsed.

| Field | Type | Notes | |---|---|---| | dst | string | Destination, e.g. "default" or a CIDR | | gateway | string? | Optional | | dev | string? | Optional, outgoing interface | | protocol | string? | Optional | | scope | string? | Optional | | prefsrc | string? | Optional, preferred source address | | flags | string[] | |

const routes = await run<RouteEntry[]>("ip-route");
const defaultRoute = routes.find((r) => r.dst === "default");

ss

  • Name: "ss" · Default command: ss -tln · Returns: Socket[]

Handles bracketed IPv6 addresses ([::1]:631) correctly by splitting on the last : rather than the first. Pass ["-tlnp"] instead of the default to also get the owning process when permissions allow it.

| Field | Type | Notes | |---|---|---| | state | string | e.g. LISTEN | | recvQ | number | | | sendQ | number | | | localAddress | string | IPv4, or IPv6 in [...] form, or * | | localPort | string | Kept as a string since * is a valid value | | peerAddress | string | | | peerPort | string | | | process | string? | Only present with -p and sufficient permissions, e.g. users:(("node",pid=123,fd=10)) |

const sockets = await run<Socket[]>("ss", ["-tlnp"]);
const listeningOn8080 = sockets.find((s) => s.localPort === "8080");

Error handling

import { run, parse, CommandNotFoundError, UnknownParserError } from "nixparse";
import { ZodError } from "zod";

try {
  const procs = await run("ps");
} catch (err) {
  if (err instanceof CommandNotFoundError) {
    console.error(`missing binary: ${err.command}`);
  } else if (err instanceof ZodError) {
    console.error("output didn't match the expected shape", err.issues);
  } else {
    throw err;
  }
}

try {
  parse("not-a-real-parser", "...");
} catch (err) {
  if (err instanceof UnknownParserError) {
    console.error(`no parser named "${err.name}"`);
  }
}

Recipes

Find the top 5 memory-hungry processes:

import { run, type ProcessInfo } from "nixparse";

const procs = await run<ProcessInfo[]>("ps");
const top5 = [...procs].sort((a, b) => b.rss - a.rss).slice(0, 5);

Alert if any disk is nearly full:

import { run, type DiskUsage } from "nixparse";

const disks = await run<DiskUsage[]>("df");
for (const d of disks) {
  if (d.usePercent >= 90) {
    console.warn(`${d.mountedOn} is at ${d.usePercent}% (${d.filesystem})`);
  }
}

Feed structured system state to an LLM agent prompt:

import { run, type ProcessInfo, type MemoryInfo } from "nixparse";

const [procs, mem] = await Promise.all([
  run<ProcessInfo[]>("ps"),
  run<MemoryInfo>("free"),
]);

const summary = {
  topProcessesByCpu: procs.sort((a, b) => b.cpu - a.cpu).slice(0, 5),
  memoryUsedPercent: (mem.mem.used / mem.mem.total) * 100,
};
// JSON.stringify(summary) → safe to embed in a prompt, already validated

Scope and limitations

  • Built-in parsers target Linux output (GNU coreutils, iproute2, util-linux). They have not been tested against macOS/BSD variants of ps, df, etc., which use different flags and columns — these will likely throw, not silently misparse, since the fixed column layout won't match. PRs adding macOS variants are welcome.
  • lsof visibility is limited by your process's permissions, same as running lsof directly.
  • who's loginTime is kept as a raw, locale-dependent string rather than parsed into a Date, since who's date format isn't reliably machine-parseable across locales.
  • df's parser assumes the filesystem name doesn't cause the line to wrap (which can happen with very long NFS-style device strings) — wrapped lines will fail validation rather than silently producing wrong data.

Development

git clone <this repo>
cd nixparse
npm install
npm run build       # tsup → dist/ (ESM + CJS + .d.ts)
npm run test        # vitest, runs against fixtures in test/fixtures/
npm run test:watch  # watch mode
npm run typecheck   # tsc --noEmit

Fixtures in test/fixtures/*.txt are real captured output from each command. When adding support for a new command or fixing an edge case, capture a real sample (<command> > test/fixtures/<name>.txt) rather than hand-writing one, so tests reflect actual tool behavior.

License

MIT