Flexible and fluent interface for searching the file system
npm install --save filehound
Requires Node.js 24 or later. Filehound is an ES module, with TypeScript types included:
import { FileHound, ext } from 'filehound';
CommonJS can load it too, as Node 24 can require() ES modules.
Upgrading from V1? See the migration guide.
The example below prints all of the files in a directory that have the .json file extension:
import { FileHound, ext } from 'filehound';
const files = await FileHound.create()
.paths('/some/dir')
.match(ext('json'))
.find();
console.log(files);
The remaining examples assume the functions they use have been imported in the same way.
Find all the files that start with dev:
const files = await FileHound.create()
.paths('/etc/pki/')
.match(glob('**/dev*'))
.find();
Glob patterns follow the same rules as Node's fs.glob and path.matchesGlob, and are matched against each path relative to the search path. So a pattern matches only at the depth it spells out: dev* matches only the top level of the search path, and certs/dev* only inside certs. Start a pattern with **/ to match at any depth. Wildcards don't match names beginning with a dot, and on macOS and Windows wildcard patterns ignore case, as they do in Node.
Find all of the files in a directory that are larger than 1024 bytes:
const files = await FileHound.create()
.paths('/some/dir')
.match(size('>1024'))
.find();
Sizes can also be given with units, in multiples of 1024: bytes (b, the default), kilobytes (k, kb), megabytes (m, mb), gigabytes (g, gb) and terabytes (t, tb), in any case. The operators are <, <=, >, >= and == (or =), which is the default:
const files = await FileHound.create()
.paths('/some/dir')
.match(size('<=1mb'))
.find();
For files of an exact number of bytes, use sizeEquals:
const files = await FileHound.create()
.paths('/some/dir')
.match(sizeEquals(1024))
.find();
Find all of the files modified in the last two days:
const files = await FileHound.create()
.paths('/some/dir')
.match(modified('<2 days'))
.find();
The operators are <, <=, >, >= and == (or =), which is the default. The units are minutes (m, min, mins, minute, minutes), hours (h, hour, hours), days (d, day, days, the default) and weeks (w, week, weeks), in any case. accessed and changed work in the same way.
Ages are compared in whole units, like find -mtime: a file modified two and a half days ago is 2 days old, so it matches '=2 days', '>=2 days' and '<3 days', but not '>2 days'. Ages are measured when each file is tested, so a predicate can be kept and reused.
An expression that can't be parsed, or has an unknown unit, throws an ExpressionError as soon as the predicate is made, before any search runs. So expressions from users, such as command-line options, can be checked up front:
import { ExpressionError, FileHound, modified } from 'filehound';
try {
const files = await FileHound.create()
.match(modified(userInput))
.find();
} catch (err) {
if (err instanceof ExpressionError) {
console.error(err.message); // e.g. Invalid expression '>3 years': unknown time unit 'years'
}
}
Find all the .txt files that are larger than 1024 bytes and start with note:
const files = await FileHound.create()
.paths('/etc/pki/')
.match(
glob('**/note*')
.and(ext('txt'))
.and(size('>1024'))
)
.find();
Use or to match either filter:
const files = await FileHound.create()
.paths('/some/dir')
.match(ext('json').or(ext('yaml')))
.find();
Find all of the files that don't have the .json extension:
const files = await FileHound.create()
.match(not(ext('json')))
.find();
Exclude any paths matching a regular expression:
const files = await FileHound.create()
.match(discard('\\.log$'))
.find();
discard only leaves matches out of the results: a discarded directory is still searched. To keep the search out of a directory, see Skipping directories.
Or include only those that match one, with like:
const files = await FileHound.create()
.match(like('reports?/2026'))
.find();
Find all of the files that pass a custom test. The test can be asynchronous:
const files = await FileHound.create()
.paths('/some/dir')
.match(applyFilter(async file => await file.size() > 0 && file.getName().includes('report')))
.find();
Filters made with applyFilter only work with find() and iterate(). Use applyFilterSync for a synchronous test that works with findSync() too:
const files = FileHound.create()
.paths('/some/dir')
.match(applyFilterSync(file => file.getName().includes('report')))
.findSync();
The built-in matchers work with all three. With find() and iterate(), those that need to read from the file system, such as size and modified, use asynchronous calls, so a search doesn't block the event loop.
A filter is given a FileView: a read-only view of the file, with methods for its name, paths, type, size, dates and stats, and none that change the file system. A filter should have no side effects, as it may be called ahead of the matches being returned, at the same time as other filters, and on files that are never returned.
Return each match as { path, stats }, where stats is an fs.Stats object:
const files = await FileHound.create()
.paths('/some/dir')
.includeFileStats()
.find();
files.forEach(({ path, stats }) => console.log(path, stats.size));
Search for directories instead of, or as well as, regular files:
const directories = await FileHound.create()
.paths('/some/dir')
.type(FileType.DIRECTORY)
.find();
const everything = await FileHound.create()
.paths('/some/dir')
.type(FileType.REGULAR, FileType.DIRECTORY)
.find();
FileType.REGULAR and FileType.DIRECTORY are the strings 'regular' and 'directory', so .type('directory') works too.
A hidden file or directory is one whose name begins with a dot. Ignoring hidden directories also leaves out everything inside them:
const files = await FileHound.create()
.paths('/some/dir')
.ignoreHiddenFiles()
.ignoreHiddenDirectories()
.find();
Skip directories by name. They are neither searched nor returned, which saves walking large trees such as node_modules:
const files = await FileHound.create()
.paths('/some/dir')
.skipDirectories('node_modules', '.git', 'build-*')
.match(ext('js'))
.find();
Each name is matched against a directory's name, not its path, using Node's path.matchesGlob: 'node_modules' matches exactly, and 'build-*' matches any name beginning build-. Every directory below the search paths is tested, including linked directories, but not the search paths themselves. Calling skipDirectories again replaces the names.
Symbolic links are followed by default: a linked file is matched as its target and a linked directory is searched. Broken links, and links back to a directory already being searched, are skipped. To leave links out of the search entirely:
const files = await FileHound.create()
.paths('/some/dir')
.ignoreSymlinks()
.find();
Find all files but only in the current directory (recursion off):
const files = await FileHound.create()
.depth(0)
.find();
Directories at the limit are not searched, but they are still returned when searching for directories. This lists the subdirectories of the current directory:
const subdirectories = await FileHound.create()
.type(FileType.DIRECTORY)
.depth(0)
.find();
Find all the files that are either over 1K or have the .json file extension:
const filesOverOneK = FileHound.create()
.paths('/some/dir')
.match(size('>1k'));
const jsonFiles = FileHound.create()
.paths('/some/dir')
.match(ext('json'));
const files = await FileHound.any(filesOverOneK, jsonFiles);
Find all JSON files in '/some/dir1' and '/some/dir2':
const jsonFiles = await FileHound.create()
.paths('/some/dir1', '/some/dir2')
.match(ext('json'))
.find();
Or pass the paths as an array:
const myPaths = ['/some/dir1', '/some/dir2'];
const jsonFiles = await FileHound.create()
.paths(myPaths)
.match(ext('json'))
.find();
find() waits for the whole search to finish. To handle each match as it's found, loop over iterate() instead. The search only runs a little ahead of the loop, so a large search doesn't hold every match in memory:
const query = FileHound.create()
.paths('/some/dir')
.match(ext('json'));
for await (const file of query.iterate()) {
console.log(file);
}
Leaving the loop early, with break or return, stops the search.
find(), iterate() and findSync() return matches in the same order: depth first, with the entries of each directory in name order.
Pass an AbortSignal to find() or iterate() to stop a search. An aborted search rejects with the signal's reason. For example, to give up after five seconds:
const files = await FileHound.create()
.paths('/')
.find({ signal: AbortSignal.timeout(5000) });
Or stop it yourself with an AbortController, for example when Ctrl+C is pressed:
const controller = new AbortController();
process.once('SIGINT', () => controller.abort());
try {
const files = await FileHound.create()
.paths('/')
.find({ signal: controller.signal });
console.log(files);
} catch (error) {
if (error.name !== 'AbortError') throw error;
console.log('search cancelled');
}
Find all JSON files in '/tmp' synchronously:
const jsonFiles = FileHound.create()
.paths('/tmp')
.match(ext('json'))
.findSync();
Bind to a match event to process each file on match. Bind to the events before calling find() or iterate():
const filehound = FileHound.create();
filehound.on('match', (file) => {
console.log(`process ${file}`);
});
filehound.on('error', (error) => {
console.log(`error ${error}`);
});
filehound.on('end', () => {
console.log('search complete');
});
await filehound.find();
An error event is emitted if the search fails or is aborted. The end event is emitted once the search has finished, however it finished: with every match found, with an error, aborted, or with a loop over iterate() left early. findSync() emits no events.
Once find(), iterate() or findSync() has been called, changing the query throws an error. Create a new query for each search:
const query = FileHound.create().paths('/some/dir');
await query.find();
query.match(ext('json')); // throws: Cannot modify query after search has started
See FileSniffer