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

@crewhaus/tool-fsx

v0.8.0

Published

Deterministic filesystem tools beyond read and write: trees, stats, hashes, copies, moves, archives, frontmatter and notebooks

Readme

@crewhaus/tool-fsx

Filesystem tools beyond read and write.

@crewhaus/tool-fs covers a file's contents — Read, Write, Edit, Glob, Grep. This package covers everything around them: what a path is, what a tree contains, moving bytes about, archives, and the two file formats a harness edits constantly and a model should not have to hand-parse.

tools:
  - all-fsx         # every tool below
  - -removePath     # ...except this one

| Tool | What it does | |---|---| | Stat | Type, size, mtime, permissions, link count, symlink target and a sha256 | | FileHash | sha256 / sha1 / md5, read in chunks so file size does not matter | | Tree | A directory drawn as a tree: depth-capped, entry-capped, sorted, .gitignore-aware | | DiskUsage | Sizes rolled up per directory, largest first | | FindFiles | By name glob, size range, mtime window and type — sorted | | ReadLines | A numbered line range, reading only as far as the range needs, within a character budget | | TailFile | The last N lines, read backwards from the end | | MakeDirectory | Create a directory, with its parents | | TouchFile | Create an empty file; set its timestamps when you supply one | | TempDir | A scratch directory under a name you choose | | CopyPath | Copy a file or tree, with dryRun and no accidental overwrite | | MovePath | Move or rename, with the same two guards | | RemovePath | Delete, with dryRun counting exactly what would go | | SplitFile | Split into numbered parts by byte size or line count | | ConcatFiles | Join files, in order, into one | | ArchiveCreate | Pack a path into tar, tar.gz or zip | | ArchiveList | List an archive's members and flag any that would escape | | ArchiveExtract | Extract into a destination, refusing anything that escapes it | | FrontmatterRead | Read a markdown file's YAML front matter as data | | FrontmatterWrite | Set or remove front-matter keys, leaving the body alone | | NotebookRead | A Jupyter .ipynb as cells, with outputs flattened | | NotebookEdit | Replace, insert or delete one notebook cell |

Containment

Every caller-supplied path goes through the same guard @crewhaus/tool-fs uses: resolve it, refuse anything landing outside process.cwd(), and re-check the real path so a symlink inside the workspace cannot point out of it. index.test.ts proves it for every path-taking tool, three ways — a .. path, an absolute path, and an in-workspace symlink to somewhere else.

The guard covers every path a tool writes, not just the one the caller named. CopyPath and MovePath's cross-filesystem fallback copy with @crewhaus/tool-safety's copyTreeSafe: the whole copy is planned before a byte is written, an existing symlink anywhere under the destination is refused (even one that stays inside the workspace, as GNU cp -R refuses to merge a directory into a link), a file never replaces a directory, and FIFOs, sockets and devices in the source are refused. Links are copied as links, and only when, from where the copy puts them, they lead inside the workspace or exactly where the original leads (an absolute link out, such as a virtualenv's interpreter, reaches nothing new and is listed in the result's outsideLinks). MovePath judges every link in the tree from its new place before it renames anything, so a/b/up -> ../.. cannot be moved one level up to point out of the workspace. SplitFile and ConcatFiles write each file through a temp created with O_EXCL|O_NOFOLLOW and renamed into place, and refuse a part or destination name that is a symlink, with or without overwrite. copy-containment.test.ts holds these.

The run's own record is out of reach as well (0.8.0). The runtime hands each tool call the directories where it keeps that record: the session logs, the tool results it saved to a file, and the proof bundles. The permission engine already denies a write that names a path inside one, or that acts beneath a workspace directory holding one, but it leaves a write beneath the workspace root itself to the rules, and lets every call through in bypass mode. So CopyPath, MovePath, RemovePath and ArchiveExtract check their own targets against the same directories before they write, and refuse in every permission mode, bypass included. Their check is exact. A file copied onto a directory that holds one is refused; a tree is refused only where it has something on the way to one, since a copy merges entry by entry; and a copy that lands beside one (CopyPath notes.md .crewhaus/notes.md) is not refused. A move or removal of a directory that holds one is refused, and so is an archive with a top-level member that is or holds one. Reading from them is not a write: CopyPath and ArchiveCreate can take .crewhaus as their source.

