@warlock.js/fs 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +76 -0
  2. package/cjs/index.cjs +424 -0
  3. package/cjs/index.cjs.map +1 -0
  4. package/esm/atomic.d.mts +22 -0
  5. package/esm/atomic.mjs +40 -0
  6. package/esm/atomic.mjs.map +1 -0
  7. package/esm/copy.d.mts +14 -0
  8. package/esm/copy.mjs +29 -0
  9. package/esm/copy.mjs.map +1 -0
  10. package/esm/dirs.d.mts +10 -0
  11. package/esm/dirs.mjs +18 -0
  12. package/esm/dirs.mjs.map +1 -0
  13. package/esm/exists.d.mts +44 -0
  14. package/esm/exists.mjs +86 -0
  15. package/esm/exists.mjs.map +1 -0
  16. package/esm/hash.d.mts +32 -0
  17. package/esm/hash.mjs +52 -0
  18. package/esm/hash.mjs.map +1 -0
  19. package/esm/index.d.mts +12 -0
  20. package/esm/index.mjs +13 -0
  21. package/esm/list.d.mts +20 -0
  22. package/esm/list.mjs +39 -0
  23. package/esm/list.mjs.map +1 -0
  24. package/esm/read.d.mts +16 -0
  25. package/esm/read.mjs +28 -0
  26. package/esm/read.mjs.map +1 -0
  27. package/esm/remove.d.mts +14 -0
  28. package/esm/remove.mjs +40 -0
  29. package/esm/remove.mjs.map +1 -0
  30. package/esm/rename.d.mts +9 -0
  31. package/esm/rename.mjs +17 -0
  32. package/esm/rename.mjs.map +1 -0
  33. package/esm/stats.d.mts +16 -0
  34. package/esm/stats.mjs +28 -0
  35. package/esm/stats.mjs.map +1 -0
  36. package/esm/write.d.mts +14 -0
  37. package/esm/write.mjs +29 -0
  38. package/esm/write.mjs.map +1 -0
  39. package/llms-full.txt +570 -0
  40. package/llms.txt +13 -0
  41. package/package.json +24 -0
  42. package/skills/hash-files/SKILL.md +122 -0
  43. package/skills/manage-directories/SKILL.md +140 -0
  44. package/skills/overview/SKILL.md +77 -0
  45. package/skills/read-and-write-files/SKILL.md +107 -0
  46. package/skills/write-atomically/SKILL.md +98 -0
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: hash-files
3
+ description: 'Compute hex digests — hashFile / hashFileAsync (streaming for large files), hashFileSmallAsync, hashString, hashBuffer. Defaults to SHA-256; supports sha1 / md5 / sha512. Triggers: `hashFile`, `hashFileAsync`, `hashFileSmallAsync`, `hashString`, `hashBuffer`, `HashAlgorithm`; "fingerprint a file for cache invalidation", "compute SHA-256 checksum", "compare two files for equality", "cache key from request input"; typical import `import { hashFileAsync, hashString } from "@warlock.js/fs"`. Skip: file IO — `@warlock.js/fs/read-and-write-files/SKILL.md`; competing libs `hasha`, `md5-file`, `crypto-hash`; native `node:crypto` `createHash`.'
4
+ ---
5
+
6
+ # Compute file and content hashes
7
+
8
+ Hex-digest helpers backed by `node:crypto`. Picks the right strategy for the input size — streaming for files (memory stays flat), one-shot for in-memory content.
9
+
10
+ ## Available algorithms
11
+
12
+ ```ts
13
+ type HashAlgorithm = "sha256" | "sha1" | "md5" | "sha512";
14
+ ```
15
+
16
+ Default is `"sha256"` — the right choice for cache-bust, content-addressable storage, and fingerprinting. Pick `"md5"` only when matching an external system that requires it; never for security.
17
+
18
+ ## Hash a file
19
+
20
+ ```ts
21
+ import { hashFile, hashFileAsync } from "@warlock.js/fs";
22
+
23
+ // Streaming — constant memory regardless of file size
24
+ const fingerprint = await hashFileAsync("./bundle.js");
25
+ // → "8a7d3e2f9b4c..."
26
+
27
+ // Sync, with custom algorithm
28
+ const md5 = hashFile("./small.txt", "md5");
29
+ ```
30
+
31
+ `hashFileAsync` uses a stream, so a 1 GB file doesn't blow the heap. `hashFile` (sync) reads the whole file at once — fine for small files.
32
+
33
+ ## Hash a small file in one shot
34
+
35
+ ```ts
36
+ import { hashFileSmallAsync } from "@warlock.js/fs";
37
+
38
+ const digest = await hashFileSmallAsync("./icon.svg");
39
+ ```
40
+
41
+ `hashFileSmallAsync` reads the file in a single `readFile` call before hashing. Slightly faster than streaming when the file is < ~1 MB; **don't** use it on large files (it'll load the whole thing into memory).
42
+
43
+ | Use | Reach for |
44
+ | --- | --- |
45
+ | Streaming async (default for files) | `hashFileAsync` |
46
+ | Small file, slightly faster async | `hashFileSmallAsync` |
47
+ | Sync (CLI / config loader only) | `hashFile` |
48
+ | In-memory string | `hashString` |
49
+ | In-memory Buffer / Uint8Array | `hashBuffer` |
50
+
51
+ ## Hash in-memory content
52
+
53
+ ```ts
54
+ import { hashString, hashBuffer } from "@warlock.js/fs";
55
+
56
+ const stringDigest = hashString("hello world");
57
+ const bufferDigest = hashBuffer(Buffer.from([0x01, 0x02, 0x03]));
58
+ ```
59
+
60
+ Both sync, both default to SHA-256, both accept the algorithm override.
61
+
62
+ ## Common shapes
63
+
64
+ ### Cache key from request input
65
+
66
+ ```ts
67
+ import { hashString } from "@warlock.js/fs";
68
+
69
+ const key = `report.${hashString(JSON.stringify(filters))}`;
70
+ await cache.set(key, report, "1h");
71
+ ```
72
+
73
+ Stable, short, collision-resistant. JSON stringification is the gotcha — key order matters; sort keys if the input might arrive in different orders.
74
+
75
+ ### Bust a CDN cache when a build artifact changes
76
+
77
+ ```ts
78
+ import { hashFileAsync } from "@warlock.js/fs";
79
+
80
+ const digest = await hashFileAsync("./dist/bundle.js");
81
+ await renameFileAsync("./dist/bundle.js", `./dist/bundle.${digest.slice(0, 8)}.js`);
82
+ ```
83
+
84
+ 8 hex chars (≈32 bits) is enough for a single-app deployment — collisions on a per-build basis are vanishingly small.
85
+
86
+ ### Skip work if content hasn't changed
87
+
88
+ ```ts
89
+ import { hashFileAsync, fileExistsAsync, getFileAsync } from "@warlock.js/fs";
90
+
91
+ const inputDigest = await hashFileAsync("./input.json");
92
+ const cachedDigest = (await fileExistsAsync("./.last-input-digest"))
93
+ ? await getFileAsync("./.last-input-digest")
94
+ : null;
95
+
96
+ if (inputDigest === cachedDigest) {
97
+ return; // input unchanged — skip the expensive pipeline
98
+ }
99
+
100
+ await runPipeline();
101
+ await putFileAsync("./.last-input-digest", inputDigest);
102
+ ```
103
+
104
+ ### Compare two files for equality
105
+
106
+ ```ts
107
+ const same = (await hashFileAsync(a)) === (await hashFileAsync(b));
108
+ ```
109
+
110
+ Cheaper than `cmp`-byte-comparing two large files when the result is "yes/no different" — and you cache the digests for later comparisons against other candidates.
111
+
112
+ ## See also
113
+
114
+ - [`@warlock.js/fs/read-and-write-files/SKILL.md`](@warlock.js/fs/read-and-write-files/SKILL.md) — reading files before hashing in-memory content
115
+ - [`@warlock.js/cache/use-cached-hof/SKILL.md`](@warlock.js/cache/use-cached-hof/SKILL.md) — building cache keys from hashes
116
+
117
+ ## Things NOT to do
118
+
119
+ - Don't use MD5 or SHA-1 for security purposes (password hashing, signature verification). Both are broken cryptographically — they're fine for cache keys and non-adversarial fingerprinting, nothing more.
120
+ - Don't truncate the digest below 32 bits (8 hex chars) for collision-sensitive uses. Two builds with the same prefix do happen; full digest is 64 hex chars for SHA-256.
121
+ - Don't load a large file via `getFileAsync` and then `hashString` it — that defeats the streaming optimization. Use `hashFileAsync` directly.
122
+ - Don't expect digests to compare lexically meaningfully — they're random hex. For ordering, hash and then `bigint`-convert if you really need sort.
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: manage-directories
3
+ description: 'Manage directories + files — ensureDirectory (mkdir -p), list / listFiles / listDirectories, copyFile / copyDirectory, renameFile, unlink (ENOENT-safe), removeDirectory (recursive force). Triggers: `ensureDirectoryAsync`, `listAsync`, `listFilesAsync`, `listDirectoriesAsync`, `copyFileAsync`, `copyDirectoryAsync`, `renameFileAsync`, `unlinkAsync`, `removeDirectoryAsync`; "create directory recursively", "list files in folder", "delete directory recursively", "copy or rename folder", "walk a tree"; typical import `import { ensureDirectoryAsync, listFilesAsync, removeDirectoryAsync } from "@warlock.js/fs"`. Skip: file IO — `@warlock.js/fs/read-and-write-files/SKILL.md`; atomic writes — `@warlock.js/fs/write-atomically/SKILL.md`; competing libs `fs-extra`, `mkdirp`, `rimraf`, `recursive-readdir`, `glob`; native `node:fs/promises`.'
4
+ ---
5
+
6
+ # Manage directories and files on disk
7
+
8
+ Same two-suffix convention as the rest of `@warlock.js/fs` — `*Async` is async, the bare name is sync.
9
+
10
+ ## Ensure a directory exists
11
+
12
+ ```ts
13
+ import { ensureDirectoryAsync } from "@warlock.js/fs";
14
+
15
+ await ensureDirectoryAsync("./dist/cache/v2");
16
+ // recursively creates dist, dist/cache, dist/cache/v2 if missing
17
+ // no-op if everything already exists
18
+ ```
19
+
20
+ Idempotent. Pair it with anything that writes — though `putFileAsync` already does this internally, so you rarely need `ensureDirectory` for the immediate parent of a file you're about to write.
21
+
22
+ ## List children
23
+
24
+ Three variants:
25
+
26
+ ```ts
27
+ import { listAsync, listFilesAsync, listDirectoriesAsync } from "@warlock.js/fs";
28
+
29
+ await listAsync("./src"); // [files + subdirs] full paths
30
+ await listFilesAsync("./src"); // only regular files
31
+ await listDirectoriesAsync("./src"); // only directories
32
+ ```
33
+
34
+ Returns **full paths** (joined to the directory you passed in), not bare entry names. Pass them straight to other fs calls.
35
+
36
+ ```ts
37
+ const components = await listFilesAsync("./src/components");
38
+ for (const file of components) {
39
+ // file = "./src/components/Button.tsx"
40
+ await processComponent(file);
41
+ }
42
+ ```
43
+
44
+ Only the immediate children — non-recursive. Recurse yourself with the directory variant + a stack/queue if you need deep traversal.
45
+
46
+ ## Copy
47
+
48
+ ```ts
49
+ import { copyFileAsync, copyDirectoryAsync } from "@warlock.js/fs";
50
+
51
+ // File — creates the destination's parent directories
52
+ await copyFileAsync("./dist/bundle.js", "./snapshot/v2/bundle.js");
53
+
54
+ // Directory — fully recursive
55
+ await copyDirectoryAsync("./public", "./dist/public");
56
+ ```
57
+
58
+ `copyFile` creates the destination's parent directories. `copyDirectory` uses Node's recursive `cp` under the hood — preserves the tree, overwrites existing files.
59
+
60
+ ## Rename / move
61
+
62
+ ```ts
63
+ import { renameFileAsync } from "@warlock.js/fs";
64
+
65
+ await renameFileAsync("./tmp/foo.txt", "./final/foo.txt");
66
+ ```
67
+
68
+ Works on files and directories. Cross-device renames (e.g. `/tmp` → `/var`) may fail with `EXDEV` — the OS won't move across mounts. For cross-device, copy then delete.
69
+
70
+ ## Delete
71
+
72
+ ```ts
73
+ import { unlinkAsync, removeDirectoryAsync } from "@warlock.js/fs";
74
+
75
+ await unlinkAsync("./obsolete.txt"); // single file — ENOENT-safe
76
+ await removeDirectoryAsync("./dist"); // recursive + force — ENOENT-safe
77
+ ```
78
+
79
+ Both are **idempotent for missing targets** — calling on a path that doesn't exist is a no-op, not an error. Other errors (`EACCES`, `EBUSY`) still throw.
80
+
81
+ ## Picking a delete shape
82
+
83
+ | Task | Reach for |
84
+ | --- | --- |
85
+ | Drop one file | `unlinkAsync(path)` |
86
+ | Drop a whole tree | `removeDirectoryAsync(path)` |
87
+ | Drop everything in a folder but keep the folder | `for (const f of await listAsync(dir)) await removeDirectoryAsync(f)` (file? unlink; dir? recurse) |
88
+
89
+ ## Common shapes
90
+
91
+ ### Snapshot a build output
92
+
93
+ ```ts
94
+ import { ensureDirectoryAsync, copyDirectoryAsync, removeDirectoryAsync } from "@warlock.js/fs";
95
+
96
+ const target = `./snapshots/${Date.now()}`;
97
+ await ensureDirectoryAsync(target);
98
+ await copyDirectoryAsync("./dist", target);
99
+ ```
100
+
101
+ ### Reset a temp dir between runs
102
+
103
+ ```ts
104
+ await removeDirectoryAsync("./tmp");
105
+ await ensureDirectoryAsync("./tmp");
106
+ ```
107
+
108
+ ### Walk every TS file under src
109
+
110
+ ```ts
111
+ async function walk(dir: string): Promise<string[]> {
112
+ const entries = await listAsync(dir);
113
+ const results: string[] = [];
114
+
115
+ for (const entry of entries) {
116
+ if (await directoryExistsAsync(entry)) {
117
+ results.push(...(await walk(entry)));
118
+ } else if (entry.endsWith(".ts")) {
119
+ results.push(entry);
120
+ }
121
+ }
122
+
123
+ return results;
124
+ }
125
+
126
+ const tsFiles = await walk("./src");
127
+ ```
128
+
129
+ ## See also
130
+
131
+ - [`@warlock.js/fs/read-and-write-files/SKILL.md`](@warlock.js/fs/read-and-write-files/SKILL.md) — text / JSON file IO and existence checks
132
+ - [`@warlock.js/fs/write-atomically/SKILL.md`](@warlock.js/fs/write-atomically/SKILL.md) — atomic writes when concurrent readers might see the file mid-write
133
+ - [`@warlock.js/fs/hash-files/SKILL.md`](@warlock.js/fs/hash-files/SKILL.md) — fingerprinting
134
+
135
+ ## Things NOT to do
136
+
137
+ - Don't expect `listAsync` to recurse — it's intentionally one level. Write your own walker (see above) for deep traversal.
138
+ - Don't catch `ENOENT` on `unlink` / `removeDirectory` — both functions already swallow missing-target errors. If you're catching, you're handling a real error and should re-throw.
139
+ - Don't use `renameFile` across filesystems / mounts — `EXDEV` will surface. Copy + delete for cross-device.
140
+ - Don't list a directory you're concurrently modifying — readdir snapshots aren't atomic across concurrent writes.
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: overview
3
+ description: 'Front-door orientation for `@warlock.js/fs` — filesystem primitives (read/write/JSON, dirs, copy/rename/delete, atomic writes, hashing, existence + stats). Two-suffix convention: `*Async` returns Promise, bare name is sync. Single canonical name per operation — no aliases. TRIGGER when: code imports anything from `@warlock.js/fs`; user asks "what does @warlock.js/fs do", "is fs the right package for X", "list all fs helpers", "fs sync vs async convention"; package.json adds `@warlock.js/fs`; user is choosing between fs vs `node:fs/promises`/`fs-extra`/`graceful-fs`. Skip: specific task already known — load the matching task skill directly (`@warlock.js/fs/read-and-write-files/SKILL.md`, `@warlock.js/fs/manage-directories/SKILL.md`, `@warlock.js/fs/write-atomically/SKILL.md`, `@warlock.js/fs/hash-files/SKILL.md`); the user is using plain `node:fs` and not touching fs imports.'
4
+ ---
5
+
6
+ # `@warlock.js/fs` — overview
7
+
8
+ Thin, opinionated wrapper over `node:fs` and `node:fs/promises`. Same operations you'd write by hand against the Node primitives, but with consistent naming, parent-directory auto-creation, ENOENT-safe deletes, atomic writes, and streaming hashes — the boring-but-load-bearing utilities every backend grows by month two anyway.
9
+
10
+ ## When to reach for it
11
+
12
+ - You're inside a `@warlock.js/*` project — every other framework package already depends on this one. Use it for consistency.
13
+ - You're outside Warlock but want one import that gives you sane defaults (auto-mkdir on writes, idempotent deletes, streaming hashes, atomic config writes).
14
+ - You're choosing between `node:fs` and a wrapper — and want a small, opinionated surface (~40 exports) rather than the kitchen sink of `fs-extra` or the patching-Node behavior of `graceful-fs`.
15
+
16
+ Skip if your code path is one-off and bare `node:fs/promises` is already imported elsewhere — there's no value in adding a dependency for a single `readFile`.
17
+
18
+ ## Convention — read these once and you know the shape
19
+
20
+ - **`*Async` is async** (returns `Promise<…>`). Use these in server / runtime code.
21
+ - **Bare name is synchronous.** Use these in CLI tools, code generators, and one-shot scripts where blocking the loop doesn't matter.
22
+ - **One canonical name per operation.** No aliases (`unlinkAsync`, not `deleteAsync` or `removeAsync` for the same thing). Reach for the obvious name; if you don't find it, it doesn't exist.
23
+ - **Writes auto-create parent directories.** `putFileAsync("./a/b/c.txt", …)` works without a separate `ensureDirectory("./a/b")` first.
24
+ - **Deletes are ENOENT-safe.** `unlinkAsync` and `removeDirectoryAsync` no-op on missing targets. Other errors (`EACCES`, `EBUSY`) still throw.
25
+
26
+ ## Skills index
27
+
28
+ Four task skills cover the surface. Load the one that matches what you're trying to do — don't load all four unless you're touring the package.
29
+
30
+ ### [`read-and-write-files/SKILL.md`](@warlock.js/fs/read-and-write-files/SKILL.md)
31
+
32
+ Read and write text or JSON files; check existence and metadata. Covers
33
+ `getFile` / `getFileAsync` / `getJsonFile` / `getJsonFileAsync`,
34
+ `putFile` / `putFileAsync` / `putJsonFile` / `putJsonFileAsync`,
35
+ `pathExists` / `fileExists` / `directoryExists`,
36
+ `lastModified` / `stats`.
37
+
38
+ Load when reading or writing text or JSON, or gating creation on existence.
39
+
40
+ ### [`manage-directories/SKILL.md`](@warlock.js/fs/manage-directories/SKILL.md)
41
+
42
+ Create, list, copy, move, and delete directories and files. Covers
43
+ `ensureDirectory(Async)`, `list(Async)` / `listFiles(Async)` / `listDirectories(Async)`,
44
+ `copyFile(Async)` / `copyDirectory(Async)`, `renameFile(Async)`,
45
+ `unlink(Async)`, `removeDirectory(Async)`.
46
+
47
+ Load when scaffolding, walking trees, snapshotting, cleaning, or moving files around.
48
+
49
+ ### [`write-atomically/SKILL.md`](@warlock.js/fs/write-atomically/SKILL.md)
50
+
51
+ Write files so concurrent readers never see a half-written state. Covers
52
+ `atomicWriteAsync(path, content)` and `atomicWriteJsonAsync(path, value)`.
53
+ Sibling temp file + atomic rename — last-writer-wins on contention, no locking.
54
+
55
+ Load when writing a file that other processes / file watchers / build steps consume in parallel (config, manifest, state, lockfile).
56
+
57
+ ### [`hash-files/SKILL.md`](@warlock.js/fs/hash-files/SKILL.md)
58
+
59
+ Compute hex digests for files (streaming) or in-memory content. Covers
60
+ `hashFile(Async)` (streaming — constant memory),
61
+ `hashFileSmallAsync` (one-shot for small files),
62
+ `hashString`, `hashBuffer`, plus the `HashAlgorithm` type.
63
+ Defaults to SHA-256; supports SHA-1 / MD5 / SHA-512.
64
+
65
+ Load when fingerprinting for cache invalidation, content-addressable storage, change detection, or file-equality comparison. Never for security (password hashing, signing).
66
+
67
+ ## What this package deliberately doesn't do
68
+
69
+ - **Globbing.** Use `tinyglobby` / `fast-glob`. Adding a glob engine here would double the surface for one use case.
70
+ - **Watching.** Use `chokidar` or `node:fs.watch` directly. Watchers have their own lifecycle that doesn't fit the one-shot utility shape.
71
+ - **Permissions / chmod / chown.** Out of scope. Reach for `node:fs/promises`'s `chmod` / `chown` directly when you need them.
72
+ - **Streaming pipelines beyond hashing.** This isn't a general streams library; it's a wrapper for the common one-shot file operations.
73
+
74
+ ## See also
75
+
76
+ - [`@warlock.js/core/warlock-conventions/SKILL.md`](@warlock.js/core/warlock-conventions/SKILL.md) — the parent framework's conventions; `fs` is one of its foundation packages.
77
+ - `mongez-agent-kit-authoring-skills` (load via agent-kit sync) — how this `overview/SKILL.md` becomes the front-door skill in `.claude/skills/warlock-js-fs-overview/`.
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: read-and-write-files
3
+ description: 'Read and write files — getFile / getFileAsync / getJsonFile / putFile (auto-creates parent dirs), plus pathExists / fileExists / directoryExists / lastModified / stats. Triggers: `getFileAsync`, `getJsonFileAsync`, `putFileAsync`, `putJsonFileAsync`, `pathExists`, `fileExists`, `lastModifiedAsync`, `statsAsync`; "read a text file", "write a JSON file", "check if file exists"; typical import `import { getFileAsync, putJsonFileAsync, fileExists } from "@warlock.js/fs"`. Skip: atomic writes — `@warlock.js/fs/write-atomically/SKILL.md`; dirs + copy + delete — `@warlock.js/fs/manage-directories/SKILL.md`; hashing — `@warlock.js/fs/hash-files/SKILL.md`; competing libs `fs-extra`, `jsonfile`, `graceful-fs`; native `node:fs/promises`.'
4
+ ---
5
+
6
+ # Read and write files
7
+
8
+ Thin, opinionated wrapper around `node:fs` and `node:fs/promises`. Two-suffix convention: `*Async` returns a Promise, the bare name is synchronous. No `Sync` suffix on the sync calls — that would mean you have to remember the inverse.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ yarn add @warlock.js/fs
14
+ ```
15
+
16
+ ## Read
17
+
18
+ ```ts
19
+ import { getFile, getFileAsync, getJsonFile, getJsonFileAsync } from "@warlock.js/fs";
20
+
21
+ // UTF-8 text
22
+ const config = await getFileAsync("./config.toml");
23
+ const sync = getFile("./config.toml");
24
+
25
+ // Parsed JSON, generic-typed
26
+ type Manifest = { version: string; files: string[] };
27
+ const manifest = await getJsonFileAsync<Manifest>("./manifest.json");
28
+ ```
29
+
30
+ Behavior:
31
+ - All read functions return UTF-8 strings (or parsed JSON in the `JsonFile` variants).
32
+ - Throws if the file doesn't exist (`ENOENT`) or JSON is invalid. Don't try/catch for "file might not be there" — use `pathExists` / `fileExists` below.
33
+
34
+ ## Write
35
+
36
+ ```ts
37
+ import { putFile, putFileAsync, putJsonFile, putJsonFileAsync } from "@warlock.js/fs";
38
+
39
+ await putFileAsync("./dist/output.txt", "hello world");
40
+ await putJsonFileAsync("./dist/manifest.json", { version: "1.0.0" });
41
+ ```
42
+
43
+ Behavior:
44
+ - Parent directories are created recursively — no need to `ensureDirectory` first.
45
+ - JSON variants pretty-print at 2-space indent. For minified output, stringify yourself and use the plain `putFile`.
46
+ - Overwrites existing files. For atomic write semantics (readers never see a half-written file), use [`write-atomically`](@warlock.js/fs/write-atomically/SKILL.md).
47
+
48
+ ## Existence checks
49
+
50
+ Three variants — pick the strictest one that fits the question you're asking. Each has an async (`*Async`) and a sync form:
51
+
52
+ ```ts
53
+ import { pathExistsAsync, fileExistsAsync, directoryExistsAsync } from "@warlock.js/fs";
54
+
55
+ await pathExistsAsync("./anything"); // true if file OR directory
56
+ await fileExistsAsync("./config.toml"); // true ONLY if a regular file
57
+ await directoryExistsAsync("./dist"); // true ONLY if a directory
58
+
59
+ // sync counterparts, same semantics
60
+ import { pathExists, fileExists, directoryExists } from "@warlock.js/fs";
61
+ ```
62
+
63
+ Match the check to the target: gate a directory operation (listing, walking) on `directoryExistsAsync`, not `fileExistsAsync` — the latter resolves `false` for a folder and would skip it entirely. Reach for `pathExistsAsync` only when the type genuinely doesn't matter.
64
+
65
+ `fileExists*` and `directoryExists*` follow symlinks (they're `stat`-based, not `lstat`). Use them to gate creation logic instead of try-catching a `read`:
66
+
67
+ ```ts
68
+ // ✅ Clearer intent
69
+ if (!(await fileExists("./config.toml"))) {
70
+ await putFileAsync("./config.toml", defaultConfig);
71
+ }
72
+
73
+ // ❌ Don't catch ENOENT as control flow
74
+ try {
75
+ await getFileAsync("./config.toml");
76
+ } catch {
77
+ await putFileAsync("./config.toml", defaultConfig);
78
+ }
79
+ ```
80
+
81
+ ## Metadata
82
+
83
+ ```ts
84
+ import { lastModified, stats } from "@warlock.js/fs";
85
+
86
+ const mtime = await lastModifiedAsync("./bundle.js"); // Date
87
+ const all = await statsAsync("./bundle.js"); // fs.Stats
88
+ ```
89
+
90
+ `lastModified` is sugar around `stat().mtime`. Reach for `stats` when you need size, mode bits, or other fields.
91
+
92
+ ## When to pick sync vs async
93
+
94
+ - **Async by default** — everything in a Warlock server / app runtime should be async. The event loop stays free.
95
+ - **Sync only in CLI tools and config-loaders that run once** — startup config, code generators, scripts. The blocking call is fine when there's nothing else to do.
96
+
97
+ ## See also
98
+
99
+ - [`@warlock.js/fs/manage-directories/SKILL.md`](@warlock.js/fs/manage-directories/SKILL.md) — directory listing, copying, removing, renaming
100
+ - [`@warlock.js/fs/write-atomically/SKILL.md`](@warlock.js/fs/write-atomically/SKILL.md) — safe writes for files that other readers depend on
101
+ - [`@warlock.js/fs/hash-files/SKILL.md`](@warlock.js/fs/hash-files/SKILL.md) — fingerprinting files
102
+
103
+ ## Things NOT to do
104
+
105
+ - Don't call `putFileAsync` on a file that other processes / readers consume in parallel — use `atomicWriteAsync` from [`write-atomically`](@warlock.js/fs/write-atomically/SKILL.md) instead.
106
+ - Don't rely on `try { getFileAsync(...) } catch` for existence checks — `fileExists` is faster and reads better.
107
+ - Don't pass binary content to `putFile` / `putFileAsync` as a `Buffer` directly — these are text-only (UTF-8). For binaries, use `node:fs/promises`'s `writeFile` directly, or use `atomicWriteAsync` (which accepts `string | Buffer`).
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: write-atomically
3
+ description: 'Atomic file writes via atomicWriteAsync(path, content) — writes to a uniquely-named sibling temp + rename onto target so readers see old or complete new content, never half-written. Triggers: `atomicWriteAsync`, `atomicWriteJsonAsync`; "atomic file write", "write config file safely with concurrent readers", "manifest written by build step", "state file across runs", "avoid half-written files"; typical import `import { atomicWriteAsync, atomicWriteJsonAsync } from "@warlock.js/fs"`. Skip: plain writes — `@warlock.js/fs/read-and-write-files/SKILL.md`; read-modify-write locking — `@warlock.js/cache/use-cache-lock/SKILL.md`; competing libs `write-file-atomic`, `steno`, `fs-extra` `outputFile`.'
4
+ ---
5
+
6
+ # Atomic file writes
7
+
8
+ `atomicWriteAsync` is the safe replacement for `putFileAsync` when readers can see the file at any moment. Same parent-directory auto-creation; the difference is the write strategy.
9
+
10
+ ## Why use it
11
+
12
+ `putFileAsync` writes directly to the destination. If a reader picks the file up while you're partway through the write, they see truncated content. That's fine for ephemeral logs; not fine for:
13
+
14
+ - **Config files watched by a dev server / linter.** Half-written config makes the watcher emit a spurious error.
15
+ - **Manifests consumed by another process.** Two-process pipelines deserialize and crash on partial JSON.
16
+ - **State files between runs of the same script.** A crash mid-write leaves you with a corrupt file you can't read on the next run.
17
+
18
+ `atomicWriteAsync` writes to a uniquely-named sibling temp file first, then `rename`s it onto the target. On most filesystems the rename is atomic — readers see the old content, then the new content, never anything in between.
19
+
20
+ ## Shape
21
+
22
+ ```ts
23
+ import { atomicWriteAsync, atomicWriteJsonAsync } from "@warlock.js/fs";
24
+
25
+ await atomicWriteAsync("./config.toml", configString);
26
+ await atomicWriteAsync("./binary.bin", Buffer.from([0x01, 0x02])); // accepts string OR Buffer
27
+
28
+ // JSON sugar — pretty-prints at 2-space indent
29
+ await atomicWriteJsonAsync("./manifest.json", { version: "1.0.0", files: [...] });
30
+ ```
31
+
32
+ ## What happens internally
33
+
34
+ ```
35
+ 1. mkdir(dir, recursive)
36
+ 2. tempPath = `${dir}/.${name}.${randomHex(6)}.tmp` ← unique sibling temp
37
+ 3. writeFile(tempPath, content)
38
+ 4. rename(tempPath, filePath) ← atomic on POSIX, near-atomic on NTFS
39
+ on failure: unlink(tempPath)
40
+ ```
41
+
42
+ The random 6-byte suffix prevents two concurrent writers from racing on the same temp file. The temp file lives in the **same directory** as the target so the rename is intra-mount (cross-mount rename would fall back to copy + unlink, which isn't atomic).
43
+
44
+ ## Concurrent writers
45
+
46
+ Two `atomicWriteAsync` calls to the same target serialize at the rename. Whichever rename completes last wins. **No locking** — last-writer-wins is the contract.
47
+
48
+ If you need read-modify-write atomicity (each writer sees the previous writer's result), wrap the calls in a distributed lock — e.g. [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md).
49
+
50
+ ## Common shapes
51
+
52
+ ### State file written across multiple runs
53
+
54
+ ```ts
55
+ // On every successful run
56
+ await atomicWriteJsonAsync("./.cache/last-run.json", {
57
+ finishedAt: new Date().toISOString(),
58
+ buildId: process.env.BUILD_ID,
59
+ });
60
+ ```
61
+
62
+ Crash partway through? Either the file has the previous run's content or the new run's content. Never garbage.
63
+
64
+ ### Manifest emitted by a build step
65
+
66
+ ```ts
67
+ const manifest = computeManifest(files);
68
+ await atomicWriteJsonAsync("./dist/manifest.json", manifest);
69
+ ```
70
+
71
+ A reader (CDN purge script, deployment tool) that picks up `dist/manifest.json` while the build is mid-write doesn't crash.
72
+
73
+ ### Config file watched by a dev server
74
+
75
+ ```ts
76
+ const config = transformConfig(input);
77
+ await atomicWriteAsync("./config.toml", config);
78
+ ```
79
+
80
+ The dev server's file watcher fires once after the rename, sees complete content. No double-event or partial-content noise.
81
+
82
+ ## What it doesn't protect against
83
+
84
+ - **Filesystem corruption.** Power-loss between `writeFile` and `rename` leaves the temp file behind — that's `fsync` territory, not handled here. For ironclad durability, you'd need a `writeFile + fsync + rename + fsync(parent)` sequence; this helper skips the fsyncs for write speed.
85
+ - **Cross-filesystem renames.** If `dir` is a different mount from the target's actual storage (unusual), `rename` may fall back to copy + delete, which isn't atomic. Keep the temp on the same mount — the helper does this automatically.
86
+ - **Race conditions in your callers.** `atomicWriteAsync` makes the file write atomic; it doesn't serialize callers. Two callers stomping each other is your problem, not the helper's.
87
+
88
+ ## See also
89
+
90
+ - [`@warlock.js/fs/read-and-write-files/SKILL.md`](@warlock.js/fs/read-and-write-files/SKILL.md) — `putFileAsync` for non-atomic writes
91
+ - [`@warlock.js/cache/use-cache-lock/SKILL.md`](@warlock.js/cache/use-cache-lock/SKILL.md) — distributed lock for read-modify-write protection
92
+
93
+ ## Things NOT to do
94
+
95
+ - Don't use `atomicWriteAsync` when you don't need atomicity — `putFileAsync` is slightly faster (no rename round-trip). For ephemeral files, plain write is fine.
96
+ - Don't store the temp file outside the target directory. The helper picks the same dir on purpose so the rename is intra-mount; if you reach inside the source and change that, you lose the atomicity guarantee on cross-mount setups.
97
+ - Don't pair atomic writes with locked reads expecting consistency. A reader between the rename and your next write sees the intermediate complete state — that's the point of the helper. If you want every read to see a particular write, serialize with a lock.
98
+ - Don't sync after atomic write expecting "definitely persisted to disk" — the helper doesn't fsync. For durability guarantees, fsync the parent directory after the rename yourself.