Skip to content

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.

src/setup.ts
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.

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 present
await fs.dirs.empty("cache"); // clear contents, keep the folder
await fs.dirs.remove("tmp"); // delete the whole tree

ensure 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");

Ask questions before you act on them.

await fs.dirs.exists("logs"); // boolean
await fs.dirs.isEmpty("logs"); // boolean — no children
await fs.dirs.count("logs"); // number of immediate children

isEmpty is the honest gate for “should I skip this?” and count gives you a quick tally without materializing the whole listing.

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 + subdirs
const onlyFiles = await fs.dirs.listFiles("src"); // regular files
const onlyDirs = await fs.dirs.listDirs("src"); // subdirectories

Single-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 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.

Both mirror the whole tree and auto-create the destination’s parent.

await fs.dirs.copy("public", "dist/public"); // recursive copy
await 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; with source-missing or source-identity-mismatch another process removed or swapped it, so read the source again before retrying.
  • moved: only after a failed syncParents. 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.

await fs.dirs.size("dist"); // total bytes, recursive
await fs.dirs.hash("dist"); // stable fingerprint of the whole tree

size 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.

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 handle

listFiles / 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.