@williamthorsen/toolbelt.filesystem 0.5.0 → 0.6.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.6.0 — 2026-08-16
6
+
7
+ ### Features
8
+
9
+ - Add mkdir, symlink, write, and writeJson methods to `TempTree` (#176)
10
+
11
+ 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.
12
+
13
+ ### Tests
14
+
15
+ - Drop expect-type in favor of expectTypeOf (#175)
16
+
17
+ 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.
18
+
5
19
  ## 0.5.0 — 2026-08-15
6
20
 
7
21
  ### Features
package/README.md CHANGED
@@ -3,19 +3,13 @@
3
3
  Filesystem utilities for TypeScript and JavaScript.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v0.5.0 (2026-08-15)
6
+ ## Release notes — v0.6.0 (2026-08-16)
7
7
 
8
8
  ### Features
9
9
 
10
- - 🚨 **Breaking:** Promote createTempTree to the candidate tier with a caller-chosen prefix and binary entries (#148)
10
+ - Add mkdir, symlink, write, and writeJson methods to `TempTree` (#176)
11
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.
12
+ 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.
19
13
  <!-- /section:release-notes -->
20
14
 
21
15
  ## Installation
@@ -298,7 +292,11 @@ A prefix that would place the tree anywhere but directly inside the system tempo
298
292
  ```ts
299
293
  interface TempTree extends Disposable {
300
294
  readonly dir: string;
295
+ mkdir(entryPath: string): string;
301
296
  resolve(...segments: string[]): string;
297
+ symlink(linkPath: string, targetPath: string): string;
298
+ write(entryPath: string, contents: string | Uint8Array): string;
299
+ writeJson(entryPath: string, value: unknown): string;
302
300
  }
303
301
  ```
304
302
 
@@ -306,6 +304,32 @@ interface TempTree extends Disposable {
306
304
 
307
305
  `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
306
 
307
+ `mkdir`, `symlink`, `write`, 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:
308
+
309
+ ```ts
310
+ using tree = createTempTree({ 'packages/app/package.json': '{ "name": "app" }' });
311
+
312
+ tree.write('packages/app/src/main.ts', 'export {};\n'); // '/private/var/folders/.../packages/app/src/main.ts'
313
+ tree.writeJson('tsconfig.json', { include: ['src'] });
314
+ tree.mkdir('packages/empty');
315
+ ```
316
+
317
+ Each creates the parent directories it needs, resolves through the same containment check as `resolve`, and returns the absolute path of what it wrote. Both of `symlink`'s paths are checked, so an escaping target is refused as well as an escaping link.
318
+
319
+ They part company on an entry that already exists: `write` replaces it, `mkdir` leaves it and its contents alone, and `symlink` raises `EEXIST`.
320
+
321
+ `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 link type is chosen from the target, which is where the one portability difference lives. A directory is linked as a junction, which Windows creates without the elevation a directory symlink needs; every other target, one that does not exist included, is linked as a file. That last case matches what Node falls back to when no type is given, and it leaves the link dangling until the target appears.
322
+
323
+ ```ts
324
+ using tree = createTempTree({ 'store/kit/package.json': '{ "name": "kit" }' });
325
+
326
+ tree.symlink('node_modules/kit', 'store/kit'); // a junction, so Windows needs no elevation
327
+ ```
328
+
329
+ The link stores an absolute path, which a junction requires. Code under test that reads a link rather than following it therefore sees a path under the tree root, where a package manager would have written a relative one; a fixture reproducing that shape reaches for `fs.symlinkSync` directly.
330
+
331
+ `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.
332
+
309
333
  Disposal is idempotent.
310
334
 
311
335
  `Disposable` is declared in `lib.esnext.disposable.d.ts` alone, so consuming this export requires `ESNext.Disposable` in your `lib`.
@@ -4,5 +4,9 @@ export interface CreateTempTreeOptions {
4
4
  }
5
5
  export interface TempTree extends Disposable {
6
6
  readonly dir: string;
7
+ mkdir(entryPath: string): string;
7
8
  resolve(...segments: string[]): string;
9
+ symlink(linkPath: string, targetPath: string): string;
10
+ write(entryPath: string, contents: string | Uint8Array): string;
11
+ writeJson(entryPath: string, value: unknown): string;
8
12
  }
@@ -7,13 +7,11 @@ export function createTempTree(entries, options = {}) {
7
7
  const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
8
8
  try {
9
9
  for (const [entry, contents] of Object.entries(entries)) {
10
- const entryPath = resolveWithinTree(dir, [entry]);
11
10
  if (entry.endsWith('/')) {
12
- fs.mkdirSync(entryPath, { recursive: true });
11
+ mkdir(entry);
13
12
  }
14
13
  else {
15
- fs.mkdirSync(path.dirname(entryPath), { recursive: true });
16
- fs.writeFileSync(entryPath, contents);
14
+ write(entry, contents);
17
15
  }
18
16
  }
19
17
  }
@@ -21,11 +19,41 @@ export function createTempTree(entries, options = {}) {
21
19
  fs.rmSync(dir, { force: true, recursive: true });
22
20
  throw error;
23
21
  }
22
+ function mkdir(entryPath) {
23
+ const absolutePath = resolveWithinTree(dir, [entryPath]);
24
+ fs.mkdirSync(absolutePath, { recursive: true });
25
+ return absolutePath;
26
+ }
27
+ function resolve(...segments) {
28
+ return resolveWithinTree(dir, segments);
29
+ }
30
+ function symlink(linkPath, targetPath) {
31
+ const absoluteLink = resolveWithinTree(dir, [linkPath]);
32
+ const absoluteTarget = resolveWithinTree(dir, [targetPath]);
33
+ fs.mkdirSync(path.dirname(absoluteLink), { recursive: true });
34
+ fs.symlinkSync(absoluteTarget, absoluteLink, chooseLinkType(absoluteTarget));
35
+ return absoluteLink;
36
+ }
37
+ function write(entryPath, contents) {
38
+ const absolutePath = resolveWithinTree(dir, [entryPath]);
39
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
40
+ fs.writeFileSync(absolutePath, contents);
41
+ return absolutePath;
42
+ }
43
+ function writeJson(entryPath, value) {
44
+ const json = JSON.stringify(value, null, 2);
45
+ if (json === undefined) {
46
+ throw new Error(`Value of type "${typeof value}" for "${entryPath}" has no JSON representation`);
47
+ }
48
+ return write(entryPath, `${json}\n`);
49
+ }
24
50
  return {
25
51
  dir,
26
- resolve(...segments) {
27
- return resolveWithinTree(dir, segments);
28
- },
52
+ mkdir,
53
+ resolve,
54
+ symlink,
55
+ write,
56
+ writeJson,
29
57
  [Symbol.dispose]() {
30
58
  fs.rmSync(dir, { force: true, recursive: true });
31
59
  },
@@ -39,6 +67,9 @@ function assertNamesDirectChild(prefix) {
39
67
  throw new Error(`Temporary-directory prefix "${prefix}" names no new directory`);
40
68
  }
41
69
  }
70
+ function chooseLinkType(targetPath) {
71
+ return fs.statSync(targetPath, { throwIfNoEntry: false })?.isDirectory() === true ? 'junction' : 'file';
72
+ }
42
73
  function resolveWithinTree(dir, segments) {
43
74
  const target = path.resolve(dir, ...segments);
44
75
  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.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Filesystem utilities",
5
5
  "keywords": [
6
6
  "config-cascade",