@williamthorsen/toolbelt.filesystem 0.5.0 → 0.7.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 +28 -0
- package/README.md +67 -7
- package/dist/esm/3-candidate/createTempTree.d.ts +10 -0
- package/dist/esm/3-candidate/createTempTree.js +91 -12
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.7.0 — 2026-08-21
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- 🚨 **Breaking:** Fix createTempTree's symlink guard and disposal, and complete its entry API (#207)
|
|
10
|
+
|
|
11
|
+
Fixes an issue where `TempTree.symlink` in `@williamthorsen/toolbelt.filesystem/candidate` refused a target outside the tree and rewrote a relative target to an absolute path inside it. The containment check now applies to the link path alone and the target is stored as given, so a link reads back as the string that was passed.
|
|
12
|
+
|
|
13
|
+
Separately, fixes an issue where disposing a `TempTree` left the tree on disk when one of its directories had been made read-only.
|
|
14
|
+
|
|
15
|
+
Adds six methods to `TempTree`. `writeAll` applies the constructor's map of entries to a tree already built; `exists`, `list`, `read`, `readJson`, and `rm` read the tree back and remove from it, so a suite scaffolding through the handle reads its own fixture through it rather than reaching for `node:fs`.
|
|
16
|
+
|
|
17
|
+
Migration: A caller relying on the previous absolute storage passes `tree.resolve(target)`, which is written unchanged.
|
|
18
|
+
|
|
19
|
+
## 0.6.0 — 2026-08-16
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- Add mkdir, symlink, write, and writeJson methods to `TempTree` (#176)
|
|
24
|
+
|
|
25
|
+
Adds four write methods to `TempTree` in `@williamthorsen/toolbelt.filesystem`: `mkdir`, `symlink`, `write`, and `writeJson`. Each takes a tree-relative path, creates the parent directories it needs, resolves through the same containment check `resolve` applies, and returns the absolute path, so a suite writing into a built temporary tree reaches it through the handle rather than through `node:fs`. `symlink` accepts a link path and a target, and picks the link type from the target.
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- Drop expect-type in favor of expectTypeOf (#175)
|
|
30
|
+
|
|
31
|
+
Replaces all imports of `expectTypeOf` from `expect-type` with the same import from `vitest`. Previously there had been imports from both libraries. `expect-type` is removed as a dependency.
|
|
32
|
+
|
|
5
33
|
## 0.5.0 — 2026-08-15
|
|
6
34
|
|
|
7
35
|
### Features
|
package/README.md
CHANGED
|
@@ -3,19 +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.7.0 (2026-08-21)
|
|
7
7
|
|
|
8
8
|
### Features
|
|
9
9
|
|
|
10
|
-
- 🚨 **Breaking:**
|
|
10
|
+
- 🚨 **Breaking:** Fix createTempTree's symlink guard and disposal, and complete its entry API (#207)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Fixes an issue where `TempTree.symlink` in `@williamthorsen/toolbelt.filesystem/candidate` refused a target outside the tree and rewrote a relative target to an absolute path inside it. The containment check now applies to the link path alone and the target is stored as given, so a link reads back as the string that was passed.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Separately, fixes an issue where disposing a `TempTree` left the tree on disk when one of its directories had been made read-only.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Adds six methods to `TempTree`. `writeAll` applies the constructor's map of entries to a tree already built; `exists`, `list`, `read`, `readJson`, and `rm` read the tree back and remove from it, so a suite scaffolding through the handle reads its own fixture through it rather than reaching for `node:fs`.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Migration: A caller relying on the previous absolute storage passes `tree.resolve(target)`, which is written unchanged.
|
|
19
19
|
<!-- /section:release-notes -->
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
@@ -298,7 +298,17 @@ A prefix that would place the tree anywhere but directly inside the system tempo
|
|
|
298
298
|
```ts
|
|
299
299
|
interface TempTree extends Disposable {
|
|
300
300
|
readonly dir: string;
|
|
301
|
+
exists(entryPath: string): boolean;
|
|
302
|
+
list(entryPath?: string): string[];
|
|
303
|
+
mkdir(entryPath: string): string;
|
|
304
|
+
read(entryPath: string): string;
|
|
305
|
+
readJson(entryPath: string): unknown;
|
|
301
306
|
resolve(...segments: string[]): string;
|
|
307
|
+
rm(entryPath: string): void;
|
|
308
|
+
symlink(linkPath: string, targetPath: string): string;
|
|
309
|
+
write(entryPath: string, contents: string | Uint8Array): string;
|
|
310
|
+
writeAll(entries: Record<string, string | Uint8Array>): void;
|
|
311
|
+
writeJson(entryPath: string, value: unknown): string;
|
|
302
312
|
}
|
|
303
313
|
```
|
|
304
314
|
|
|
@@ -306,7 +316,57 @@ interface TempTree extends Disposable {
|
|
|
306
316
|
|
|
307
317
|
`resolve` joins `segments` against the root and throws when the result would fall outside it, so a stray `..` fails loudly rather than reaching into the enclosing directory. An absolute segment landing inside the root is returned unchanged. The containment test is lexical, so it does not follow a symlink inside the tree that points out of it.
|
|
308
318
|
|
|
309
|
-
|
|
319
|
+
`mkdir`, `symlink`, `write`, `writeAll`, and `writeJson` write into the tree after it is built, for a fixture that varies per test or a file created to trigger a re-read:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
using tree = createTempTree({ 'packages/app/package.json': '{ "name": "app" }' });
|
|
323
|
+
|
|
324
|
+
tree.write('packages/app/src/main.ts', 'export {};\n'); // '/private/var/folders/.../packages/app/src/main.ts'
|
|
325
|
+
tree.writeJson('tsconfig.json', { include: ['src'] });
|
|
326
|
+
tree.mkdir('packages/empty');
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Each creates the parent directories it needs, resolves through the same containment check as `resolve`, and returns the absolute path of what it wrote. `symlink`'s link path is checked; its target is not, being a string the link holds rather than a location the tree writes to.
|
|
330
|
+
|
|
331
|
+
`writeAll` takes the same map the constructor takes, `/`-suffix convention included, so a fixture built in one call can be added to in one call:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
tree.writeAll({ 'packages/empty/': '', 'packages/app/src/main.ts': 'export {};\n' });
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
It returns nothing, there being no single path to return, and unlike the constructor it is not atomic: a failure part-way leaves the entries already written in place, there being no whole tree to discard.
|
|
338
|
+
|
|
339
|
+
They part company on an entry that already exists: `write` replaces it, `mkdir` leaves it and its contents alone, and `symlink` raises `EEXIST`.
|
|
340
|
+
|
|
341
|
+
`symlink` takes the link first and the target second, inverting `fs.symlinkSync`, so that it reads like the other methods: the path being created leads. The target is stored verbatim, so it may be absolute or relative, name something outside the tree, or dangle until the target appears; a relative one resolves against the link's own directory, as POSIX resolves it. Code under test that reads a link rather than following it therefore sees the string that was passed, which is what a consumer hashing a link's target depends on.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
using tree = createTempTree({ 'store/kit/package.json': '{ "name": "kit" }' });
|
|
345
|
+
|
|
346
|
+
tree.symlink('node_modules/kit', '../store/kit'); // reads back as '../store/kit'
|
|
347
|
+
tree.symlink('node_modules/.bin', tree.resolve('store/kit/bin')); // reads back absolute
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The link type is chosen from the target, which is where the one portability difference lives. An absolute directory target is linked as a junction, which Windows creates without the elevation a directory symlink needs; a relative directory target is linked as a directory, which needs that elevation, because Node normalizes a junction's target to an absolute path and would discard the relative string. Every other target, one that does not exist included, is linked as a file, matching what Node falls back to when no type is given.
|
|
351
|
+
|
|
352
|
+
`exists`, `list`, `read`, `readJson`, and `rm` read the tree back and remove from it, each through the same containment check:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
using tree = createTempTree({ 'packages/app/package.json': '{ "name": "app" }', 'packages/app/src/main.ts': 'export {};\n' });
|
|
356
|
+
|
|
357
|
+
tree.list(); // ['packages'], defaulting to the tree root
|
|
358
|
+
tree.list('packages/app'); // ['package.json', 'src'], sorted
|
|
359
|
+
tree.read('packages/app/src/main.ts'); // 'export {};\n'
|
|
360
|
+
tree.readJson('packages/app/package.json'); // unknown, for the caller to narrow
|
|
361
|
+
tree.exists('packages/app/tsconfig.json'); // false
|
|
362
|
+
tree.rm('packages/app');
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
`read` returns UTF-8 text, and a missing entry raises `ENOENT` rather than answering emptily -- `exists` is the check. `readJson` returns `unknown`, so a caller narrows it rather than trusting an asserted type; contents that do not parse raise an error naming the entry, which the parse error alone does not. `exists` follows a symlink, so a dangling one answers `false`. `rm` is recursive and silent on an entry that is not there.
|
|
366
|
+
|
|
367
|
+
`writeJson` writes two-space-indented JSON ending in a newline, so a tree outliving a crashed run reads as a real config file would. A fixture needing exact bytes goes through `write` instead. A value `JSON.stringify` cannot represent -- `undefined`, a function, a symbol -- is refused rather than written, so an optional binding that arrived empty fails at the call that passed it instead of surfacing later as a parse error.
|
|
368
|
+
|
|
369
|
+
Disposal is idempotent, and it removes a tree that has been made unwritable: unlinking an entry needs write permission on the directory containing it, so disposal restores permission across the tree and retries once before giving up. A suite that chmods a directory to exercise a write-failure path therefore needs no wrapper to chmod it back.
|
|
310
370
|
|
|
311
371
|
`Disposable` is declared in `lib.esnext.disposable.d.ts` alone, so consuming this export requires `ESNext.Disposable` in your `lib`.
|
|
312
372
|
|
|
@@ -4,5 +4,15 @@ export interface CreateTempTreeOptions {
|
|
|
4
4
|
}
|
|
5
5
|
export interface TempTree extends Disposable {
|
|
6
6
|
readonly dir: string;
|
|
7
|
+
exists(entryPath: string): boolean;
|
|
8
|
+
list(entryPath?: string): string[];
|
|
9
|
+
mkdir(entryPath: string): string;
|
|
10
|
+
read(entryPath: string): string;
|
|
11
|
+
readJson(entryPath: string): unknown;
|
|
7
12
|
resolve(...segments: string[]): string;
|
|
13
|
+
rm(entryPath: string): void;
|
|
14
|
+
symlink(linkPath: string, targetPath: string): string;
|
|
15
|
+
write(entryPath: string, contents: string | Uint8Array): string;
|
|
16
|
+
writeAll(entries: Record<string, string | Uint8Array>): void;
|
|
17
|
+
writeJson(entryPath: string, value: unknown): string;
|
|
8
18
|
}
|
|
@@ -6,28 +6,91 @@ export function createTempTree(entries, options = {}) {
|
|
|
6
6
|
assertNamesDirectChild(prefix);
|
|
7
7
|
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
8
8
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
writeAll(entries);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
fs.rmSync(dir, { force: true, recursive: true });
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
function exists(entryPath) {
|
|
16
|
+
return fs.existsSync(resolveWithinTree(dir, [entryPath]));
|
|
17
|
+
}
|
|
18
|
+
function list(entryPath = '') {
|
|
19
|
+
return fs.readdirSync(resolveWithinTree(dir, [entryPath])).toSorted();
|
|
20
|
+
}
|
|
21
|
+
function mkdir(entryPath) {
|
|
22
|
+
const absolutePath = resolveWithinTree(dir, [entryPath]);
|
|
23
|
+
fs.mkdirSync(absolutePath, { recursive: true });
|
|
24
|
+
return absolutePath;
|
|
25
|
+
}
|
|
26
|
+
function read(entryPath) {
|
|
27
|
+
return fs.readFileSync(resolveWithinTree(dir, [entryPath]), 'utf8');
|
|
28
|
+
}
|
|
29
|
+
function readJson(entryPath) {
|
|
30
|
+
const contents = read(entryPath);
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(contents);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new Error(`Entry "${entryPath}" is not readable as JSON`, { cause: error });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function resolve(...segments) {
|
|
39
|
+
return resolveWithinTree(dir, segments);
|
|
40
|
+
}
|
|
41
|
+
function rm(entryPath) {
|
|
42
|
+
fs.rmSync(resolveWithinTree(dir, [entryPath]), { force: true, recursive: true });
|
|
43
|
+
}
|
|
44
|
+
function symlink(linkPath, targetPath) {
|
|
45
|
+
const absoluteLink = resolveWithinTree(dir, [linkPath]);
|
|
46
|
+
fs.mkdirSync(path.dirname(absoluteLink), { recursive: true });
|
|
47
|
+
fs.symlinkSync(targetPath, absoluteLink, chooseLinkType(absoluteLink, targetPath));
|
|
48
|
+
return absoluteLink;
|
|
49
|
+
}
|
|
50
|
+
function write(entryPath, contents) {
|
|
51
|
+
const absolutePath = resolveWithinTree(dir, [entryPath]);
|
|
52
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(absolutePath, contents);
|
|
54
|
+
return absolutePath;
|
|
55
|
+
}
|
|
56
|
+
function writeAll(newEntries) {
|
|
57
|
+
for (const [entry, contents] of Object.entries(newEntries)) {
|
|
11
58
|
if (entry.endsWith('/')) {
|
|
12
|
-
|
|
59
|
+
mkdir(entry);
|
|
13
60
|
}
|
|
14
61
|
else {
|
|
15
|
-
|
|
16
|
-
fs.writeFileSync(entryPath, contents);
|
|
62
|
+
write(entry, contents);
|
|
17
63
|
}
|
|
18
64
|
}
|
|
19
65
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
66
|
+
function writeJson(entryPath, value) {
|
|
67
|
+
const json = JSON.stringify(value, null, 2);
|
|
68
|
+
if (json === undefined) {
|
|
69
|
+
throw new Error(`Value of type "${typeof value}" for "${entryPath}" has no JSON representation`);
|
|
70
|
+
}
|
|
71
|
+
return write(entryPath, `${json}\n`);
|
|
23
72
|
}
|
|
24
73
|
return {
|
|
25
74
|
dir,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
75
|
+
exists,
|
|
76
|
+
list,
|
|
77
|
+
mkdir,
|
|
78
|
+
read,
|
|
79
|
+
readJson,
|
|
80
|
+
resolve,
|
|
81
|
+
rm,
|
|
82
|
+
symlink,
|
|
83
|
+
write,
|
|
84
|
+
writeAll,
|
|
85
|
+
writeJson,
|
|
29
86
|
[Symbol.dispose]() {
|
|
30
|
-
|
|
87
|
+
try {
|
|
88
|
+
fs.rmSync(dir, { force: true, recursive: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
restoreDirectoryPermissions(dir);
|
|
92
|
+
fs.rmSync(dir, { force: true, recursive: true });
|
|
93
|
+
}
|
|
31
94
|
},
|
|
32
95
|
};
|
|
33
96
|
}
|
|
@@ -39,6 +102,22 @@ function assertNamesDirectChild(prefix) {
|
|
|
39
102
|
throw new Error(`Temporary-directory prefix "${prefix}" names no new directory`);
|
|
40
103
|
}
|
|
41
104
|
}
|
|
105
|
+
function chooseLinkType(absoluteLinkPath, targetPath) {
|
|
106
|
+
const resolvedTarget = path.resolve(path.dirname(absoluteLinkPath), targetPath);
|
|
107
|
+
if (fs.statSync(resolvedTarget, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
108
|
+
return 'file';
|
|
109
|
+
}
|
|
110
|
+
return path.isAbsolute(targetPath) ? 'junction' : 'dir';
|
|
111
|
+
}
|
|
112
|
+
function restoreDirectoryPermissions(dir) {
|
|
113
|
+
fs.chmodSync(dir, 0o700);
|
|
114
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (entry.isDirectory()) {
|
|
117
|
+
restoreDirectoryPermissions(path.join(dir, entry.name));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
42
121
|
function resolveWithinTree(dir, segments) {
|
|
43
122
|
const target = path.resolve(dir, ...segments);
|
|
44
123
|
if (target !== dir && !target.startsWith(dir + path.sep)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/toolbelt.filesystem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.
|
|
47
|
+
"@williamthorsen/toolbelt.errors": "0.6.0"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
50
50
|
"node": ">=24.0.0"
|