@williamthorsen/toolbelt.filesystem 0.4.1 → 0.5.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.5.0 — 2026-08-15
6
+
7
+ ### Features
8
+
9
+ - 🚨 **Breaking:** Promote createTempTree to the candidate tier with a caller-chosen prefix and binary entries (#148)
10
+
11
+ Promotes `createTempTree` and `TempTree` to `@williamthorsen/toolbelt.filesystem/candidate`, adding two capabilities as they move: a caller-chosen prefix for the temporary directory's name, and file contents given as bytes. A prefix that would place the tree anywhere but directly inside the system temporary directory is rejected before anything is created.
12
+
13
+ Migration: `createTempTree` and `TempTree` are imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than `/proposed`.
14
+
15
+ - Add `writeAtomic`, an atomic file-write utility (#154)
16
+
17
+ Adds `writeAtomic` to `@williamthorsen/toolbelt.filesystem`, exported from the `/proposed` subpath. It stages content in a temp file beside the target and renames over it, so a concurrent reader sees either the previous file or the complete new one.
18
+
5
19
  ## 0.4.1 — 2026-08-13
6
20
 
7
21
  ### Tooling
package/README.md CHANGED
@@ -2,7 +2,21 @@
2
2
 
3
3
  Filesystem utilities for TypeScript and JavaScript.
4
4
 
5
- <!-- section:release-notes --><!-- /section:release-notes -->
5
+ <!-- section:release-notes -->
6
+ ## Release notes — v0.5.0 (2026-08-15)
7
+
8
+ ### Features
9
+
10
+ - 🚨 **Breaking:** Promote createTempTree to the candidate tier with a caller-chosen prefix and binary entries (#148)
11
+
12
+ Promotes `createTempTree` and `TempTree` to `@williamthorsen/toolbelt.filesystem/candidate`, adding two capabilities as they move: a caller-chosen prefix for the temporary directory's name, and file contents given as bytes. A prefix that would place the tree anywhere but directly inside the system temporary directory is rejected before anything is created.
13
+
14
+ Migration: `createTempTree` and `TempTree` are imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than `/proposed`.
15
+
16
+ - Add `writeAtomic`, an atomic file-write utility (#154)
17
+
18
+ Adds `writeAtomic` to `@williamthorsen/toolbelt.filesystem`, exported from the `/proposed` subpath. It stages content in a temp file beside the target and renames over it, so a concurrent reader sees either the previous file or the complete new one.
19
+ <!-- /section:release-notes -->
6
20
 
7
21
  ## Installation
8
22
 
@@ -12,7 +26,7 @@ pnpm add @williamthorsen/toolbelt.filesystem
12
26
 
13
27
  ## Runtime requirements
14
28
 
15
- `createTempTree`, `findDirectoryChainMatch`, `listDirectoryChainMatches`, `loadConfigCascade`, `reconcileFile`, and `reconcileFileFromFile` reach the filesystem through `node:` builtins, so they run under Node.js 24 or later, Bun, and Deno. They do not run in browsers, nor in edge runtimes that expose no filesystem. `listDirectoryChain` and `replaceFileExtension` touch no filesystem, so an edge runtime that exposes none runs them; they still import `node:path`, which a browser bundle has to supply.
29
+ `createTempTree`, `findDirectoryChainMatch`, `listDirectoryChainMatches`, `loadConfigCascade`, `reconcileFile`, `reconcileFileFromFile`, and `writeAtomic` reach the filesystem through `node:` builtins, so they run under Node.js 24 or later, Bun, and Deno. They do not run in browsers, nor in edge runtimes that expose no filesystem. `listDirectoryChain` and `replaceFileExtension` touch no filesystem, so an edge runtime that exposes none runs them; they still import `node:path`, which a browser bundle has to supply.
16
30
 
17
31
  `loadConfigCascade` imports each config through the host runtime, so a `.ts` config is subject to whatever that runtime does with TypeScript. Node strips types rather than compiling them, which admits erasable syntax alone: an `enum`, a `namespace`, or a parameter property in a config file fails to parse. A `.mjs` or `.js` config sidesteps the question.
18
32
 
@@ -98,7 +112,7 @@ findDirectoryChainMatch('/home/dev/app/src', ['.git']);
98
112
  // { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }
99
113
  ```
100
114
 
101
- Probing stops at the first level that matches, so no level beyond it is touched the reason to reach for this rather than read element zero off `listDirectoryChainMatches`, which probes to the ceiling regardless. The nullable return type is the other reason: a result that may be absent says so, where an array leaves the caller to narrow.
115
+ Probing stops at the first level that matches, so no level beyond it is touched -- the reason to reach for this rather than read element zero off `listDirectoryChainMatches`, which probes to the ceiling regardless. The nullable return type is the other reason: a result that may be absent says so, where an array leaves the caller to narrow.
102
116
 
103
117
  ## `loadConfigCascade`
104
118
 
@@ -241,16 +255,16 @@ The source is read even under `isDryRun`, because the outcome depends on compari
241
255
 
242
256
  ## `createTempTree`
243
257
 
244
- Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
258
+ Candidate tier: imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than the package root, and subject to change.
245
259
 
246
260
  ```ts
247
- createTempTree(entries: Record<string, string>): TempTree;
261
+ createTempTree(entries: Record<string, string | Uint8Array>, options?: { prefix?: string }): TempTree;
248
262
  ```
249
263
 
250
264
  Builds a throwaway directory tree and returns a handle that removes it when the binding leaves scope:
251
265
 
252
266
  ```ts
253
- import { createTempTree } from '@williamthorsen/toolbelt.filesystem/proposed';
267
+ import { createTempTree } from '@williamthorsen/toolbelt.filesystem/candidate';
254
268
 
255
269
  {
256
270
  using tree = createTempTree({
@@ -266,6 +280,21 @@ import { createTempTree } from '@williamthorsen/toolbelt.filesystem/proposed';
266
280
 
267
281
  Each key of `entries` is a path relative to the tree root. One ending in `/` becomes a directory; any other becomes a file holding the mapped contents, with its intermediate directories created for it. A key resolving outside the root is rejected, and a call that throws leaves nothing on disk.
268
282
 
283
+ A value is text or the bytes themselves, so a body no UTF-8 round trip survives is as writable as a string:
284
+
285
+ ```ts
286
+ using tree = createTempTree({ 'logo.png': pngBytes });
287
+ ```
288
+
289
+ `prefix` names the directory built under the system temporary directory, defaulting to `toolbelt-`. Set it to whatever is doing the building, so a tree outliving a crashed run says what made it:
290
+
291
+ ```ts
292
+ using tree = createTempTree({}, { prefix: 'rdy-tsconfig-' });
293
+ tree.dir; // '/private/var/folders/.../rdy-tsconfig-a1b2c3'
294
+ ```
295
+
296
+ A prefix that would place the tree anywhere but directly inside the system temporary directory is rejected before anything is created. `mkdtemp` appends its random suffix to the joined path as given, so a prefix holding `/` or `\` targets a nested directory that has to already exist, or, where it ascends, a directory outside the temporary one; and a prefix that normalizes away (`''`, `'.'`, or `'..'`) lands the suffix beside the temporary directory rather than within it.
297
+
269
298
  ```ts
270
299
  interface TempTree extends Disposable {
271
300
  readonly dir: string;
@@ -308,3 +337,31 @@ replaceFileExtension('src/main.d.ts', '.js', { oldExtension: '.d.ts' }); // 'src
308
337
  Which extension is meant is genuinely ambiguous, since `archive.tar.gz` could reasonably end in `.gz` or in `.tar.gz`, so the caller declares it rather than the function guessing.
309
338
 
310
339
  Two inputs throw rather than returning a path that would quietly be wrong: a `filePath` ending in a separator, which names a directory rather than a file, and a `filePath` that does not end with a declared `oldExtension`.
340
+
341
+ ## `writeAtomic`
342
+
343
+ Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
344
+
345
+ ```ts
346
+ writeAtomic(filePath: string, content: string | Uint8Array): Promise<void>;
347
+ ```
348
+
349
+ Writes `content` to `filePath` through a temp file and a rename, so a concurrent reader sees either the previous file or the complete new one, never a partial write:
350
+
351
+ ```ts
352
+ import { writeAtomic } from '@williamthorsen/toolbelt.filesystem/proposed';
353
+
354
+ await writeAtomic('.agents/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
355
+ ```
356
+
357
+ The temp file is a sibling of the target, which is the part a hand-rolled copy most often gets wrong: `rename` is atomic only within one filesystem, so a temp file staged under the system temporary directory fails with `EXDEV` the moment the target lives on another volume. Its name is dot-prefixed and carries a random component, so it stays out of `*` globs and two processes writing the same target do not collide.
358
+
359
+ Missing parent directories are created, as they are for [`reconcileFile`](#reconcilefile).
360
+
361
+ An existing target's permission bits are carried onto the replacement. A plain `writeFile` truncates the file in place and so preserves its mode, while a rename replaces the inode and would otherwise reset it to the platform default; without this, swapping a plain write for an atomic one would silently widen a `0o600` file to world-readable. A target that does not exist yet gets the platform default, exactly as a plain write would.
362
+
363
+ Three behaviors are worth knowing before they surprise you:
364
+
365
+ - Nothing is fsynced. "Atomic" here means no torn reads, not survives-power-loss: a write this function has returned from can still be lost to a power failure. A durability option is additive if a caller ever needs one.
366
+ - A symlink at `filePath` is replaced by a regular file rather than written through, because the rename replaces the target's directory entry. The link's former target is left untouched.
367
+ - A failure removes the temp file best-effort and rethrows the error that caused it, never the cleanup's own. Where the cleanup also fails, the temp file survives beside the target under its dot-prefixed name ending in `.tmp`, which is where to look for one.
@@ -1,2 +1,2 @@
1
- export { createTempTree, type TempTree } from './createTempTree.js';
2
1
  export { replaceFileExtension, type ReplaceFileExtensionOptions } from './replaceFileExtension.js';
2
+ export { writeAtomic } from './writeAtomic.js';
@@ -1,2 +1,2 @@
1
- export { createTempTree } from "./createTempTree.js";
2
1
  export { replaceFileExtension } from "./replaceFileExtension.js";
2
+ export { writeAtomic } from "./writeAtomic.js";
@@ -0,0 +1 @@
1
+ export declare function writeAtomic(filePath: string, content: string | Uint8Array): Promise<void>;
@@ -0,0 +1,32 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export async function writeAtomic(filePath, content) {
5
+ const dir = path.dirname(filePath);
6
+ await fs.mkdir(dir, { recursive: true });
7
+ const mode = await findFileMode(filePath);
8
+ const tempPath = path.join(dir, `.${path.basename(filePath)}.${randomBytes(8).toString('hex')}.tmp`);
9
+ try {
10
+ await fs.writeFile(tempPath, content, { mode });
11
+ if (mode !== undefined)
12
+ await fs.chmod(tempPath, mode);
13
+ await fs.rename(tempPath, filePath);
14
+ }
15
+ catch (error) {
16
+ try {
17
+ await fs.rm(tempPath, { force: true });
18
+ }
19
+ catch {
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ async function findFileMode(filePath) {
25
+ try {
26
+ const stats = await fs.stat(filePath);
27
+ return stats.mode & 0o777;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
@@ -1,4 +1,7 @@
1
- export declare function createTempTree(entries: Record<string, string>): TempTree;
1
+ export declare function createTempTree(entries: Record<string, string | Uint8Array>, options?: CreateTempTreeOptions): TempTree;
2
+ export interface CreateTempTreeOptions {
3
+ prefix?: string;
4
+ }
2
5
  export interface TempTree extends Disposable {
3
6
  readonly dir: string;
4
7
  resolve(...segments: string[]): string;
@@ -1,8 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- export function createTempTree(entries) {
5
- const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbelt-')));
4
+ export function createTempTree(entries, options = {}) {
5
+ const { prefix = 'toolbelt-' } = options;
6
+ assertNamesDirectChild(prefix);
7
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
6
8
  try {
7
9
  for (const [entry, contents] of Object.entries(entries)) {
8
10
  const entryPath = resolveWithinTree(dir, [entry]);
@@ -29,6 +31,14 @@ export function createTempTree(entries) {
29
31
  },
30
32
  };
31
33
  }
34
+ function assertNamesDirectChild(prefix) {
35
+ if (prefix.includes('/') || prefix.includes('\\')) {
36
+ throw new Error(`Temporary-directory prefix "${prefix}" contains a path separator`);
37
+ }
38
+ if (['', '.', '..'].includes(prefix)) {
39
+ throw new Error(`Temporary-directory prefix "${prefix}" names no new directory`);
40
+ }
41
+ }
32
42
  function resolveWithinTree(dir, segments) {
33
43
  const target = path.resolve(dir, ...segments);
34
44
  if (target !== dir && !target.startsWith(dir + path.sep)) {
@@ -1 +1 @@
1
- export {};
1
+ export { createTempTree, type CreateTempTreeOptions, type TempTree } from './createTempTree.js';
@@ -1 +1 @@
1
- export {};
1
+ export { createTempTree } from "./createTempTree.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/toolbelt.filesystem",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Filesystem utilities",
5
5
  "keywords": [
6
6
  "config-cascade",
@@ -44,7 +44,7 @@
44
44
  "CHANGELOG.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@williamthorsen/toolbelt.errors": "0.4.0"
47
+ "@williamthorsen/toolbelt.errors": "0.5.0"
48
48
  },
49
49
  "engines": {
50
50
  "node": ">=24.0.0"