nixparse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,480 @@
1
+ # nixparse
2
+
3
+ Type-safe parsers for classic Unix command output — `ps`, `df`, `du`, `free`, `lsof`, `mount`, `who`, `uptime`, `ip`, `ss` — validated at runtime with [Zod](https://zod.dev).
4
+
5
+ [日本語版 README はこちら](./README.ja.md)
6
+
7
+ 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.
8
+
9
+ - **Runtime-validated**: every parser result is checked against a Zod schema, so malformed input throws a `ZodError` instead of silently producing garbage.
10
+ - **No shell, no injection risk**: `run()` uses `child_process.execFile`, never a shell — arguments are never string-interpolated into a command line.
11
+ - **Pluggable**: register your own parser for any command with `registerParser()`.
12
+ - **Dual ESM/CJS** build with full `.d.ts` types.
13
+
14
+ ## Table of contents
15
+
16
+ - [Install](#install)
17
+ - [Quick start](#quick-start)
18
+ - [Core concepts](#core-concepts)
19
+ - [API reference](#api-reference)
20
+ - [`parse(name, raw)`](#parsename-raw)
21
+ - [`run(name, args?)`](#runname-args)
22
+ - [`registerParser(name, definition)`](#registerparsername-definition)
23
+ - [Supported commands (full field reference)](#supported-commands-full-field-reference)
24
+ - [`ps`](#ps)
25
+ - [`df`](#df)
26
+ - [`du`](#du)
27
+ - [`free`](#free)
28
+ - [`lsof`](#lsof)
29
+ - [`mount`](#mount)
30
+ - [`who`](#who)
31
+ - [`uptime`](#uptime)
32
+ - [`ip-addr`](#ip-addr)
33
+ - [`ip-route`](#ip-route)
34
+ - [`ss`](#ss)
35
+ - [Error handling](#error-handling)
36
+ - [Recipes](#recipes)
37
+ - [Scope and limitations](#scope-and-limitations)
38
+ - [Development](#development)
39
+ - [License](#license)
40
+
41
+ ## Install
42
+
43
+ ```sh
44
+ npm install nixparse
45
+ ```
46
+
47
+ Requires Node.js 18+. Commands are run on the host system (Linux assumed — see [Scope](#scope-and-limitations)), so the underlying binaries (`ps`, `df`, etc.) must already be installed and on `PATH`.
48
+
49
+ ## Quick start
50
+
51
+ ```ts
52
+ import { run, type ProcessInfo } from "nixparse";
53
+
54
+ const processes = await run<ProcessInfo[]>("ps");
55
+ console.log(processes[0].pid, processes[0].command);
56
+ ```
57
+
58
+ 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.
59
+
60
+ ## Core concepts
61
+
62
+ 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:
63
+
64
+ | | You already have the text | You want nixparse to execute the command |
65
+ |---|---|---|
66
+ | Function | [`parse(name, raw)`](#parsename-raw) | [`run(name, args?)`](#runname-args) |
67
+ | Use case | Output piped in from elsewhere, captured by an agent, read from a log/fixture file | Normal application code |
68
+ | Side effects | None — pure function | Spawns a child process |
69
+
70
+ Both funnel through the same registry of parsers, so the returned types and validation behavior are identical.
71
+
72
+ ## API reference
73
+
74
+ ### `parse(name, raw)`
75
+
76
+ ```ts
77
+ function parse<T = unknown>(name: string, raw: string): T;
78
+ ```
79
+
80
+ Parses a raw string with the parser registered under `name`, validates the result against that parser's Zod schema, and returns it.
81
+
82
+ - `name` — the parser name (see [the command table](#supported-commands-full-field-reference) below). For built-ins, the string literal type `BuiltinCommandName` gives you autocomplete.
83
+ - `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).
84
+ - Throws `UnknownParserError` if `name` isn't registered.
85
+ - Throws a Zod `ZodError` if the parsed shape doesn't match the schema (e.g. you passed the wrong command's output by mistake).
86
+
87
+ ```ts
88
+ import { parse, type DiskUsage } from "nixparse";
89
+ import { execSync } from "node:child_process";
90
+
91
+ const raw = execSync("df -k").toString();
92
+ const disks = parse<DiskUsage[]>("df", raw);
93
+ ```
94
+
95
+ ### `run(name, args?)`
96
+
97
+ ```ts
98
+ function run<T = unknown>(
99
+ name: BuiltinCommandName,
100
+ args?: string[],
101
+ ): Promise<T>;
102
+ ```
103
+
104
+ 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).
105
+
106
+ - `name` — one of `"ps" | "df" | "du" | "free" | "lsof" | "mount" | "who" | "uptime" | "ip-addr" | "ip-route" | "ss"`.
107
+ - `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).
108
+ - 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.
109
+ - Throws `CommandNotFoundError` if the binary isn't on `PATH`.
110
+ - Throws the same Zod errors as `parse()` if the output doesn't match the schema (e.g. an unexpected OS/output format).
111
+
112
+ ```ts
113
+ import { run, type DirSize } from "nixparse";
114
+
115
+ // override the default args — du requires an explicit path
116
+ const sizes = await run<DirSize[]>("du", ["-k", "/var/log"]);
117
+ const biggest = sizes.sort((a, b) => b.sizeKb - a.sizeKb)[0];
118
+ ```
119
+
120
+ ### `registerParser(name, definition)`
121
+
122
+ ```ts
123
+ function registerParser<T>(
124
+ name: string,
125
+ definition: { schema: ZodType<T>; parse: (raw: string) => unknown },
126
+ ): void;
127
+ ```
128
+
129
+ 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.
130
+
131
+ ```ts
132
+ import { registerParser, parse } from "nixparse";
133
+ import { z } from "zod";
134
+
135
+ const UserSchema = z.object({ name: z.string(), shell: z.string() });
136
+
137
+ registerParser("getent-passwd", {
138
+ schema: z.array(UserSchema),
139
+ parse: (raw) =>
140
+ raw
141
+ .trim()
142
+ .split("\n")
143
+ .map((line) => {
144
+ const fields = line.split(":");
145
+ return { name: fields[0], shell: fields[6] };
146
+ }),
147
+ });
148
+
149
+ const users = parse("getent-passwd", rawGetentOutput);
150
+ ```
151
+
152
+ 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()`.
153
+
154
+ ## Supported commands (full field reference)
155
+
156
+ All built-in parsers are based on **Linux (GNU coreutils / iproute2 / util-linux)** output. See [Scope and limitations](#scope-and-limitations) for other platforms.
157
+
158
+ ### `ps`
159
+
160
+ - Name: `"ps"` · Default command: `ps aux` · Returns: `ProcessInfo[]`
161
+
162
+ | Field | Type | Source column | Notes |
163
+ |---|---|---|---|
164
+ | `user` | `string` | `USER` | |
165
+ | `pid` | `number` | `PID` | |
166
+ | `cpu` | `number` | `%CPU` | |
167
+ | `mem` | `number` | `%MEM` | |
168
+ | `vsz` | `number` | `VSZ` | Virtual memory size, KB |
169
+ | `rss` | `number` | `RSS` | Resident set size, KB |
170
+ | `tty` | `string` | `TTY` | `?` if no controlling terminal |
171
+ | `stat` | `string` | `STAT` | Process state code, e.g. `Ss`, `R+` |
172
+ | `start` | `string` | `START` | As printed by `ps` (time or date) |
173
+ | `time` | `string` | `TIME` | Cumulative CPU time |
174
+ | `command` | `string` | `COMMAND` | Full command line, including all arguments — never truncated at a space |
175
+
176
+ ```ts
177
+ const procs = await run<ProcessInfo[]>("ps");
178
+ const highCpu = procs.filter((p) => p.cpu > 50);
179
+ ```
180
+
181
+ ### `df`
182
+
183
+ - Name: `"df"` · Default command: `df -k` · Returns: `DiskUsage[]`
184
+
185
+ | Field | Type | Source column | Notes |
186
+ |---|---|---|---|
187
+ | `filesystem` | `string` | `Filesystem` | |
188
+ | `blocksKb` | `number` | `1K-blocks` | Total size in KB |
189
+ | `usedKb` | `number` | `Used` | KB |
190
+ | `availableKb` | `number` | `Available` | KB |
191
+ | `usePercent` | `number` | `Use%` | Numeric, `%` sign stripped (e.g. `42`, not `"42%"`) |
192
+ | `mountedOn` | `string` | `Mounted on` | |
193
+
194
+ ```ts
195
+ const disks = await run<DiskUsage[]>("df");
196
+ const full = disks.filter((d) => d.usePercent >= 90);
197
+ ```
198
+
199
+ ### `du`
200
+
201
+ - Name: `"du"` · Default command: `du -k` (no path — you should pass one) · Returns: `DirSize[]`
202
+
203
+ | Field | Type | Notes |
204
+ |---|---|---|
205
+ | `sizeKb` | `number` | Size in KB |
206
+ | `path` | `string` | Path as printed by `du` |
207
+
208
+ `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.
209
+
210
+ ```ts
211
+ const sizes = await run<DirSize[]>("du", ["-k", "-d", "1", "/home/me/projects"]);
212
+ ```
213
+
214
+ ### `free`
215
+
216
+ - Name: `"free"` · Default command: `free -b` · Returns: `MemoryInfo`
217
+
218
+ `-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.
219
+
220
+ | Field | Type | Notes |
221
+ |---|---|---|
222
+ | `mem.total` | `number` | Bytes |
223
+ | `mem.used` | `number` | Bytes |
224
+ | `mem.free` | `number` | Bytes |
225
+ | `mem.shared` | `number` | Bytes |
226
+ | `mem.buffCache` | `number` | Bytes (`buff/cache` column) |
227
+ | `mem.available` | `number` | Bytes |
228
+ | `swap.total` | `number` | Bytes |
229
+ | `swap.used` | `number` | Bytes |
230
+ | `swap.free` | `number` | Bytes |
231
+
232
+ ```ts
233
+ const mem = await run<MemoryInfo>("free");
234
+ const usedPercent = (mem.mem.used / mem.mem.total) * 100;
235
+ ```
236
+
237
+ ### `lsof`
238
+
239
+ - Name: `"lsof"` · Default command: `lsof -F pcufTtn` · Returns: `OpenFile[]`
240
+
241
+ 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.
242
+
243
+ | Field | Type | Notes |
244
+ |---|---|---|
245
+ | `pid` | `number` | Owning process ID |
246
+ | `command` | `string` | Owning process's command name |
247
+ | `user` | `string` | Owning process's user (numeric UID as printed by lsof) |
248
+ | `fd` | `string` | File descriptor, e.g. `cwd`, `txt`, `mem`, or a number |
249
+ | `type` | `string` | File type, e.g. `DIR`, `REG`, `IPv4`, `unknown` |
250
+ | `name` | `string` | Path, or socket/device description |
251
+
252
+ ```ts
253
+ // all processes with files open under /var/log
254
+ const open = await run<OpenFile[]>("lsof");
255
+ const logUsers = open.filter((f) => f.name.startsWith("/var/log"));
256
+ ```
257
+
258
+ Without root, lsof can only see your own processes' files — this is an OS permission limit, not a nixparse limitation.
259
+
260
+ ### `mount`
261
+
262
+ - Name: `"mount"` · Default command: `mount` · Returns: `MountPoint[]`
263
+
264
+ | Field | Type | Notes |
265
+ |---|---|---|
266
+ | `device` | `string` | e.g. `/dev/sda1`, `none`, `tmpfs` |
267
+ | `path` | `string` | Mount point |
268
+ | `fsType` | `string` | e.g. `ext4`, `overlay`, `tmpfs` |
269
+ | `options` | `string[]` | Mount options split on `,`, e.g. `["rw", "relatime"]` |
270
+
271
+ ```ts
272
+ const mounts = await run<MountPoint[]>("mount");
273
+ const readOnly = mounts.filter((m) => m.options.includes("ro"));
274
+ ```
275
+
276
+ ### `who`
277
+
278
+ - Name: `"who"` · Default command: `who` · Returns: `LoggedInUser[]`
279
+
280
+ | Field | Type | Notes |
281
+ |---|---|---|
282
+ | `user` | `string` | |
283
+ | `tty` | `string` | e.g. `pts/1` |
284
+ | `loginTime` | `string` | As printed by `who` (locale-dependent format, kept as a raw string rather than parsed into a `Date`) |
285
+
286
+ ### `uptime`
287
+
288
+ - Name: `"uptime"` · Default command: `uptime` · Returns: `UptimeInfo`
289
+
290
+ | Field | Type | Notes |
291
+ |---|---|---|
292
+ | `currentTime` | `string` | Wall clock time as printed, e.g. `"14:32:10"` |
293
+ | `upDays` | `number` | `0` if uptime is under a day |
294
+ | `upHours` | `number` | |
295
+ | `upMinutes` | `number` | |
296
+ | `users` | `number` | Number of logged-in users |
297
+ | `loadAverage1m` | `number` | |
298
+ | `loadAverage5m` | `number` | |
299
+ | `loadAverage15m` | `number` | |
300
+
301
+ ```ts
302
+ const up = await run<UptimeInfo>("uptime");
303
+ if (up.loadAverage1m > 4) console.warn("system is under heavy load");
304
+ ```
305
+
306
+ ### `ip-addr`
307
+
308
+ - Name: `"ip-addr"` · Default command: `ip -j addr` · Returns: `NetworkInterface[]`
309
+
310
+ `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.
311
+
312
+ | Field | Type | Notes |
313
+ |---|---|---|
314
+ | `ifindex` | `number` | |
315
+ | `ifname` | `string` | e.g. `eth0`, `lo` |
316
+ | `flags` | `string[]` | e.g. `["BROADCAST", "MULTICAST", "UP"]` |
317
+ | `mtu` | `number` | |
318
+ | `qdisc` | `string` | |
319
+ | `operstate` | `string` | e.g. `UP`, `DOWN`, `UNKNOWN` |
320
+ | `group` | `string` | |
321
+ | `txqlen` | `number?` | Optional — not present on all interface types |
322
+ | `link_type` | `string` | e.g. `ether`, `loopback` |
323
+ | `address` | `string` | MAC address |
324
+ | `broadcast` | `string?` | Optional |
325
+ | `addr_info` | `AddrInfo[]` | See below |
326
+
327
+ `AddrInfo`:
328
+
329
+ | Field | Type | Notes |
330
+ |---|---|---|
331
+ | `family` | `string` | `"inet"` or `"inet6"` |
332
+ | `local` | `string` | The IP address |
333
+ | `prefixlen` | `number` | |
334
+ | `broadcast` | `string?` | Optional |
335
+ | `scope` | `string` | e.g. `global`, `host`, `link` |
336
+ | `label` | `string?` | Optional |
337
+ | `valid_life_time` | `number` | |
338
+ | `preferred_life_time` | `number` | |
339
+
340
+ ```ts
341
+ const ifaces = await run<NetworkInterface[]>("ip-addr");
342
+ const eth0 = ifaces.find((i) => i.ifname === "eth0");
343
+ const ipv4 = eth0?.addr_info.find((a) => a.family === "inet")?.local;
344
+ ```
345
+
346
+ ### `ip-route`
347
+
348
+ - Name: `"ip-route"` · Default command: `ip -j route` · Returns: `RouteEntry[]`
349
+
350
+ Same approach as `ip-addr`: native JSON, validated rather than parsed.
351
+
352
+ | Field | Type | Notes |
353
+ |---|---|---|
354
+ | `dst` | `string` | Destination, e.g. `"default"` or a CIDR |
355
+ | `gateway` | `string?` | Optional |
356
+ | `dev` | `string?` | Optional, outgoing interface |
357
+ | `protocol` | `string?` | Optional |
358
+ | `scope` | `string?` | Optional |
359
+ | `prefsrc` | `string?` | Optional, preferred source address |
360
+ | `flags` | `string[]` | |
361
+
362
+ ```ts
363
+ const routes = await run<RouteEntry[]>("ip-route");
364
+ const defaultRoute = routes.find((r) => r.dst === "default");
365
+ ```
366
+
367
+ ### `ss`
368
+
369
+ - Name: `"ss"` · Default command: `ss -tln` · Returns: `Socket[]`
370
+
371
+ 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.
372
+
373
+ | Field | Type | Notes |
374
+ |---|---|---|
375
+ | `state` | `string` | e.g. `LISTEN` |
376
+ | `recvQ` | `number` | |
377
+ | `sendQ` | `number` | |
378
+ | `localAddress` | `string` | IPv4, or IPv6 in `[...]` form, or `*` |
379
+ | `localPort` | `string` | Kept as a string since `*` is a valid value |
380
+ | `peerAddress` | `string` | |
381
+ | `peerPort` | `string` | |
382
+ | `process` | `string?` | Only present with `-p` and sufficient permissions, e.g. `users:(("node",pid=123,fd=10))` |
383
+
384
+ ```ts
385
+ const sockets = await run<Socket[]>("ss", ["-tlnp"]);
386
+ const listeningOn8080 = sockets.find((s) => s.localPort === "8080");
387
+ ```
388
+
389
+ ## Error handling
390
+
391
+ ```ts
392
+ import { run, parse, CommandNotFoundError, UnknownParserError } from "nixparse";
393
+ import { ZodError } from "zod";
394
+
395
+ try {
396
+ const procs = await run("ps");
397
+ } catch (err) {
398
+ if (err instanceof CommandNotFoundError) {
399
+ console.error(`missing binary: ${err.command}`);
400
+ } else if (err instanceof ZodError) {
401
+ console.error("output didn't match the expected shape", err.issues);
402
+ } else {
403
+ throw err;
404
+ }
405
+ }
406
+
407
+ try {
408
+ parse("not-a-real-parser", "...");
409
+ } catch (err) {
410
+ if (err instanceof UnknownParserError) {
411
+ console.error(`no parser named "${err.name}"`);
412
+ }
413
+ }
414
+ ```
415
+
416
+ ## Recipes
417
+
418
+ **Find the top 5 memory-hungry processes:**
419
+
420
+ ```ts
421
+ import { run, type ProcessInfo } from "nixparse";
422
+
423
+ const procs = await run<ProcessInfo[]>("ps");
424
+ const top5 = [...procs].sort((a, b) => b.rss - a.rss).slice(0, 5);
425
+ ```
426
+
427
+ **Alert if any disk is nearly full:**
428
+
429
+ ```ts
430
+ import { run, type DiskUsage } from "nixparse";
431
+
432
+ const disks = await run<DiskUsage[]>("df");
433
+ for (const d of disks) {
434
+ if (d.usePercent >= 90) {
435
+ console.warn(`${d.mountedOn} is at ${d.usePercent}% (${d.filesystem})`);
436
+ }
437
+ }
438
+ ```
439
+
440
+ **Feed structured system state to an LLM agent prompt:**
441
+
442
+ ```ts
443
+ import { run, type ProcessInfo, type MemoryInfo } from "nixparse";
444
+
445
+ const [procs, mem] = await Promise.all([
446
+ run<ProcessInfo[]>("ps"),
447
+ run<MemoryInfo>("free"),
448
+ ]);
449
+
450
+ const summary = {
451
+ topProcessesByCpu: procs.sort((a, b) => b.cpu - a.cpu).slice(0, 5),
452
+ memoryUsedPercent: (mem.mem.used / mem.mem.total) * 100,
453
+ };
454
+ // JSON.stringify(summary) → safe to embed in a prompt, already validated
455
+ ```
456
+
457
+ ## Scope and limitations
458
+
459
+ - 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.
460
+ - `lsof` visibility is limited by your process's permissions, same as running `lsof` directly.
461
+ - `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.
462
+ - `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.
463
+
464
+ ## Development
465
+
466
+ ```sh
467
+ git clone <this repo>
468
+ cd nixparse
469
+ npm install
470
+ npm run build # tsup → dist/ (ESM + CJS + .d.ts)
471
+ npm run test # vitest, runs against fixtures in test/fixtures/
472
+ npm run test:watch # watch mode
473
+ npm run typecheck # tsc --noEmit
474
+ ```
475
+
476
+ 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.
477
+
478
+ ## License
479
+
480
+ MIT