Archives get a second gate. ArchiveExtract reads the member list from the archive's own structures in this process — tar's headers, zip's central directory — rather than from tar -t or unzip -Z1 output, because a member name can contain a newline and the two tars in the wild quote control characters differently. It refuses any member whose path escapes the destination (zip-slip) or whose link does. A link's target is resolved over the archive's own tree one component at a time, the way the kernel will resolve it, so x -> a/b/y/../f is seen to leave the destination when a/b/y is itself a link to ../..; text folding would have said a/b/f. A .tar.gz is listed from every gzip member, as tar -xz reads it, and a pax GNU.sparse.name is read as the member's name, as both tars read it.

It then extracts into a staging directory and checks what was actually written before accepting any of it: every link resolved through the real tree, every file's hard-link count (a file with a name outside the tree is refused), every top-level name against the index, and the bytes written. maxBytes (default 1 GiB, at most 16 GiB) caps the content: an archive whose index declares more is refused before extraction, dryRun and ArchiveList report totalBytes, the extractor is stopped once the staging tree passes the cap, and a zip that wrote more than its index declared is refused as lying. Hand-built malicious archives in archive-fixtures.ts test that, because tar and zip refuse to create such an archive in the first place.

ArchiveCreate stores a symlink as a link in every format (zip -y, as tar does), never the file or directory it points at, and reports any stored link that leads outside the archived tree, since ArchiveExtract will refuse it.

Reading and rewriting files

Every read opens the file without following a link at the leaf, and refuses a FIFO, socket or device before opening it: a plain open of a FIFO blocks the whole process until something writes to it, and no timeout reaches it. The walk's .gitignore files are read the same way, and one linked out of the workspace, larger than 1 MiB, or not a regular file contributes no rules.

ReadLines returns at most maxChars characters of line text in all (default 262 144, at most 4 Mi). A line that would pass the budget is cut and listed in truncatedLines with its full length (charsAtLeast when it runs on for more than 64 MiB), and the range stops there, with truncated: true; resume from end + 1. Lines before start are counted, never held.

FrontmatterWrite and NotebookEdit rewrite a file through a temp and a rename, and the file keeps its permission bits. A front matter key named __proto__ is read, kept and written like any other key; it cannot be set through FrontmatterWrite (the input would drop it), and neither can a key the subset could not read back.

Determinism

Same call, same tree, same bytes. Listings sort with plain < on the raw string — never localeCompare, whose answer depends on the host's locale data. Time bounds come from the caller as ISO strings, never from the clock; TouchFile will not bump an mtime unless you give it one. Nothing samples a random source except the temp-file suffix of an atomic write.

Globs

FindFiles names, exclude patterns and .gitignore rules share one matcher (compileGlob, exported for other packages). It is not a RegExp: a pattern is split into path segments and matched with the two-pointer wildcard walk, so *a*a*a*a*a*a*b against a 255-character name costs microseconds where a backtracking engine took hours, and a committed .gitignore line cannot freeze Tree. A character class never matches /, negated or not (a[!x]b does not match a/b), as gitignore(5) says.

What is deliberately not here

WatchPath: unbounded in time, so it has no place among tools a harness may call freely. Anything already in @crewhaus/tool-fs. And the .gitignore matcher does not consult the git index, so a tracked file matching a pattern is still hidden — stated in the source rather than glossed over, and checked against real git check-ignore verdicts in the tests.

Layout

src/lib/ holds the pure functions — glob compilation, .gitignore semantics, the front-matter subset, notebook shapes, the tar and zip parsers — tested in src/lib.test.ts. src/index.ts wraps them as tools, tested against real files in src/index.test.ts and through executeTool in src/integration.test.ts.

Safety flags

Reads (Stat, FileHash, Tree, DiskUsage, FindFiles, ReadLines, TailFile, ArchiveList, FrontmatterRead, NotebookRead) are readOnly and concurrencySafe. Everything else is destructive and not concurrency-safe. ArchiveCreate and ArchiveExtract spawn tar / zip, so they alone declare scope: "external" and ioCapability: "process"; the other twenty are internal with no io capability. No tool requires a sandbox, because none runs untrusted code. src/index.test.ts asserts every one of those facts per tool, so a future addition that reaches outside has to change the assertion deliberately.