@williamthorsen/toolbelt.filesystem 0.2.1 → 0.4.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,62 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.4.0 — 2026-08-12
6
+
7
+ ### Features
8
+
9
+ - 🚨 **Breaking:** Add reconcileFile to toolbelt.filesystem and promote describeError to release tier (#122)
10
+
11
+ Adds `reconcileFile` to `@williamthorsen/toolbelt.filesystem`: an idempotent file write that creates what is missing, refuses by default to replace what is not, and reports which of those it did as a structured outcome rather than throwing.
12
+
13
+ Separately, `describeError` is promoted to `@williamthorsen/toolbelt.errors`' release tier, so no release-tier module depends on a candidate one.
14
+
15
+ Migration: `describeError` is imported from `@williamthorsen/toolbelt.errors` rather than `@williamthorsen/toolbelt.errors/candidate`, which no longer exports it. `chainError`, `isError`, and `assertIsError` remain at candidate tier.
16
+
17
+ - Add reconcileFileFromFile to toolbelt.filesystem (#123)
18
+
19
+ 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.
20
+
21
+ 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.
22
+
23
+ ## 0.3.0 — 2026-08-08
24
+
25
+ ### Features
26
+
27
+ - Migrate replaceFileExtension into filesystem package (#74)
28
+
29
+ Adds `replaceFileExtension` to the filesystem utilities. The function replaces the file extension in a file path; unlike analogous built-in functions, it supports multi-part extensions such as `.d.ts`.
30
+
31
+ - Add directory-chain ascent and lookup exports (#102)
32
+
33
+ Adds three functions for upward directory search, which walk from a starting directory to either the filesystem root or a bounded ceiling, finding named files or directories at each level along the way:
34
+
35
+ - `listDirectoryChain` returns the directories alone
36
+ - `listDirectoryChainMatches` returns every level's match
37
+ - `findDirectoryChainMatch` finds only the nearest match
38
+
39
+ All three reject a path that falls outside the range they were asked to search. `findProjectRoot` now applies that same rule to its markers.
40
+
41
+ - Add createTempTree with scope-bound disposal (#106)
42
+
43
+ Adds `createTempTree` to `@williamthorsen/toolbelt.filesystem/proposed`. The new function allows a caller to describe a directory tree as a plain object mapping paths to contents and receive a handle in return; the tree is removed when that handle goes out of scope.
44
+
45
+ - 🚨 **Breaking:** Add findPackageRoot, getSelfVersion, and findProjectRoot to toolbelt.packaging (#107)
46
+
47
+ Adds a way for any module, whether it runs from a source tree or a compiled build, to identify the package that owns it and the version that package declares.
48
+
49
+ `findProjectRoot` moves from `@williamthorsen/toolbelt.filesystem` to `@williamthorsen/toolbelt.packaging`. Callers of `loadConfigCascade` must now state where its upward search stops, rather than relying on a project root the function found for them.
50
+
51
+ ### Tooling
52
+
53
+ - Migrate Vitest configs to the nmr projects model (#73)
54
+
55
+ Packages no longer need to declare their own Vitest config. Test suites are now selected by a test file's name suffix rather than by choosing a config file: `*.app.test.ts` and `*.int.test.ts` route to the app and integration suites, and everything else runs as a unit test. Local development is now declared to require Node 24.16 or later.
56
+
57
+ - Use identical compiler settings for all packages (#105)
58
+
59
+ All packages now have identical compiler settings, using the settings from the `@williamthorsen/tsconfig` base config without modification.
60
+
5
61
  ## 0.2.1 — 2026-07-27
6
62
 
7
63
  ### Tooling
package/README.md CHANGED
@@ -2,7 +2,25 @@
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.4.0 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ - 🚨 **Breaking:** Add reconcileFile to toolbelt.filesystem and promote describeError to release tier (#122)
11
+
12
+ Adds `reconcileFile` to `@williamthorsen/toolbelt.filesystem`: an idempotent file write that creates what is missing, refuses by default to replace what is not, and reports which of those it did as a structured outcome rather than throwing.
13
+
14
+ Separately, `describeError` is promoted to `@williamthorsen/toolbelt.errors`' release tier, so no release-tier module depends on a candidate one.
15
+
16
+ Migration: `describeError` is imported from `@williamthorsen/toolbelt.errors` rather than `@williamthorsen/toolbelt.errors/candidate`, which no longer exports it. `chainError`, `isError`, and `assertIsError` remain at candidate tier.
17
+
18
+ - Add reconcileFileFromFile to toolbelt.filesystem (#123)
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.
23
+ <!-- /section:release-notes -->
6
24
 
7
25
  ## Installation
8
26
 
@@ -12,64 +30,110 @@ pnpm add @williamthorsen/toolbelt.filesystem
12
30
 
13
31
  ## Runtime requirements
14
32
 
15
- Both functions 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.
33
+ `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.
16
34
 
17
35
  `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
36
 
19
- ## `findProjectRoot`
37
+ ## `listDirectoryChain`
20
38
 
21
39
  ```ts
22
- findProjectRoot(startDir: string, options?: { markers?: ReadonlyArray<string> }): ProjectRoot;
40
+ listDirectoryChain(startDir: string, options?: { stopAtDir?: string }): [string, ...string[]];
23
41
  ```
24
42
 
25
- Resolves `startDir` to an absolute path, ascends from it, and returns the first directory carrying a root marker, along with the evidence that identified it:
43
+ Resolves `startDir` to an absolute path and returns it followed by each of its ancestors, nearest first. It manipulates paths as strings and reads nothing from disk.
26
44
 
27
45
  ```ts
28
- interface ProjectRoot {
29
- marker: string | null; // the marker that matched, or null when a fallback answered
30
- rootDir: string;
31
- source: 'marker' | 'package-json' | 'start-dir';
32
- }
46
+ import { listDirectoryChain } from '@williamthorsen/toolbelt.filesystem';
47
+
48
+ listDirectoryChain('/home/dev/app/src');
49
+ // ['/home/dev/app/src', '/home/dev/app', '/home/dev', '/home', '/']
50
+
51
+ listDirectoryChain('/home/dev/app/src', { stopAtDir: '/home/dev' });
52
+ // ['/home/dev/app/src', '/home/dev/app', '/home/dev']
53
+ ```
54
+
55
+ `stopAtDir` bounds the ascent inclusively and is resolved the same way `startDir` is, so a relative ceiling behaves like a relative start. One that is neither the start directory nor an ancestor of it throws, naming both, rather than being ignored and letting the ascent run past the bound. The comparison is exact, so a `stopAtDir` differing from its target only in case is off the chain even on a volume that would open it.
56
+
57
+ The result type records that the chain is never empty, which is what spares the nearest directory an undefined check:
58
+
59
+ ```ts
60
+ const [nearestDir] = listDirectoryChain(process.cwd()); // string, not string | undefined
33
61
  ```
34
62
 
35
- `DEFAULT_ROOT_MARKERS` is consulted in order, so the earliest entry wins when one directory carries several:
63
+ The ascent terminates at the filesystem root on every platform, so a Windows drive root or UNC share is as safe a starting point as a POSIX path.
64
+
65
+ ## `listDirectoryChainMatches`
66
+
67
+ ```ts
68
+ listDirectoryChainMatches(
69
+ startDir: string,
70
+ names: ReadonlyArray<string>,
71
+ options?: { stopAtDir?: string },
72
+ ): DirectoryChainMatch[];
73
+ ```
36
74
 
37
- 1. `.git`, matching either a directory (an ordinary clone) or a file (a worktree or submodule);
38
- 2. `pnpm-workspace.yaml`;
39
- 3. `pnpm-lock.yaml`;
40
- 4. `package-lock.json`;
41
- 5. `yarn.lock`;
42
- 6. `bun.lock`.
75
+ Returns, for each directory in the chain at or above `startDir`, the first of `names` that exists there:
43
76
 
44
- Passing `markers` replaces that list rather than extending it. Spread `DEFAULT_ROOT_MARKERS` to add to it:
77
+ ```ts
78
+ interface DirectoryChainMatch {
79
+ dir: string; // the chain level, which differs from the entry's own directory for a nested name
80
+ entryName: string;
81
+ entryPath: string;
82
+ }
83
+ ```
45
84
 
46
85
  ```ts
47
- import { DEFAULT_ROOT_MARKERS, findProjectRoot } from '@williamthorsen/toolbelt.filesystem';
86
+ import { listDirectoryChainMatches } from '@williamthorsen/toolbelt.filesystem';
48
87
 
49
- findProjectRoot(process.cwd(), { markers: [...DEFAULT_ROOT_MARKERS, 'deno.json'] });
88
+ listDirectoryChainMatches('/home/dev/app/src', ['.git'], { stopAtDir: '/home/dev' });
89
+ // [{ dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }]
50
90
  ```
51
91
 
52
- When no directory up to and including the filesystem root carries a marker, the result falls back in this order, reporting a `null` marker either way:
92
+ A level yields at most one match, the earliest of `names` found there, and a level holding none contributes nothing, so an empty result is an ordinary outcome rather than an error. A name matches a directory as readily as a file, which is what lets `.git` be probed without knowing whether the clone is ordinary or a worktree.
53
93
 
54
- 1. the nearest ancestor holding a `package.json`, reported as `source: 'package-json'`;
55
- 2. `startDir` itself, reported as `source: 'start-dir'`.
94
+ Each name is a path relative to the level it is probed against, so a nested location such as `.config/stack.config.mjs` works. A name that would leave its level (an absolute path, or one whose `..` segments escape it) is rejected before any level is probed, so the rejection never depends on what happens to exist on disk.
56
95
 
57
- The ascent terminates at the filesystem root on every platform, so a Windows drive root or UNC share is as safe a starting point as a POSIX path.
96
+ `options` is forwarded to `listDirectoryChain`, so `stopAtDir` bounds the ascent the same way.
97
+
98
+ Every level is probed, because every level's match is reported. Where only the nearest match matters, [`findDirectoryChainMatch`](#finddirectorychainmatch) returns it and stops there.
99
+
100
+ ## `findDirectoryChainMatch`
101
+
102
+ ```ts
103
+ findDirectoryChainMatch(
104
+ startDir: string,
105
+ names: ReadonlyArray<string>,
106
+ options?: { stopAtDir?: string },
107
+ ): DirectoryChainMatch | undefined;
108
+ ```
109
+
110
+ Returns the nearest directory at or above `startDir` holding one of `names`, or `undefined` when none does. It is `listDirectoryChainMatches` narrowed to the first hit, sharing its result shape, its options, and its name validation:
111
+
112
+ ```ts
113
+ import { findDirectoryChainMatch } from '@williamthorsen/toolbelt.filesystem';
114
+
115
+ findDirectoryChainMatch('/home/dev/app/src', ['.git']);
116
+ // { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }
117
+ ```
118
+
119
+ 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.
58
120
 
59
121
  ## `loadConfigCascade`
60
122
 
61
123
  ```ts
62
124
  loadConfigCascade<TConfig>(options: {
63
125
  fileNames: ReadonlyArray<string>;
64
- markers?: ReadonlyArray<string>;
65
126
  shouldStopAscent?: (config: TConfig) => boolean;
66
127
  startDir: string;
128
+ stopAtDir: string;
67
129
  }): Promise<ConfigCascade<TConfig>>;
68
130
  ```
69
131
 
70
- Loads every config file between `startDir` and its project root, nearest first, and reads nothing above that root.
132
+ Loads every config file between `startDir` and `stopAtDir`, nearest first, and reads nothing above that boundary.
133
+
134
+ Discovery is [`listDirectoryChainMatches`](#listdirectorychainmatches) bounded at `stopAtDir`: the first of `fileNames` that exists at a level becomes that level's config, a level holding none contributes nothing, and a name that would leave its level is rejected before any file is read. A `stopAtDir` that is neither the start directory nor one of its ancestors throws, on the same terms `listDirectoryChain` sets out.
71
135
 
72
- At each level from `startDir` up to and including the root, the first of `fileNames` that exists becomes that level's config; a level holding none contributes nothing. Each name is a path relative to the level, so a nested location such as `.config/stack.config.mjs` works. A name that would leave its level (an absolute path, or one whose `..` segments escape it) is rejected before any file is read, since following it would breach the bound the cascade exists to enforce. The project root is resolved by `findProjectRoot`, and `markers` is forwarded to it.
136
+ The boundary is required, and it is the caller's to choose. That is what keeps this function free of any notion of what marks a project: it never asks whether a directory holds a lockfile or a workspace manifest. Where the boundary should be a project root, [`findProjectRoot`](https://github.com/williamthorsen/toolbelt/tree/main/packages/packaging#findprojectroot) in `@williamthorsen/toolbelt.packaging` resolves one from markers.
73
137
 
74
138
  The matched files are then imported one at a time, and `shouldStopAscent` is consulted after each. Once it returns true, the ascent halts and no farther file is imported at all, rather than being loaded and discarded:
75
139
 
@@ -80,8 +144,7 @@ interface ConfigCascade<TConfig> {
80
144
  dir: string; // the cascade level, which differs from the file's own directory for a nested file name
81
145
  filePath: string;
82
146
  }>;
83
- projectRoot: ProjectRoot;
84
- stopReason: 'predicate' | 'project-root';
147
+ stopReason: 'predicate' | 'stop-dir';
85
148
  }
86
149
  ```
87
150
 
@@ -93,17 +156,173 @@ The predicate is the caller's whole stop policy, so any field can drive it. By c
93
156
 
94
157
  ```ts
95
158
  import { loadConfigCascade } from '@williamthorsen/toolbelt.filesystem';
159
+ import { findProjectRoot } from '@williamthorsen/toolbelt.packaging';
96
160
 
97
161
  interface StackConfig {
98
162
  rules?: Record<string, string>;
99
163
  shouldStopAscent?: boolean;
100
164
  }
101
165
 
102
- const { entries, projectRoot, stopReason } = await loadConfigCascade<StackConfig>({
166
+ const { rootDir } = findProjectRoot(process.cwd());
167
+
168
+ const { entries, stopReason } = await loadConfigCascade<StackConfig>({
103
169
  fileNames: ['stack.config.mjs', 'stack.config.js'],
104
170
  shouldStopAscent: (config) => config.shouldStopAscent === true,
105
171
  startDir: process.cwd(),
172
+ stopAtDir: rootDir,
106
173
  });
107
174
  ```
108
175
 
109
- `projectRoot` and `stopReason` are provenance for the caller to surface, so a user can see which directory bounded the cascade and what ended it.
176
+ `stopReason` is provenance for the caller to surface, so a user can see whether the predicate ended the cascade or it simply reached the boundary. Which directory bounded it is the `stopAtDir` the caller passed in.
177
+
178
+ ## `reconcileFile`
179
+
180
+ ```ts
181
+ reconcileFile(
182
+ filePath: string,
183
+ content: string,
184
+ options?: { conflictPolicy?: 'replace' | 'skip'; isDryRun?: boolean },
185
+ ): FileReconciliation;
186
+ ```
187
+
188
+ Writes `content` to `filePath` and reports what the write took, rather than throwing:
189
+
190
+ ```ts
191
+ import { reconcileFile } from '@williamthorsen/toolbelt.filesystem';
192
+
193
+ reconcileFile('.config/tool.config.ts', template);
194
+ // { filePath: '.config/tool.config.ts', outcome: 'created' }
195
+ ```
196
+
197
+ Missing parent directories are created. `isDryRun` writes nothing and creates no directory, returning the outcome the real call would have produced, which is what lets a `--dry-run` flag print the same lines the run itself would. A write that would fail is the exception: nothing detects that without attempting it, so a dry run reports the outcome the write was headed for.
198
+
199
+ `conflictPolicy` decides what becomes of an existing file whose content differs, and decides nothing else: it is consulted in that case alone. The default, `'skip'`, never replaces a file the user may have edited.
200
+
201
+ | exists | differs | `conflictPolicy` | outcome |
202
+ | ------ | ------- | ---------------- | ------------- |
203
+ | no | — | — | `created` |
204
+ | yes | no | either | `up-to-date` |
205
+ | yes | yes | `replace` | `overwritten` |
206
+ | yes | yes | `skip` | `skipped` |
207
+
208
+ What counts as differing follows the policy, which is the part worth reading twice. `'replace'` promises the file holds exactly `content` afterwards, so only byte-identical content reports `up-to-date`; a file differing from `content` only in trailing whitespace is rewritten, because calling it up to date would leave the caller holding a file that is not what it asked for. `'skip'` modifies nothing either way, so its comparison decides a message alone and ignores trailing whitespace per line and at end of file, which keeps formatter churn from reading as a conflict. `up-to-date` therefore means the same thing under both: this policy has no work to do.
209
+
210
+ The result discriminates on `outcome`, so a failure always carries its reason:
211
+
212
+ ```ts
213
+ type FileReconciliation =
214
+ | { filePath: string; outcome: 'created' | 'overwritten' | 'up-to-date' }
215
+ | { filePath: string; outcome: 'skipped'; error?: string }
216
+ | { filePath: string; outcome: 'failed'; error: string };
217
+ ```
218
+
219
+ An I/O error on the write path reports `failed` rather than throwing, which is what lets a command writing several files collect a result for each instead of losing the rest to the first failure.
220
+
221
+ Three behaviors are worth knowing before they surprise you:
222
+
223
+ - A `skipped` result carrying an `error` means the existing file could not be read for comparison. The file was left alone, which is exactly what `'skip'` promises, so this is not a failure and a command exiting non-zero on failures should not count it as one.
224
+ - The existence probe follows symlinks. A dangling symlink therefore reports as non-existent: the outcome is `created`, the result names the link, and the bytes land at the link's target.
225
+ - The probe and the write are separate calls, leaving a window in which another process can create or remove the file. That gap is left open deliberately: the callers this serves are scaffolding commands with no competing writer, and an exclusive-create flag would close only the create half of it.
226
+
227
+ ## `reconcileFileFromFile`
228
+
229
+ ```ts
230
+ reconcileFileFromFile(
231
+ filePath: string,
232
+ sourcePath: string,
233
+ options?: { conflictPolicy?: 'replace' | 'skip'; isDryRun?: boolean },
234
+ ): FileReconciliation;
235
+ ```
236
+
237
+ Reconciles `filePath` against the content of `sourcePath`, which is what a command copying a bundled template reaches for:
238
+
239
+ ```ts
240
+ import { reconcileFileFromFile } from '@williamthorsen/toolbelt.filesystem';
241
+
242
+ reconcileFileFromFile('.config/git-cliff.toml', bundledTemplatePath);
243
+ // { filePath: '.config/git-cliff.toml', outcome: 'created' }
244
+ ```
245
+
246
+ It is [`reconcileFile`](#reconcilefile) with the read supplied: the outcome table, the conflict policy, the created parent directories, and the result type are that function's, unchanged. Three things are this one's own.
247
+
248
+ The source is read as utf8 text, so a binary source is not supported: it would be decoded and re-encoded on the way through.
249
+
250
+ A source that cannot be read reports `failed` rather than throwing, and a missing source is not distinguished from an unreadable one. The reason names the source and the cause:
251
+
252
+ ```
253
+ Failed to read /pkg/cliff.toml.template: ENOENT: no such file or directory, open '/pkg/cliff.toml.template'
254
+ ```
255
+
256
+ The path is interpolated rather than left to the underlying message, which carries none of its own at the read stage: reading a directory yields `EISDIR: illegal operation on a directory, read`. Under `ENOENT` the path therefore reads twice. The result's `filePath` is the destination on this path as on every other, so a caller copying several templates keys its results by destination and still sees which source failed.
257
+
258
+ The source is read even under `isDryRun`, because the outcome depends on comparing its content. A dry run can therefore report `failed` where `reconcileFile`'s cannot, and it still writes nothing.
259
+
260
+ ## `createTempTree`
261
+
262
+ Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
263
+
264
+ ```ts
265
+ createTempTree(entries: Record<string, string>): TempTree;
266
+ ```
267
+
268
+ Builds a throwaway directory tree and returns a handle that removes it when the binding leaves scope:
269
+
270
+ ```ts
271
+ import { createTempTree } from '@williamthorsen/toolbelt.filesystem/proposed';
272
+
273
+ {
274
+ using tree = createTempTree({
275
+ '.git/': '',
276
+ 'packages/app/package.json': '{ "name": "app" }',
277
+ });
278
+
279
+ tree.dir; // '/private/var/folders/.../toolbelt-a1b2c3'
280
+ tree.resolve('packages/app'); // '/private/var/folders/.../toolbelt-a1b2c3/packages/app'
281
+ }
282
+ // The tree is gone here.
283
+ ```
284
+
285
+ 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
+
287
+ ```ts
288
+ interface TempTree extends Disposable {
289
+ readonly dir: string;
290
+ resolve(...segments: string[]): string;
291
+ }
292
+ ```
293
+
294
+ `dir` is realpath-resolved, because `os.tmpdir()` is a symlink on macOS and a caller comparing paths against it would otherwise see a mismatch it did not cause.
295
+
296
+ `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.
297
+
298
+ Disposal is idempotent.
299
+
300
+ `Disposable` is declared in `lib.esnext.disposable.d.ts` alone, so consuming this export requires `ESNext.Disposable` in your `lib`.
301
+
302
+ ## `replaceFileExtension`
303
+
304
+ Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
305
+
306
+ ```ts
307
+ replaceFileExtension(filePath: string, newExtension: string, options?: { oldExtension?: string }): string;
308
+ ```
309
+
310
+ Returns `filePath` with its extension replaced. It manipulates the string alone and touches no filesystem.
311
+
312
+ ```ts
313
+ import { replaceFileExtension } from '@williamthorsen/toolbelt.filesystem/proposed';
314
+
315
+ replaceFileExtension('src/main.ts', '.js'); // 'src/main.js'
316
+ replaceFileExtension('src/main.ts', 'js'); // 'src/main.js' -- the leading period is optional
317
+ replaceFileExtension('src/main.ts', ''); // 'src/main' -- an empty replacement removes the extension
318
+ ```
319
+
320
+ The extension being replaced defaults to whatever `path.extname` reports, which is the substring from the final period in the file name. That is wrong for a multi-part extension: `path.extname('src/main.d.ts')` returns `.ts`, so the default would yield `src/main.d.js`. Declare the whole extension through `oldExtension` to replace it entire:
321
+
322
+ ```ts
323
+ replaceFileExtension('src/main.d.ts', '.js', { oldExtension: '.d.ts' }); // 'src/main.js'
324
+ ```
325
+
326
+ 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
+
328
+ 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`.
@@ -0,0 +1,5 @@
1
+ export declare function createTempTree(entries: Record<string, string>): TempTree;
2
+ export interface TempTree extends Disposable {
3
+ readonly dir: string;
4
+ resolve(...segments: string[]): string;
5
+ }
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export function createTempTree(entries) {
5
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbelt-')));
6
+ try {
7
+ for (const [entry, contents] of Object.entries(entries)) {
8
+ const entryPath = resolveWithinTree(dir, [entry]);
9
+ if (entry.endsWith('/')) {
10
+ fs.mkdirSync(entryPath, { recursive: true });
11
+ }
12
+ else {
13
+ fs.mkdirSync(path.dirname(entryPath), { recursive: true });
14
+ fs.writeFileSync(entryPath, contents);
15
+ }
16
+ }
17
+ }
18
+ catch (error) {
19
+ fs.rmSync(dir, { force: true, recursive: true });
20
+ throw error;
21
+ }
22
+ return {
23
+ dir,
24
+ resolve(...segments) {
25
+ return resolveWithinTree(dir, segments);
26
+ },
27
+ [Symbol.dispose]() {
28
+ fs.rmSync(dir, { force: true, recursive: true });
29
+ },
30
+ };
31
+ }
32
+ function resolveWithinTree(dir, segments) {
33
+ const target = path.resolve(dir, ...segments);
34
+ if (target !== dir && !target.startsWith(dir + path.sep)) {
35
+ throw new Error(`Path "${target}" falls outside the temporary tree at "${dir}"`);
36
+ }
37
+ return target;
38
+ }
@@ -1 +1,2 @@
1
- export {};
1
+ export { createTempTree, type TempTree } from './createTempTree.js';
2
+ export { replaceFileExtension, type ReplaceFileExtensionOptions } from './replaceFileExtension.js';
@@ -1 +1,2 @@
1
- export {};
1
+ export { createTempTree } from "./createTempTree.js";
2
+ export { replaceFileExtension } from "./replaceFileExtension.js";
@@ -0,0 +1,4 @@
1
+ export declare function replaceFileExtension(filePath: string, newExtension: string, options?: ReplaceFileExtensionOptions): string;
2
+ export interface ReplaceFileExtensionOptions {
3
+ oldExtension?: string | undefined;
4
+ }
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ export function replaceFileExtension(filePath, newExtension, options = {}) {
3
+ if (endsWithSeparator(filePath)) {
4
+ throw new Error(`File path "${filePath}" ends with a path separator, so it names a directory`);
5
+ }
6
+ const oldExtension = toDotPrefixed(options.oldExtension ?? path.extname(filePath));
7
+ const replacement = toDotPrefixed(newExtension);
8
+ if (oldExtension === '') {
9
+ return `${filePath}${replacement}`;
10
+ }
11
+ if (!filePath.endsWith(oldExtension)) {
12
+ throw new Error(`File path "${filePath}" does not end with extension "${oldExtension}"`);
13
+ }
14
+ return filePath.slice(0, -oldExtension.length) + replacement;
15
+ }
16
+ function endsWithSeparator(filePath) {
17
+ return filePath.endsWith('/') || filePath.endsWith(path.sep);
18
+ }
19
+ function toDotPrefixed(extension) {
20
+ if (extension === '' || extension.startsWith('.'))
21
+ return extension;
22
+ return `.${extension}`;
23
+ }
@@ -0,0 +1,9 @@
1
+ import { type ListDirectoryChainOptions } from './listDirectoryChain.js';
2
+ export interface DirectoryChainMatch {
3
+ dir: string;
4
+ entryName: string;
5
+ entryPath: string;
6
+ }
7
+ export type DirectoryChainMatchOptions = ListDirectoryChainOptions;
8
+ export declare function findDirectoryChainMatch(startDir: string, names: ReadonlyArray<string>, options?: DirectoryChainMatchOptions): DirectoryChainMatch | undefined;
9
+ export declare function listDirectoryChainMatches(startDir: string, names: ReadonlyArray<string>, options?: DirectoryChainMatchOptions): DirectoryChainMatch[];
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { listDirectoryChain } from "./listDirectoryChain.js";
4
+ export function findDirectoryChainMatch(startDir, names, options = {}) {
5
+ assertLevelRelativeNames(names);
6
+ for (const dir of listDirectoryChain(startDir, options)) {
7
+ const entryName = findMatchingName(dir, names);
8
+ if (entryName !== undefined) {
9
+ return { dir, entryName, entryPath: path.join(dir, entryName) };
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+ export function listDirectoryChainMatches(startDir, names, options = {}) {
15
+ assertLevelRelativeNames(names);
16
+ const matches = [];
17
+ for (const dir of listDirectoryChain(startDir, options)) {
18
+ const entryName = findMatchingName(dir, names);
19
+ if (entryName !== undefined) {
20
+ matches.push({ dir, entryName, entryPath: path.join(dir, entryName) });
21
+ }
22
+ }
23
+ return matches;
24
+ }
25
+ function assertLevelRelativeNames(names) {
26
+ for (const name of names) {
27
+ if (path.isAbsolute(name)) {
28
+ throw new Error(`Entry name must be relative to its directory level: ${name}`);
29
+ }
30
+ const normalized = path.normalize(name);
31
+ if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
32
+ throw new Error(`Entry name must not ascend above its directory level: ${name}`);
33
+ }
34
+ }
35
+ }
36
+ function findMatchingName(dir, names) {
37
+ for (const name of names) {
38
+ if (fs.existsSync(path.join(dir, name))) {
39
+ return name;
40
+ }
41
+ }
42
+ return undefined;
43
+ }
@@ -1,2 +1,5 @@
1
- export { DEFAULT_ROOT_MARKERS, findProjectRoot, type FindProjectRootOptions, type ProjectRoot, type ProjectRootSource, } from './findProjectRoot.js';
1
+ export { type DirectoryChainMatch, type DirectoryChainMatchOptions, findDirectoryChainMatch, listDirectoryChainMatches, } from './directory-chain-matches.js';
2
+ export { listDirectoryChain, type ListDirectoryChainOptions } from './listDirectoryChain.js';
2
3
  export { type CascadeStopReason, type ConfigCascade, type ConfigEntry, loadConfigCascade, type LoadConfigCascadeOptions, } from './loadConfigCascade.js';
4
+ export { type FileReconciliation, reconcileFile, type ReconcileFileOptions, type ReconciliationOutcome, } from './reconcileFile.js';
5
+ export { reconcileFileFromFile } from './reconcileFileFromFile.js';
@@ -1,2 +1,5 @@
1
- export { DEFAULT_ROOT_MARKERS, findProjectRoot, } from "./findProjectRoot.js";
1
+ export { findDirectoryChainMatch, listDirectoryChainMatches, } from "./directory-chain-matches.js";
2
+ export { listDirectoryChain } from "./listDirectoryChain.js";
2
3
  export { loadConfigCascade, } from "./loadConfigCascade.js";
4
+ export { reconcileFile, } from "./reconcileFile.js";
5
+ export { reconcileFileFromFile } from "./reconcileFileFromFile.js";
@@ -0,0 +1,4 @@
1
+ export declare function listDirectoryChain(startDir: string, options?: ListDirectoryChainOptions): [string, ...string[]];
2
+ export interface ListDirectoryChainOptions {
3
+ stopAtDir?: string | undefined;
4
+ }
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+ export function listDirectoryChain(startDir, options = {}) {
3
+ const { stopAtDir } = options;
4
+ const resolvedStartDir = path.resolve(startDir);
5
+ const resolvedStopAtDir = stopAtDir === undefined ? undefined : path.resolve(stopAtDir);
6
+ const chain = [resolvedStartDir];
7
+ let dir = resolvedStartDir;
8
+ let hasReachedStopAtDir = dir === resolvedStopAtDir;
9
+ while (!hasReachedStopAtDir) {
10
+ const parentDir = path.dirname(dir);
11
+ if (parentDir === dir)
12
+ break;
13
+ chain.push(parentDir);
14
+ dir = parentDir;
15
+ hasReachedStopAtDir = dir === resolvedStopAtDir;
16
+ }
17
+ if (resolvedStopAtDir !== undefined && !hasReachedStopAtDir) {
18
+ throw new Error('Stop directory must be the start directory or one of its ancestors: ' +
19
+ `stopAtDir=${resolvedStopAtDir}, startDir=${resolvedStartDir}`);
20
+ }
21
+ return chain;
22
+ }
@@ -1,9 +1,7 @@
1
- import { type ProjectRoot } from './findProjectRoot.js';
2
1
  export declare function loadConfigCascade<TConfig = unknown>(options: LoadConfigCascadeOptions<TConfig>): Promise<ConfigCascade<TConfig>>;
3
- export type CascadeStopReason = 'predicate' | 'project-root';
2
+ export type CascadeStopReason = 'predicate' | 'stop-dir';
4
3
  export interface ConfigCascade<TConfig> {
5
4
  entries: ConfigEntry<TConfig>[];
6
- projectRoot: ProjectRoot;
7
5
  stopReason: CascadeStopReason;
8
6
  }
9
7
  export interface ConfigEntry<TConfig> {
@@ -13,7 +11,7 @@ export interface ConfigEntry<TConfig> {
13
11
  }
14
12
  export interface LoadConfigCascadeOptions<TConfig> {
15
13
  fileNames: ReadonlyArray<string>;
16
- markers?: ReadonlyArray<string> | undefined;
17
14
  shouldStopAscent?: ((config: TConfig) => boolean) | undefined;
18
15
  startDir: string;
16
+ stopAtDir: string;
19
17
  }
@@ -6,56 +6,22 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
9
  import { pathToFileURL } from 'node:url';
12
- import { findProjectRoot } from "./findProjectRoot.js";
10
+ import { listDirectoryChainMatches } from "./directory-chain-matches.js";
13
11
  export async function loadConfigCascade(options) {
14
- const { fileNames, markers, shouldStopAscent, startDir } = options;
15
- assertLevelRelativeFileNames(fileNames);
16
- const projectRoot = findProjectRoot(startDir, { markers });
17
- const candidates = collectCandidates(path.resolve(startDir), projectRoot.rootDir, fileNames);
12
+ const { fileNames, shouldStopAscent, startDir, stopAtDir } = options;
13
+ const matches = listDirectoryChainMatches(startDir, fileNames, { stopAtDir });
18
14
  const entries = [];
19
- let stopReason = 'project-root';
20
- for (const { dir, filePath } of candidates) {
21
- const config = await importDefaultExport(filePath);
22
- entries.push({ config, dir, filePath });
15
+ let stopReason = 'stop-dir';
16
+ for (const { dir, entryPath } of matches) {
17
+ const config = await importDefaultExport(entryPath);
18
+ entries.push({ config, dir, filePath: entryPath });
23
19
  if (shouldStopAscent?.(config) === true) {
24
20
  stopReason = 'predicate';
25
21
  break;
26
22
  }
27
23
  }
28
- return { entries, projectRoot, stopReason };
29
- }
30
- function assertLevelRelativeFileNames(fileNames) {
31
- for (const fileName of fileNames) {
32
- if (path.isAbsolute(fileName)) {
33
- throw new Error(`Config file name must be relative to its directory level: ${fileName}`);
34
- }
35
- const normalized = path.normalize(fileName);
36
- if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
37
- throw new Error(`Config file name must not ascend above its directory level: ${fileName}`);
38
- }
39
- }
40
- }
41
- function collectCandidates(startDir, rootDir, fileNames) {
42
- const candidates = [];
43
- let dir = startDir;
44
- let previousDir = '';
45
- while (dir !== previousDir) {
46
- for (const fileName of fileNames) {
47
- const filePath = path.join(dir, fileName);
48
- if (fs.existsSync(filePath)) {
49
- candidates.push({ dir, filePath });
50
- break;
51
- }
52
- }
53
- if (dir === rootDir)
54
- break;
55
- previousDir = dir;
56
- dir = path.dirname(dir);
57
- }
58
- return candidates;
24
+ return { entries, stopReason };
59
25
  }
60
26
  async function importDefaultExport(filePath) {
61
27
  const configModule = await import(__rewriteRelativeImportExtension(pathToFileURL(filePath).href));
@@ -0,0 +1,18 @@
1
+ export declare function reconcileFile(filePath: string, content: string, options?: ReconcileFileOptions): FileReconciliation;
2
+ export type FileReconciliation = {
3
+ filePath: string;
4
+ outcome: 'created' | 'overwritten' | 'up-to-date';
5
+ } | {
6
+ filePath: string;
7
+ outcome: 'skipped';
8
+ error?: string | undefined;
9
+ } | {
10
+ filePath: string;
11
+ outcome: 'failed';
12
+ error: string;
13
+ };
14
+ export interface ReconcileFileOptions {
15
+ conflictPolicy?: 'replace' | 'skip' | undefined;
16
+ isDryRun?: boolean | undefined;
17
+ }
18
+ export type ReconciliationOutcome = FileReconciliation['outcome'];
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ export function reconcileFile(filePath, content, options = {}) {
5
+ const { conflictPolicy = 'skip', isDryRun = false } = options;
6
+ const doesFileExist = existsSync(filePath);
7
+ if (doesFileExist) {
8
+ const comparison = compareWithExisting(filePath, content, conflictPolicy);
9
+ if (comparison.isUpToDate)
10
+ return { filePath, outcome: 'up-to-date' };
11
+ if (conflictPolicy === 'skip') {
12
+ return comparison.error === undefined
13
+ ? { filePath, outcome: 'skipped' }
14
+ : { filePath, outcome: 'skipped', error: comparison.error };
15
+ }
16
+ }
17
+ const outcome = doesFileExist ? 'overwritten' : 'created';
18
+ if (isDryRun)
19
+ return { filePath, outcome };
20
+ try {
21
+ mkdirSync(path.dirname(filePath), { recursive: true });
22
+ writeFileSync(filePath, content, 'utf8');
23
+ }
24
+ catch (error) {
25
+ return { filePath, outcome: 'failed', error: describeError(error) };
26
+ }
27
+ return { filePath, outcome };
28
+ }
29
+ function compareWithExisting(filePath, content, conflictPolicy) {
30
+ let existingContent;
31
+ try {
32
+ existingContent = readFileSync(filePath, 'utf8');
33
+ }
34
+ catch (error) {
35
+ return { isUpToDate: false, error: describeError(error) };
36
+ }
37
+ if (conflictPolicy === 'replace') {
38
+ return { isUpToDate: existingContent === content };
39
+ }
40
+ return { isUpToDate: normalizeTrailingWhitespace(existingContent) === normalizeTrailingWhitespace(content) };
41
+ }
42
+ function normalizeTrailingWhitespace(content) {
43
+ return content
44
+ .split('\n')
45
+ .map((line) => line.trimEnd())
46
+ .join('\n')
47
+ .trimEnd();
48
+ }
@@ -0,0 +1,2 @@
1
+ import { type FileReconciliation, type ReconcileFileOptions } from './reconcileFile.js';
2
+ export declare function reconcileFileFromFile(filePath: string, sourcePath: string, options?: ReconcileFileOptions): FileReconciliation;
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { reconcileFile } from "./reconcileFile.js";
4
+ export function reconcileFileFromFile(filePath, sourcePath, options = {}) {
5
+ let content;
6
+ try {
7
+ content = readFileSync(sourcePath, 'utf8');
8
+ }
9
+ catch (error) {
10
+ return { filePath, outcome: 'failed', error: `Failed to read ${sourcePath}: ${describeError(error)}` };
11
+ }
12
+ return reconcileFile(filePath, content, options);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/toolbelt.filesystem",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Filesystem utilities",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/williamthorsen/toolbelt/tree/main/packages/filesystem#readme",
@@ -33,6 +33,9 @@
33
33
  "dist/*",
34
34
  "CHANGELOG.md"
35
35
  ],
36
+ "dependencies": {
37
+ "@williamthorsen/toolbelt.errors": "0.3.0"
38
+ },
36
39
  "engines": {
37
40
  "node": ">=24.0.0"
38
41
  },
@@ -1,11 +0,0 @@
1
- export declare const DEFAULT_ROOT_MARKERS: ReadonlyArray<string>;
2
- export declare function findProjectRoot(startDir: string, options?: FindProjectRootOptions): ProjectRoot;
3
- export interface FindProjectRootOptions {
4
- markers?: ReadonlyArray<string> | undefined;
5
- }
6
- export interface ProjectRoot {
7
- marker: string | null;
8
- rootDir: string;
9
- source: ProjectRootSource;
10
- }
11
- export type ProjectRootSource = 'marker' | 'package-json' | 'start-dir';
@@ -1,33 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- export const DEFAULT_ROOT_MARKERS = [
4
- '.git',
5
- 'pnpm-workspace.yaml',
6
- 'pnpm-lock.yaml',
7
- 'package-lock.json',
8
- 'yarn.lock',
9
- 'bun.lock',
10
- ];
11
- export function findProjectRoot(startDir, options = {}) {
12
- const { markers = DEFAULT_ROOT_MARKERS } = options;
13
- const resolvedStartDir = path.resolve(startDir);
14
- let nearestPackageJsonDir;
15
- let dir = resolvedStartDir;
16
- let previousDir = '';
17
- while (dir !== previousDir) {
18
- for (const marker of markers) {
19
- if (fs.existsSync(path.join(dir, marker))) {
20
- return { marker, rootDir: dir, source: 'marker' };
21
- }
22
- }
23
- if (nearestPackageJsonDir === undefined && fs.existsSync(path.join(dir, 'package.json'))) {
24
- nearestPackageJsonDir = dir;
25
- }
26
- previousDir = dir;
27
- dir = path.dirname(dir);
28
- }
29
- if (nearestPackageJsonDir !== undefined) {
30
- return { marker: null, rootDir: nearestPackageJsonDir, source: 'package-json' };
31
- }
32
- return { marker: null, rootDir: resolvedStartDir, source: 'start-dir' };
33
- }