@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
replaceFileExtensionto 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:
listDirectoryChainreturns the directories alonelistDirectoryChainMatchesreturns every level's matchfindDirectoryChainMatchfinds only the nearest match
All three reject a path that falls outside the range they were asked to search.
findProjectRootnow applies that same rule to its markers.Add createTempTree with scope-bound disposal (#106)
Adds
createTempTreeto@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.
findProjectRootmoves from@williamthorsen/toolbelt.filesystemto@williamthorsen/toolbelt.packaging. Callers ofloadConfigCascademust now state where its upward search stops, rather than relying on a project root the function found for them.
Installation
pnpm add @williamthorsen/toolbelt.filesystemRuntime 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 | undefinedThe 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 extensionThe 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.
