Skip to content
Warlock.js v4.7.0

Edit files in place

Every project ends up doing the same dance: read a file, change one thing, write it back. Bump a version. Flip a flag. Push a log line. Done by hand that’s three steps and a variable you have to name. The facade turns each of those into one call — you pass a function, fs handles the read and the write.

import { fs } from "@warlock.js/fs";
await fs.files.editJson("package.json", (pkg) => ({ ...pkg, version: "4.7.0" }));

That’s the whole read-modify-write loop. No getJson, no temp variable, no putJson. This guide is the tour of that family.

edit(path, text => text) — patch a text file

Section titled “edit(path, text => text) — patch a text file”

edit reads the file as a string, hands it to your function, and writes whatever you return.

await fs.files.edit("README.md", (md) => md.replaceAll("4.6", "4.7"));

Compare that to doing it by hand — the thing you’d otherwise write:

const md = await fs.files.get("README.md");
await fs.files.put("README.md", md.replaceAll("4.6", "4.7"));

Same result, but edit names the pattern for you: the argument is the current contents, the return value is the new contents. Nothing to mistype, nothing to forget to write back.

editJson(path, obj => obj) — transform a JSON document

Section titled “editJson(path, obj => obj) — transform a JSON document”

editJson is edit for JSON: it parses, calls your function with the parsed object, and writes the (pretty-printed) result. This is the killer one — bumping a version, retargeting a field, rewriting a nested value.

await fs.files.editJson<Pkg>("package.json", (pkg) => ({
...pkg,
version: bumpPatch(pkg.version),
}));

Because you get the whole document, you can do anything — including push into an array, which nothing else in the family does:

await fs.files.editJson<Manifest>("manifest.json", (m) => ({
...m,
files: [...m.files, "styles.css"],
}));

mergeJson(path, partial, { deep? }) — patch a config object

Section titled “mergeJson(path, partial, { deep? }) — patch a config object”

When you only want to set a few fields and leave the rest alone, you don’t need a whole transform function — hand mergeJson a partial object and it merges it in. Shallow by default; pass { deep: true } to recurse into nested objects.

await fs.files.mergeJson("config.json", { flags: { queues: true } });

The deep flag is the whole story. Say config.json starts as:

config.json (before)
{ "flags": { "cache": true }, "port": 3000 }

A shallow merge replaces flags wholesale — the old keys inside it are gone:

shallow (default)
await fs.files.mergeJson("config.json", { flags: { queues: true } });
config.json (after — shallow)
{ "flags": { "queues": true }, "port": 3000 }

A deep merge recurses into flags and keeps cache:

deep
await fs.files.mergeJson("config.json", { flags: { queues: true } }, { deep: true });
config.json (after — deep)
{ "flags": { "cache": true, "queues": true }, "port": 3000 }

ensureJson(path, fallback) — get-or-create config

Section titled “ensureJson(path, fallback) — get-or-create config”

Loading a config file that might not exist yet? ensureJson returns the parsed document if it’s there, or writes fallback and returns that.

const config = await fs.files.ensureJson("config.json", {
port: 3000,
host: "localhost",
});

No exists check, no try/catch around a missing-file read. First run writes the defaults; every run after reads them. Pair it with the family above — ensureJson to get the doc into existence, mergeJson / editJson to evolve it from there.

appendJsonLine(path, value) — NDJSON logs

Section titled “appendJsonLine(path, value) — NDJSON logs”

Not every JSON file is one document. A log is a stream of records, one per line — NDJSON. appendJsonLine serializes one value and appends it as a single line.

await fs.files.appendJsonLine("events.ndjson", { type: "signup", at: Date.now() });

Each call adds exactly one line:

events.ndjson
{"type":"signup","at":1719800000000}
{"type":"login","at":1719800600000}

This is not the same as pushing into an array with editJson. The array approach re-reads and re-writes the entire file on every append — O(n) and increasingly slow as the log grows. appendJsonLine just appends bytes.

MethodWhen
getJson(path, { schema?, default? })read a document (optionally validate + fall back when missing)
putJson(path, value, { indent? })overwrite the whole document
mergeJson(path, partial, { deep? })set a few known fields; leave the rest (shallow, or deep)
editJson(path, fn)transform based on the old value — bump, compute, push into an array
ensureJson(path, fallback)read it, or create it if missing
appendJsonLine(path, value)one record per line (a log) — not one document

Rough rule: reading → getJson; replacing → putJson; patching known fields → mergeJson; computing from the current value → editJson; first-run defaults → ensureJson; a growing log → appendJsonLine.

Every method here also lives on a fs.file() handle, so if you’re already holding one you skip repeating the path:

const pkg = fs.file("package.json");
await pkg.editJson<Pkg>((p) => ({ ...p, version: "4.7.0" }));
await pkg.mergeJson({ private: true });