@williamthorsen/toolbelt.filesystem 0.3.0 → 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,24 @@
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
+
5
23
  ## 0.3.0 — 2026-08-08
6
24
 
7
25
  ### Features
package/README.md CHANGED
@@ -3,33 +3,23 @@
3
3
  Filesystem utilities for TypeScript and JavaScript.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v0.3.0 (2026-08-08)
6
+ ## Release notes — v0.4.0 (2026-08-12)
7
7
 
8
8
  ### Features
9
9
 
10
- - Migrate replaceFileExtension into filesystem package (#74)
10
+ - 🚨 **Breaking:** Add reconcileFile to toolbelt.filesystem and promote describeError to release tier (#122)
11
11
 
12
- 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`.
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
13
 
14
- - Add directory-chain ascent and lookup exports (#102)
14
+ Separately, `describeError` is promoted to `@williamthorsen/toolbelt.errors`' release tier, so no release-tier module depends on a candidate one.
15
15
 
16
- 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:
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
17
 
18
- - `listDirectoryChain` returns the directories alone
19
- - `listDirectoryChainMatches` returns every level's match
20
- - `findDirectoryChainMatch` finds only the nearest match
18
+ - Add reconcileFileFromFile to toolbelt.filesystem (#123)
21
19
 
22
- All three reject a path that falls outside the range they were asked to search. `findProjectRoot` now applies that same rule to its markers.
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.
23
21
 
24
- - Add createTempTree with scope-bound disposal (#106)
25
-
26
- 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.
27
-
28
- - 🚨 **Breaking:** Add findPackageRoot, getSelfVersion, and findProjectRoot to toolbelt.packaging (#107)
29
-
30
- 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.
31
-
32
- `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.
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.
33
23
  <!-- /section:release-notes -->
34
24
 
35
25
  ## Installation
@@ -40,7 +30,7 @@ pnpm add @williamthorsen/toolbelt.filesystem
40
30
 
41
31
  ## Runtime requirements
42
32
 
43
- `createTempTree`, `findDirectoryChainMatch`, `listDirectoryChainMatches`, and `loadConfigCascade` 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.
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.
44
34
 
45
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.
46
36
 
@@ -185,6 +175,88 @@ const { entries, stopReason } = await loadConfigCascade<StackConfig>({
185
175
 
186
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.
187
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
+
188
260
  ## `createTempTree`
189
261
 
190
262
  Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
@@ -1,3 +1,5 @@
1
1
  export { type DirectoryChainMatch, type DirectoryChainMatchOptions, findDirectoryChainMatch, listDirectoryChainMatches, } from './directory-chain-matches.js';
2
2
  export { listDirectoryChain, type ListDirectoryChainOptions } from './listDirectoryChain.js';
3
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,3 +1,5 @@
1
1
  export { findDirectoryChainMatch, listDirectoryChainMatches, } from "./directory-chain-matches.js";
2
2
  export { listDirectoryChain } from "./listDirectoryChain.js";
3
3
  export { loadConfigCascade, } from "./loadConfigCascade.js";
4
+ export { reconcileFile, } from "./reconcileFile.js";
5
+ export { reconcileFileFromFile } from "./reconcileFileFromFile.js";
@@ -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.3.0",
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
  },