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

@williamthorsen/toolbelt.filesystem

v0.3.0

Published

Filesystem utilities

Downloads

1,077

Readme

@williamthorsen/toolbelt.filesystem

Filesystem utilities for TypeScript and JavaScript.

Release notes — v0.3.0 (2026-08-08)

Features

  • Migrate replaceFileExtension into filesystem package (#74)

    Adds replaceFileExtension to the filesystem utilities. The function replaces the file extension in a file path; unlike analogous built-in functions, it supports multi-part extensions such as .d.ts.

  • Add directory-chain ascent and lookup exports (#102)

    Adds three functions for upward directory search, which walk from a starting directory to either the filesystem root or a bounded ceiling, finding named files or directories at each level along the way:

    • listDirectoryChain returns the directories alone
    • listDirectoryChainMatches returns every level's match
    • findDirectoryChainMatch finds only the nearest match

    All three reject a path that falls outside the range they were asked to search. findProjectRoot now applies that same rule to its markers.

  • Add createTempTree with scope-bound disposal (#106)

    Adds createTempTree to @williamthorsen/toolbelt.filesystem/proposed. The new function allows a caller to describe a directory tree as a plain object mapping paths to contents and receive a handle in return; the tree is removed when that handle goes out of scope.

  • 🚨 Breaking: Add findPackageRoot, getSelfVersion, and findProjectRoot to toolbelt.packaging (#107)

    Adds a way for any module, whether it runs from a source tree or a compiled build, to identify the package that owns it and the version that package declares.

    findProjectRoot moves from @williamthorsen/toolbelt.filesystem to @williamthorsen/toolbelt.packaging. Callers of loadConfigCascade must now state where its upward search stops, rather than relying on a project root the function found for them.

Installation

pnpm add @williamthorsen/toolbelt.filesystem

Runtime requirements

createTempTree, findDirectoryChainMatch, listDirectoryChainMatches, and loadConfigCascade reach the filesystem through node: builtins, so they run under Node.js 24 or later, Bun, and Deno. They do not run in browsers, nor in edge runtimes that expose no filesystem. listDirectoryChain and replaceFileExtension touch no filesystem, so an edge runtime that exposes none runs them; they still import node:path, which a browser bundle has to supply.

loadConfigCascade imports each config through the host runtime, so a .ts config is subject to whatever that runtime does with TypeScript. Node strips types rather than compiling them, which admits erasable syntax alone: an enum, a namespace, or a parameter property in a config file fails to parse. A .mjs or .js config sidesteps the question.

listDirectoryChain

listDirectoryChain(startDir: string, options?: { stopAtDir?: string }): [string, ...string[]];

Resolves startDir to an absolute path and returns it followed by each of its ancestors, nearest first. It manipulates paths as strings and reads nothing from disk.

import { listDirectoryChain } from '@williamthorsen/toolbelt.filesystem';

listDirectoryChain('/home/dev/app/src');
// ['/home/dev/app/src', '/home/dev/app', '/home/dev', '/home', '/']

listDirectoryChain('/home/dev/app/src', { stopAtDir: '/home/dev' });
// ['/home/dev/app/src', '/home/dev/app', '/home/dev']

stopAtDir bounds the ascent inclusively and is resolved the same way startDir is, so a relative ceiling behaves like a relative start. One that is neither the start directory nor an ancestor of it throws, naming both, rather than being ignored and letting the ascent run past the bound. The comparison is exact, so a stopAtDir differing from its target only in case is off the chain even on a volume that would open it.

The result type records that the chain is never empty, which is what spares the nearest directory an undefined check:

const [nearestDir] = listDirectoryChain(process.cwd()); // string, not string | undefined

The ascent terminates at the filesystem root on every platform, so a Windows drive root or UNC share is as safe a starting point as a POSIX path.

listDirectoryChainMatches

listDirectoryChainMatches(
  startDir: string,
  names: ReadonlyArray<string>,
  options?: { stopAtDir?: string },
): DirectoryChainMatch[];

Returns, for each directory in the chain at or above startDir, the first of names that exists there:

interface DirectoryChainMatch {
  dir: string; // the chain level, which differs from the entry's own directory for a nested name
  entryName: string;
  entryPath: string;
}
import { listDirectoryChainMatches } from '@williamthorsen/toolbelt.filesystem';

listDirectoryChainMatches('/home/dev/app/src', ['.git'], { stopAtDir: '/home/dev' });
// [{ dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }]

A level yields at most one match, the earliest of names found there, and a level holding none contributes nothing, so an empty result is an ordinary outcome rather than an error. A name matches a directory as readily as a file, which is what lets .git be probed without knowing whether the clone is ordinary or a worktree.

Each name is a path relative to the level it is probed against, so a nested location such as .config/stack.config.mjs works. A name that would leave its level (an absolute path, or one whose .. segments escape it) is rejected before any level is probed, so the rejection never depends on what happens to exist on disk.

options is forwarded to listDirectoryChain, so stopAtDir bounds the ascent the same way.

Every level is probed, because every level's match is reported. Where only the nearest match matters, findDirectoryChainMatch returns it and stops there.

findDirectoryChainMatch

findDirectoryChainMatch(
  startDir: string,
  names: ReadonlyArray<string>,
  options?: { stopAtDir?: string },
): DirectoryChainMatch | undefined;

Returns the nearest directory at or above startDir holding one of names, or undefined when none does. It is listDirectoryChainMatches narrowed to the first hit, sharing its result shape, its options, and its name validation:

import { findDirectoryChainMatch } from '@williamthorsen/toolbelt.filesystem';

findDirectoryChainMatch('/home/dev/app/src', ['.git']);
// { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }

Probing stops at the first level that matches, so no level beyond it is touched — the reason to reach for this rather than read element zero off listDirectoryChainMatches, which probes to the ceiling regardless. The nullable return type is the other reason: a result that may be absent says so, where an array leaves the caller to narrow.

loadConfigCascade

loadConfigCascade<TConfig>(options: {
  fileNames: ReadonlyArray<string>;
  shouldStopAscent?: (config: TConfig) => boolean;
  startDir: string;
  stopAtDir: string;
}): Promise<ConfigCascade<TConfig>>;

Loads every config file between startDir and stopAtDir, nearest first, and reads nothing above that boundary.

Discovery is listDirectoryChainMatches bounded at stopAtDir: the first of fileNames that exists at a level becomes that level's config, a level holding none contributes nothing, and a name that would leave its level is rejected before any file is read. A stopAtDir that is neither the start directory nor one of its ancestors throws, on the same terms listDirectoryChain sets out.

The boundary is required, and it is the caller's to choose. That is what keeps this function free of any notion of what marks a project: it never asks whether a directory holds a lockfile or a workspace manifest. Where the boundary should be a project root, findProjectRoot in @williamthorsen/toolbelt.packaging resolves one from markers.

The matched files are then imported one at a time, and shouldStopAscent is consulted after each. Once it returns true, the ascent halts and no farther file is imported at all, rather than being loaded and discarded:

interface ConfigCascade<TConfig> {
  entries: Array<{
    config: TConfig;
    dir: string; // the cascade level, which differs from the file's own directory for a nested file name
    filePath: string;
  }>;
  stopReason: 'predicate' | 'stop-dir';
}

A config is the module's default export. A matched module declaring none is rejected by name; validating what a config contains stays with the caller, which is what lets one mechanism serve schemas sharing no fields.

The shouldStopAscent convention

The predicate is the caller's whole stop policy, so any field can drive it. By convention a config declares a boolean shouldStopAscent, which a consumer reads directly:

import { loadConfigCascade } from '@williamthorsen/toolbelt.filesystem';
import { findProjectRoot } from '@williamthorsen/toolbelt.packaging';

interface StackConfig {
  rules?: Record<string, string>;
  shouldStopAscent?: boolean;
}

const { rootDir } = findProjectRoot(process.cwd());

const { entries, stopReason } = await loadConfigCascade<StackConfig>({
  fileNames: ['stack.config.mjs', 'stack.config.js'],
  shouldStopAscent: (config) => config.shouldStopAscent === true,
  startDir: process.cwd(),
  stopAtDir: rootDir,
});

stopReason is provenance for the caller to surface, so a user can see whether the predicate ended the cascade or it simply reached the boundary. Which directory bounded it is the stopAtDir the caller passed in.

createTempTree

Proposed tier: imported from @williamthorsen/toolbelt.filesystem/proposed rather than the package root, and subject to change.

createTempTree(entries: Record<string, string>): TempTree;

Builds a throwaway directory tree and returns a handle that removes it when the binding leaves scope:

import { createTempTree } from '@williamthorsen/toolbelt.filesystem/proposed';

{
  using tree = createTempTree({
    '.git/': '',
    'packages/app/package.json': '{ "name": "app" }',
  });

  tree.dir; // '/private/var/folders/.../toolbelt-a1b2c3'
  tree.resolve('packages/app'); // '/private/var/folders/.../toolbelt-a1b2c3/packages/app'
}
// The tree is gone here.

Each key of entries is a path relative to the tree root. One ending in / becomes a directory; any other becomes a file holding the mapped contents, with its intermediate directories created for it. A key resolving outside the root is rejected, and a call that throws leaves nothing on disk.

interface TempTree extends Disposable {
  readonly dir: string;
  resolve(...segments: string[]): string;
}

dir is realpath-resolved, because os.tmpdir() is a symlink on macOS and a caller comparing paths against it would otherwise see a mismatch it did not cause.

resolve joins segments against the root and throws when the result would fall outside it, so a stray .. fails loudly rather than reaching into the enclosing directory. An absolute segment landing inside the root is returned unchanged. The containment test is lexical, so it does not follow a symlink inside the tree that points out of it.

Disposal is idempotent.

Disposable is declared in lib.esnext.disposable.d.ts alone, so consuming this export requires ESNext.Disposable in your lib.

replaceFileExtension

Proposed tier: imported from @williamthorsen/toolbelt.filesystem/proposed rather than the package root, and subject to change.

replaceFileExtension(filePath: string, newExtension: string, options?: { oldExtension?: string }): string;

Returns filePath with its extension replaced. It manipulates the string alone and touches no filesystem.

import { replaceFileExtension } from '@williamthorsen/toolbelt.filesystem/proposed';

replaceFileExtension('src/main.ts', '.js'); // 'src/main.js'
replaceFileExtension('src/main.ts', 'js'); // 'src/main.js' -- the leading period is optional
replaceFileExtension('src/main.ts', ''); // 'src/main' -- an empty replacement removes the extension

The extension being replaced defaults to whatever path.extname reports, which is the substring from the final period in the file name. That is wrong for a multi-part extension: path.extname('src/main.d.ts') returns .ts, so the default would yield src/main.d.js. Declare the whole extension through oldExtension to replace it entire:

replaceFileExtension('src/main.d.ts', '.js', { oldExtension: '.d.ts' }); // 'src/main.js'

Which extension is meant is genuinely ambiguous, since archive.tar.gz could reasonably end in .gz or in .tar.gz, so the caller declares it rather than the function guessing.

Two inputs throw rather than returning a path that would quietly be wrong: a filePath ending in a separator, which names a directory rather than a file, and a filePath that does not end with a declared oldExtension.