Manage directories
Directories are the other half of file work: you stage them before writing,
list them to find things, and wipe them when you’re done. fs.dirs.* is the
async surface for all of it — grouped, ergonomic, and named for what you
actually want.
import { fs } from "@warlock.js/fs";
await fs.dirs.ensure("uploads");Idempotent create. That single line makes uploads/ (and every missing
parent) exist, and does nothing if it already does.
Create, empty, remove
Section titled “Create, empty, remove”Three verbs cover the whole lifecycle, and they read exactly as they behave.
await fs.dirs.ensure("dist/cache/v2"); // create (+ parents), no-op if presentawait fs.dirs.empty("cache"); // clear contents, keep the folderawait fs.dirs.remove("tmp"); // delete the whole treeensure is recursive mkdir — you rarely need it before writing a file,
since fs.files.put auto-creates the parent. Reach for it when you want an
empty directory staged up front, or to make the create step visible in a
script.
empty is the one people reinvent by hand: it removes every child but leaves
the directory itself in place — perfect for a scratch folder you keep reusing.
remove deletes recursively and is safe to call on a path that doesn’t
exist — a missing target is a no-op, not an error. So a wipe-and-recreate
needs no guards:
await fs.dirs.remove("build");await fs.dirs.ensure("build");// or, if the folder should survive: await fs.dirs.empty("build");Inspect a directory
Section titled “Inspect a directory”Ask questions before you act on them.
await fs.dirs.exists("logs"); // booleanawait fs.dirs.isEmpty("logs"); // boolean — no childrenawait fs.dirs.count("logs"); // number of immediate childrenisEmpty is the honest gate for “should I skip this?” and count gives you a
quick tally without materializing the whole listing.
List children
Section titled “List children”list returns the immediate entries; listFiles and listDirs narrow to one
type. All three yield full paths joined to the directory you passed, so
results feed straight into the next call.
const everything = await fs.dirs.list("src"); // files + subdirsconst onlyFiles = await fs.dirs.listFiles("src"); // regular filesconst onlyDirs = await fs.dirs.listDirs("src"); // subdirectoriesSingle-level by default. Pass { recursive: true } to flatten the whole tree
into one array:
const allFiles = await fs.dirs.listFiles("src", { recursive: true });That’s convenient for small trees, but it builds the entire list in memory before you see the first entry. For anything large, stream instead.
Walk a tree in constant memory
Section titled “Walk a tree in constant memory”walk is an async iterator: it yields { path, name, type } one entry at a
time, so memory stays flat no matter how deep or wide the tree is.
for await (const entry of fs.dirs.walk("src", { recursive: true })) { if (entry.type === "file" && entry.name.endsWith(".ts")) { console.log(entry.path); }}You can start processing the first match immediately instead of waiting for the
whole scan — the right default for build steps, indexers, and cleanup passes.
walk also takes { followSymlinks: true } when you want to descend through
links.
Copy and move
Section titled “Copy and move”Both mirror the whole tree and auto-create the destination’s parent.
await fs.dirs.copy("public", "dist/public"); // recursive copyawait fs.dirs.move("staging/build", "releases/current");move is EXDEV-safe: when source and destination live on different volumes
(a /tmp → /var hop on Linux, or two drives on Windows), the OS can’t rename
across filesystems, so fs.dirs.move transparently falls back to copy-then-
delete. You don’t handle EXDEV yourself.
Claim an unused destination for a directory move
Section titled “Claim an unused destination for a directory move”fs.dirs.move with overwrite: false checks the destination and then
renames — a window in which another directory can appear, and an ordinary
rename silently replaces an empty directory. For cooperating writers
(archiving a feature, publishing a release folder), use
moveDirectoryNoReplaceAsync (also fs.dirs.moveNoReplace):
import { directoryIdentityAsync, moveDirectoryNoReplaceAsync, DirectoryMoveError,} from "@warlock.js/fs";
const identity = await directoryIdentityAsync("features/billing");
try { await moveDirectoryNoReplaceAsync("features/billing", "archive/billing-2026", { expectedSource: identity, // refuse if the source was swapped syncParents: true, // fsync both parent directories after the rename });} catch (error) { if (error instanceof DirectoryMoveError && error.outcome === "not-moved") { // known: this call moved nothing, and nothing at the destination was replaced } throw error;}It claims the destination name with an exclusive mkdir (atomic: it fails if
anything holds the name), then renames the source over that empty placeholder.
A destination that exists, or that someone writes into meanwhile, is refused
with reason: "destination-exists". It never falls back to copy + delete.
The error’s outcome tells you what to rely on:
not-moved: this call moved nothing and replaced nothing at the destination. For most reasons the source is still where it was; withsource-missingorsource-identity-mismatchanother process removed or swapped it, so read the source again before retrying.moved: only after a failedsyncParents. The move happened; durability is unknown.uncertain: neither location holds what was expected. Inspect both paths.
If the move stopped after claiming the destination and the empty placeholder
could not be removed, error.placeholderCleanupError is set and the message
says so: remove that empty directory before retrying, or the retry reports
destination-exists. Without syncParents the move is atomic but not
crash-durable: flushing both parent directories is the caller’s job.
Limits: Node has no renameat2(RENAME_NOREPLACE) or descriptor-relative
renames, so paths are resolved by name; the empty placeholder is briefly
visible; the source and placeholder identity checks are sequential. A swap of
the placeholder after its identity is recorded is refused
(placeholder-replaced), but a process not using this API that swaps it
for its own empty directory in the instant right after the claim (before the
identity can be read) or right before the rename would still be replaced. It refuses
on Windows (unsupported-platform), across filesystems (cross-device) and
when the destination’s parent is missing. The parent may be a symlink to a
directory (macOS /tmp, a deploy current link); its target is what is
checked. Existing move behavior is unchanged.
Measure and fingerprint
Section titled “Measure and fingerprint”await fs.dirs.size("dist"); // total bytes, recursiveawait fs.dirs.hash("dist"); // stable fingerprint of the whole treesize sums every file underneath — the number you’d report as “build output
size”. hash produces a stable digest of the tree’s structure and content
(SHA-256 by default): the same files in the same shape always hash the same,
regardless of walk order. It’s the reliable way to answer “did this directory
change since last time?” without diffing entry by entry — see
Hash files for the file-level counterpart.
Prefer an object? Use a handle
Section titled “Prefer an object? Use a handle”When you’re doing several operations against one directory, fs.dir(path) hands
you a lazy Directory — no IO until you call a method, and child handles for
free.
const src = fs.dir("src");
for (const file of await src.listFiles({ recursive: true })) { if (file.extension === ".ts") { console.log(await file.size()); // File handle, per entry }}
const nested = src.dir("components", "ui"); // child Directory handlelistFiles / listDirs on a handle return File[] / Directory[], so you keep
chaining without re-joining paths by hand. See
The fs facade for the full handle surface.
Related
Section titled “Related”- Read and write files — text + JSON IO through
fs.files.*. - The fs facade — the whole
fs.*surface, including handles. - Hash files — fingerprinting for cache invalidation.
- Reference / API — full signatures, including the sync primitives.