@williamthorsen/toolbelt.filesystem 0.4.0 → 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 +23 -0
- package/README.md +54 -15
- package/dist/esm/1-proposed/index.d.ts +1 -1
- package/dist/esm/1-proposed/index.js +1 -1
- package/dist/esm/1-proposed/writeAtomic.d.ts +1 -0
- package/dist/esm/1-proposed/writeAtomic.js +32 -0
- package/dist/esm/{1-proposed → 3-candidate}/createTempTree.d.ts +4 -1
- package/dist/esm/{1-proposed → 3-candidate}/createTempTree.js +12 -2
- package/dist/esm/3-candidate/index.d.ts +1 -1
- package/dist/esm/3-candidate/index.js +1 -1
- package/package.json +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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
|
+
|
|
19
|
+
## 0.4.1 — 2026-08-13
|
|
20
|
+
|
|
21
|
+
### Tooling
|
|
22
|
+
|
|
23
|
+
- Remove redundant .gitignore files
|
|
24
|
+
- Populate manifest metadata and adopt a pnpm catalog (#140)
|
|
25
|
+
|
|
26
|
+
Adopts a pnpm catalog to avoid specifying the version of a common dependency in multiple places. Separately, fixes violations of newly activated `package-json` lint rules. Missing values have been added to `package.json` fields across the repo, and package descriptions are improved.
|
|
27
|
+
|
|
5
28
|
## 0.4.0 — 2026-08-12
|
|
6
29
|
|
|
7
30
|
### Features
|
package/README.md
CHANGED
|
@@ -3,23 +3,19 @@
|
|
|
3
3
|
Filesystem utilities for TypeScript and JavaScript.
|
|
4
4
|
|
|
5
5
|
<!-- section:release-notes -->
|
|
6
|
-
## Release notes — v0.
|
|
6
|
+
## Release notes — v0.5.0 (2026-08-15)
|
|
7
7
|
|
|
8
8
|
### Features
|
|
9
9
|
|
|
10
|
-
- 🚨 **Breaking:**
|
|
10
|
+
- 🚨 **Breaking:** Promote createTempTree to the candidate tier with a caller-chosen prefix and binary entries (#148)
|
|
11
11
|
|
|
12
|
-
|
|
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
13
|
|
|
14
|
-
|
|
14
|
+
Migration: `createTempTree` and `TempTree` are imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than `/proposed`.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
- Add `writeAtomic`, an atomic file-write utility (#154)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Adds `reconcileFileFromFile` to `@williamthorsen/toolbelt.filesystem`. It reads a source path as utf8 text and reconciles a destination against it, sharing `reconcileFile`'s options, outcome vocabulary, and `FileReconciliation` result type.
|
|
21
|
-
|
|
22
|
-
A source that cannot be read reports `failed` with a reason naming the source and the cause, rather than throwing. Because the outcome depends on the source's content, the read happens even under `isDryRun`, so a dry run can report `failed` where `reconcileFile`'s cannot.
|
|
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.
|
|
23
19
|
<!-- /section:release-notes -->
|
|
24
20
|
|
|
25
21
|
## Installation
|
|
@@ -30,7 +26,7 @@ pnpm add @williamthorsen/toolbelt.filesystem
|
|
|
30
26
|
|
|
31
27
|
## Runtime requirements
|
|
32
28
|
|
|
33
|
-
`createTempTree`, `findDirectoryChainMatch`, `listDirectoryChainMatches`, `loadConfigCascade`, `reconcileFile`, and `
|
|
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.
|
|
34
30
|
|
|
35
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.
|
|
36
32
|
|
|
@@ -116,7 +112,7 @@ findDirectoryChainMatch('/home/dev/app/src', ['.git']);
|
|
|
116
112
|
// { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }
|
|
117
113
|
```
|
|
118
114
|
|
|
119
|
-
Probing stops at the first level that matches, so no level beyond it is touched
|
|
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.
|
|
120
116
|
|
|
121
117
|
## `loadConfigCascade`
|
|
122
118
|
|
|
@@ -259,16 +255,16 @@ The source is read even under `isDryRun`, because the outcome depends on compari
|
|
|
259
255
|
|
|
260
256
|
## `createTempTree`
|
|
261
257
|
|
|
262
|
-
|
|
258
|
+
Candidate tier: imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than the package root, and subject to change.
|
|
263
259
|
|
|
264
260
|
```ts
|
|
265
|
-
createTempTree(entries: Record<string, string
|
|
261
|
+
createTempTree(entries: Record<string, string | Uint8Array>, options?: { prefix?: string }): TempTree;
|
|
266
262
|
```
|
|
267
263
|
|
|
268
264
|
Builds a throwaway directory tree and returns a handle that removes it when the binding leaves scope:
|
|
269
265
|
|
|
270
266
|
```ts
|
|
271
|
-
import { createTempTree } from '@williamthorsen/toolbelt.filesystem/
|
|
267
|
+
import { createTempTree } from '@williamthorsen/toolbelt.filesystem/candidate';
|
|
272
268
|
|
|
273
269
|
{
|
|
274
270
|
using tree = createTempTree({
|
|
@@ -284,6 +280,21 @@ import { createTempTree } from '@williamthorsen/toolbelt.filesystem/proposed';
|
|
|
284
280
|
|
|
285
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.
|
|
286
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
|
+
|
|
287
298
|
```ts
|
|
288
299
|
interface TempTree extends Disposable {
|
|
289
300
|
readonly dir: string;
|
|
@@ -326,3 +337,31 @@ replaceFileExtension('src/main.d.ts', '.js', { oldExtension: '.d.ts' }); // 'src
|
|
|
326
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.
|
|
327
338
|
|
|
328
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.
|
|
@@ -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
|
|
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
|
|
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,8 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/toolbelt.filesystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Filesystem utilities",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"config-cascade",
|
|
7
|
+
"esm",
|
|
8
|
+
"filesystem",
|
|
9
|
+
"fs",
|
|
10
|
+
"path",
|
|
11
|
+
"toolbelt",
|
|
12
|
+
"typescript",
|
|
13
|
+
"utilities"
|
|
14
|
+
],
|
|
6
15
|
"homepage": "https://github.com/williamthorsen/toolbelt/tree/main/packages/filesystem#readme",
|
|
7
16
|
"bugs": {
|
|
8
17
|
"url": "https://github.com/williamthorsen/toolbelt/issues"
|
|
@@ -14,6 +23,7 @@
|
|
|
14
23
|
},
|
|
15
24
|
"license": "ISC",
|
|
16
25
|
"author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
|
|
26
|
+
"sideEffects": false,
|
|
17
27
|
"type": "module",
|
|
18
28
|
"exports": {
|
|
19
29
|
".": {
|
|
@@ -34,7 +44,7 @@
|
|
|
34
44
|
"CHANGELOG.md"
|
|
35
45
|
],
|
|
36
46
|
"dependencies": {
|
|
37
|
-
"@williamthorsen/toolbelt.errors": "0.
|
|
47
|
+
"@williamthorsen/toolbelt.errors": "0.5.0"
|
|
38
48
|
},
|
|
39
49
|
"engines": {
|
|
40
50
|
"node": ">=24.0.0"
|