@williamthorsen/toolbelt.filesystem 0.8.1 → 0.8.3
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 +26 -0
- package/README.md +17 -17
- package/dist/esm/1-proposed/replaceFileExtension.d.ts +20 -0
- package/dist/esm/1-proposed/writeAtomic.d.ts +23 -0
- package/dist/esm/3-candidate/createTempTree.d.ts +66 -0
- package/dist/esm/4-release/directory-chain-matches.d.ts +39 -0
- package/dist/esm/4-release/listDirectoryChain.d.ts +15 -0
- package/dist/esm/4-release/loadConfigCascade.d.ts +36 -0
- package/dist/esm/4-release/reconcileFile.d.ts +22 -0
- package/dist/esm/4-release/reconcileFileFromFile.d.ts +21 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.8.3 — 2026-09-06
|
|
6
|
+
|
|
7
|
+
### Documentation
|
|
8
|
+
|
|
9
|
+
- Repair reduced object relatives in passages recurring across files (#262)
|
|
10
|
+
|
|
11
|
+
Repairs the reduced object relative in the prose passages that recur across more than one file, in package READMEs, source comments, test titles, and the ReadyUp kits' check messages.
|
|
12
|
+
|
|
13
|
+
- Repair reduced object relatives in the READMEs and AGENTS.md (#263)
|
|
14
|
+
|
|
15
|
+
Repairs the reduced object relative in `AGENTS.md`, the root `README.md`, and the package READMEs.
|
|
16
|
+
|
|
17
|
+
- Repair reduced object relatives in packages/adoption (#264)
|
|
18
|
+
|
|
19
|
+
Repairs the reduced object relative in `packages/adoption`, in source comments, doc descriptions, and test titles.
|
|
20
|
+
|
|
21
|
+
- Repair the repository's prose and record every rejection's ground (#290)
|
|
22
|
+
|
|
23
|
+
Applies one repo-wide `revise-prose` sweep across the repository's READMEs, `AGENTS.md`, source comments, doc descriptions, and test names.
|
|
24
|
+
|
|
25
|
+
## 0.8.2 — 2026-08-30
|
|
26
|
+
|
|
27
|
+
### Dependency updates
|
|
28
|
+
|
|
29
|
+
- Bumped `@williamthorsen/toolbelt.errors` to 0.6.3
|
|
30
|
+
|
|
5
31
|
## 0.8.1 — 2026-08-28
|
|
6
32
|
|
|
7
33
|
### Refactoring
|
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ listDirectoryChainMatches('/home/dev/app/src', ['.git'], { stopAtDir: '/home/dev
|
|
|
73
73
|
|
|
74
74
|
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.
|
|
75
75
|
|
|
76
|
-
Each name is a path relative to the level it is probed
|
|
76
|
+
Each name is a path relative to the level against which it is probed, 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.
|
|
77
77
|
|
|
78
78
|
`options` is forwarded to `listDirectoryChain`, so `stopAtDir` bounds the ascent the same way.
|
|
79
79
|
|
|
@@ -155,7 +155,7 @@ const { entries, stopReason } = await loadConfigCascade<StackConfig>({
|
|
|
155
155
|
});
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
`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
|
|
158
|
+
`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` passed in by the caller.
|
|
159
159
|
|
|
160
160
|
## `reconcileFile`
|
|
161
161
|
|
|
@@ -176,18 +176,18 @@ reconcileFile('.config/tool.config.ts', template);
|
|
|
176
176
|
// { filePath: '.config/tool.config.ts', outcome: 'created' }
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
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
|
|
179
|
+
Missing parent directories are created. `isDryRun` writes nothing and creates no directory, returning the outcome that the real call would have produced, which is what lets a `--dry-run` flag print what the run itself would. A write that would fail is the exception: nothing detects that without attempting it, so a dry run reports the write's intended outcome.
|
|
180
180
|
|
|
181
|
-
`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.
|
|
181
|
+
`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 that the user may have edited.
|
|
182
182
|
|
|
183
183
|
| exists | differs | `conflictPolicy` | outcome |
|
|
184
184
|
| ------ | ------- | ---------------- | ------------- |
|
|
185
|
-
| no |
|
|
185
|
+
| no | n/a | n/a | `created` |
|
|
186
186
|
| yes | no | either | `up-to-date` |
|
|
187
187
|
| yes | yes | `replace` | `overwritten` |
|
|
188
188
|
| yes | yes | `skip` | `skipped` |
|
|
189
189
|
|
|
190
|
-
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:
|
|
190
|
+
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.
|
|
191
191
|
|
|
192
192
|
The result discriminates on `outcome`, so a failure always carries its reason:
|
|
193
193
|
|
|
@@ -204,7 +204,7 @@ Three behaviors are worth knowing before they surprise you:
|
|
|
204
204
|
|
|
205
205
|
- 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.
|
|
206
206
|
- 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.
|
|
207
|
-
- 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.
|
|
207
|
+
- 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 that this serves are scaffolding commands with no competing writer, and an exclusive-create flag would close only the create half of it.
|
|
208
208
|
|
|
209
209
|
## `reconcileFileFromFile`
|
|
210
210
|
|
|
@@ -266,7 +266,7 @@ import { createTempTree } from '@williamthorsen/toolbelt.filesystem/candidate';
|
|
|
266
266
|
|
|
267
267
|
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.
|
|
268
268
|
|
|
269
|
-
A value is text or the bytes themselves, so a body no UTF-8 round trip survives is as writable as a string:
|
|
269
|
+
A value is text or the bytes themselves, so a body that no UTF-8 round trip survives is as writable as a string:
|
|
270
270
|
|
|
271
271
|
```ts
|
|
272
272
|
using tree = createTempTree({ 'logo.png': pngBytes });
|
|
@@ -299,7 +299,7 @@ interface TempTree extends Disposable {
|
|
|
299
299
|
}
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
-
`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.
|
|
302
|
+
`dir` is realpath-resolved, because `os.tmpdir()` is a symlink on macOS and a caller comparing paths against it would otherwise see a mismatch that it did not cause.
|
|
303
303
|
|
|
304
304
|
`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.
|
|
305
305
|
|
|
@@ -313,9 +313,9 @@ tree.writeJson('tsconfig.json', { include: ['src'] });
|
|
|
313
313
|
tree.mkdir('packages/empty');
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
-
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
|
|
316
|
+
Each creates the parent directories that 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 held by the link rather than a location to which the tree writes.
|
|
317
317
|
|
|
318
|
-
`writeAll` takes the same map the constructor
|
|
318
|
+
`writeAll` takes the same map as the constructor, `/`-suffix convention included, so a fixture built in one call can be added to in one call:
|
|
319
319
|
|
|
320
320
|
```ts
|
|
321
321
|
tree.writeAll({ 'packages/empty/': '', 'packages/app/src/main.ts': 'export {};\n' });
|
|
@@ -334,7 +334,7 @@ tree.symlink('node_modules/kit', '../store/kit'); // reads back as '../store/kit
|
|
|
334
334
|
tree.symlink('node_modules/.bin', tree.resolve('store/kit/bin')); // reads back absolute
|
|
335
335
|
```
|
|
336
336
|
|
|
337
|
-
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
|
|
337
|
+
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 needed by a directory symlink; 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.
|
|
338
338
|
|
|
339
339
|
`exists`, `list`, `listFiles`, `read`, `readJson`, and `rm` read the tree back and remove from it, each through the same containment check:
|
|
340
340
|
|
|
@@ -350,11 +350,11 @@ tree.exists('packages/app/tsconfig.json'); // false
|
|
|
350
350
|
tree.rm('packages/app');
|
|
351
351
|
```
|
|
352
352
|
|
|
353
|
-
`listFiles` reaches every depth and reports paths relative to the directory
|
|
353
|
+
`listFiles` reaches every depth and reports paths relative to the directory given to it, sorted, with `/` as the separator on every platform: a path in a test's assertion is a value rather than a location, so `'app/src/main.ts'` should not vary by platform. It parts from `list` twice. A directory that is not there returns `[]` where `list` raises `ENOENT`, which is what lets a suite assert that a build emitted nothing without guarding the call; a path that exists as a file still raises `ENOTDIR`, as `list` does. And a symlink below the directory given to it is neither named nor descended, so every path in the result names a file held inside the tree, where `list` reports a link by name at its own level. The directory given as the argument is the exception, followed as `list`, `read`, and `exists` follow theirs: one naming a link out of the tree lists the target's files.
|
|
354
354
|
|
|
355
|
-
`read` returns UTF-8 text, and a missing entry raises `ENOENT` rather than
|
|
355
|
+
`read` returns UTF-8 text, and a missing entry raises `ENOENT` rather than returning an empty string -- `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 it returns `false` for a dangling one. `rm` is recursive and silent on an entry that is not there.
|
|
356
356
|
|
|
357
|
-
`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.
|
|
357
|
+
`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 that `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.
|
|
358
358
|
|
|
359
359
|
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.
|
|
360
360
|
|
|
@@ -404,7 +404,7 @@ import { writeAtomic } from '@williamthorsen/toolbelt.filesystem/proposed';
|
|
|
404
404
|
await writeAtomic('.agents/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
-
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.
|
|
407
|
+
The temp file is a sibling of the target, which is the part that 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.
|
|
408
408
|
|
|
409
409
|
Missing parent directories are created, as they are for [`reconcileFile`](#reconcilefile).
|
|
410
410
|
|
|
@@ -412,6 +412,6 @@ An existing target's permission bits are carried onto the replacement. A plain `
|
|
|
412
412
|
|
|
413
413
|
Three behaviors are worth knowing before they surprise you:
|
|
414
414
|
|
|
415
|
-
- Nothing is fsynced. "Atomic" here means no torn reads, not survives-power-loss: a write this function has returned
|
|
415
|
+
- Nothing is fsynced. "Atomic" here means no torn reads, not survives-power-loss: a write from which this function has returned can still be lost to a power failure. A durability option is additive if a caller ever needs one.
|
|
416
416
|
- 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.
|
|
417
417
|
- 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.
|
|
@@ -1,4 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns `filePath` with its extension replaced by `newExtension`.
|
|
3
|
+
*
|
|
4
|
+
* The extension is the substring beginning at the final period in the file name, which must come at the very end
|
|
5
|
+
* of the path. A multi-part extension is indivisible to `path.extname` (`.d.ts` reports as `.ts`), so declare one
|
|
6
|
+
* through `oldExtension` to replace it whole.
|
|
7
|
+
*
|
|
8
|
+
* Either extension may be written with or without its leading period, so `'js'` and `'.js'` are equivalent. An
|
|
9
|
+
* empty `newExtension` removes the extension. Throws when `filePath` ends with a separator, and when `filePath`
|
|
10
|
+
* does not end with a declared `oldExtension`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* replaceFileExtension('src/main.ts', '.js'); // 'src/main.js'
|
|
14
|
+
* replaceFileExtension('src/main.d.ts', '.js', { oldExtension: '.d.ts' }); // 'src/main.js'
|
|
15
|
+
*
|
|
16
|
+
* @category Filesystem
|
|
17
|
+
* @experimental
|
|
18
|
+
* @stage proposed
|
|
19
|
+
*/
|
|
1
20
|
export declare function replaceFileExtension(filePath: string, newExtension: string, options?: ReplaceFileExtensionOptions): string;
|
|
2
21
|
export interface ReplaceFileExtensionOptions {
|
|
22
|
+
/** The extension to replace; defaults to the value returned by Node's `path.extname`. */
|
|
3
23
|
oldExtension?: string | undefined;
|
|
4
24
|
}
|
|
@@ -1 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `content` to `filePath` through a sibling temp file and a rename, so a concurrent reader sees either the
|
|
3
|
+
* previous file or the complete new one, never a partial write. Siting the temp file beside the target is what
|
|
4
|
+
* keeps the rename within one filesystem, where it is atomic.
|
|
5
|
+
*
|
|
6
|
+
* Missing parent directories are created. An existing target's permission bits are carried onto the replacement,
|
|
7
|
+
* which a bare `writeFile` would preserve by truncating in place and a rename would otherwise reset to the
|
|
8
|
+
* platform default.
|
|
9
|
+
*
|
|
10
|
+
* Two guarantees that it does not make. Nothing is fsynced, so a power loss can lose a write from which this
|
|
11
|
+
* function has already returned; "atomic" here means no torn reads. And the rename replaces the target's directory
|
|
12
|
+
* entry, so a symlink at `filePath` becomes a regular file rather than being written through.
|
|
13
|
+
*
|
|
14
|
+
* A failure removes the temp file best-effort and rethrows the error that caused it. Where the cleanup itself
|
|
15
|
+
* fails, the temp file survives beside the target under a dot-prefixed name ending in `.tmp`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* await writeAtomic('.agents/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
|
|
19
|
+
*
|
|
20
|
+
* @category Filesystem
|
|
21
|
+
* @experimental
|
|
22
|
+
* @stage proposed
|
|
23
|
+
*/
|
|
1
24
|
export declare function writeAtomic(filePath: string, content: string | Uint8Array): Promise<void>;
|
|
@@ -1,19 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a throwaway directory tree and returns a handle that removes it on disposal. Each key of `entries` is a
|
|
3
|
+
* path relative to the tree root: One ending in `/` becomes a directory, and any other becomes a file holding the
|
|
4
|
+
* mapped contents, given as text or as the bytes themselves. A key resolving outside the root is rejected, and a
|
|
5
|
+
* call that throws leaves nothing on disk.
|
|
6
|
+
*
|
|
7
|
+
* The handle writes into the tree after it is built, through `mkdir`, `symlink`, `write`, `writeAll`, and
|
|
8
|
+
* `writeJson`. Each creates the parent directories that it needs and takes its entry path through the containment
|
|
9
|
+
* check applied by `resolve`; `symlink`'s target is the exception, stored verbatim. Every one but `writeAll`,
|
|
10
|
+
* which takes a map, returns the absolute path that it wrote. It reads the tree back through `exists`, `list`,
|
|
11
|
+
* `listFiles`, `read`, and `readJson`, and removes an entry through `rm`.
|
|
12
|
+
*
|
|
13
|
+
* `prefix` names the directory, so a tree outliving a crashed run still shows what made it.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* using tree = createTempTree({ '.git/': '', 'src/main.ts': 'export {};\n' });
|
|
17
|
+
* tree.resolve('src/main.ts'); // '/private/var/folders/…/toolbelt-a1b2c3/src/main.ts'
|
|
18
|
+
*
|
|
19
|
+
* @category Filesystem
|
|
20
|
+
* @experimental
|
|
21
|
+
* @stage candidate
|
|
22
|
+
*/
|
|
1
23
|
export declare function createTempTree(entries: Record<string, string | Uint8Array>, options?: CreateTempTreeOptions): TempTree;
|
|
2
24
|
export interface CreateTempTreeOptions {
|
|
25
|
+
/** Leading text of the generated directory's name, to which a random suffix is appended. Defaults to `toolbelt-`. */
|
|
3
26
|
prefix?: string;
|
|
4
27
|
}
|
|
5
28
|
export interface TempTree extends Disposable {
|
|
29
|
+
/** Realpath of the tree root, resolved because `os.tmpdir()` is a symlink on macOS. */
|
|
6
30
|
readonly dir: string;
|
|
31
|
+
/** Reports whether a tree-relative path exists, following a symlink, so a dangling one yields `false`. */
|
|
7
32
|
exists(entryPath: string): boolean;
|
|
33
|
+
/** Lists the names directly inside a tree-relative directory, sorted, defaulting to the tree root. */
|
|
8
34
|
list(entryPath?: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Lists every file below a tree-relative directory, at any depth, as `/`-separated paths relative to it, sorted,
|
|
37
|
+
* defaulting to the tree root. A symlink below that directory is neither listed nor descended, so every path in
|
|
38
|
+
* the result names a file held inside the tree. The directory given as the argument is the exception, followed as
|
|
39
|
+
* `list`, `read`, and `exists` follow theirs: One naming a link out of the tree lists the target's files. A path
|
|
40
|
+
* that does not exist yields an empty array, where `list` throws; one that exists as a file raises `ENOTDIR`, as
|
|
41
|
+
* `list` does.
|
|
42
|
+
*/
|
|
9
43
|
listFiles(entryPath?: string): string[];
|
|
44
|
+
/** Creates the directory at a tree-relative path, along with its parents, leaving an existing one as it is. */
|
|
10
45
|
mkdir(entryPath: string): string;
|
|
46
|
+
/** Reads the file at a tree-relative path as UTF-8 text. */
|
|
11
47
|
read(entryPath: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Reads the file at a tree-relative path as JSON. The result is `unknown`, for the caller to narrow; contents
|
|
50
|
+
* that do not parse raise an error naming the entry, which the parse error alone does not.
|
|
51
|
+
*/
|
|
12
52
|
readJson(entryPath: string): unknown;
|
|
53
|
+
/**
|
|
54
|
+
* Resolves `segments` against the tree root, throwing when the result falls outside it. An absolute segment
|
|
55
|
+
* landing inside the root is returned. The containment test is lexical, so it does not follow a symlink within
|
|
56
|
+
* the tree that points out of it.
|
|
57
|
+
*/
|
|
13
58
|
resolve(...segments: string[]): string;
|
|
59
|
+
/** Removes a tree-relative entry, along with its contents where it is a directory, and a missing one silently. */
|
|
14
60
|
rm(entryPath: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Links a tree-relative path to `targetPath`, taking the link first and so inverting `fs.symlinkSync`. The target
|
|
63
|
+
* is stored verbatim and is not containment-checked, being a string held by the link rather than a location to
|
|
64
|
+
* which the tree writes: It may be absolute or relative, name something outside the tree, or dangle. A relative one
|
|
65
|
+
* resolves against the link's own directory, as POSIX resolves it. An occupied link path raises `EEXIST`.
|
|
66
|
+
*
|
|
67
|
+
* The link type is the one portability difference. An absolute directory target is linked as a junction, which
|
|
68
|
+
* Windows creates without the elevation needed by a directory symlink; a relative directory target is linked as a
|
|
69
|
+
* directory, which needs that elevation; every other target, a missing one included, is linked as a file.
|
|
70
|
+
*/
|
|
15
71
|
symlink(linkPath: string, targetPath: string): string;
|
|
72
|
+
/** Writes `contents` at a tree-relative path, replacing an existing file. */
|
|
16
73
|
write(entryPath: string, contents: string | Uint8Array): string;
|
|
74
|
+
/**
|
|
75
|
+
* Writes a map of entries in the shape taken by the constructor, so a fixture built in one call there can be
|
|
76
|
+
* added to in one call here. Unlike the constructor, a failure part-way leaves the entries already written in
|
|
77
|
+
* place, there being no whole tree to discard.
|
|
78
|
+
*/
|
|
17
79
|
writeAll(entries: Record<string, string | Uint8Array>): void;
|
|
80
|
+
/**
|
|
81
|
+
* Writes `value` at a tree-relative path as two-space-indented JSON ending in a newline. A value with no JSON
|
|
82
|
+
* representation -- `undefined`, a function, a symbol -- is refused rather than written.
|
|
83
|
+
*/
|
|
18
84
|
writeJson(entryPath: string, value: unknown): string;
|
|
19
85
|
}
|
|
@@ -1,9 +1,48 @@
|
|
|
1
1
|
import { type ListDirectoryChainOptions } from './listDirectoryChain.js';
|
|
2
2
|
export interface DirectoryChainMatch {
|
|
3
|
+
/** The chain level at which the match was found, which differs from the entry's own directory for a nested name. */
|
|
3
4
|
dir: string;
|
|
4
5
|
entryName: string;
|
|
5
6
|
entryPath: string;
|
|
6
7
|
}
|
|
8
|
+
/** Forwarded to `listDirectoryChain`. */
|
|
7
9
|
export type DirectoryChainMatchOptions = ListDirectoryChainOptions;
|
|
10
|
+
/**
|
|
11
|
+
* Returns the nearest directory at or above `startDir` holding one of `names`, or `undefined` when none does.
|
|
12
|
+
*
|
|
13
|
+
* Probing stops at the first level that matches, so no level beyond it is touched. Where every level's match
|
|
14
|
+
* matters rather than the nearest, `listDirectoryChainMatches` collects them all.
|
|
15
|
+
*
|
|
16
|
+
* Each name is a path relative to the level against which it is probed, so a nested location such as
|
|
17
|
+
* `.config/stack.config.mjs` works. A name that would leave its level is rejected before any level is probed.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* findDirectoryChainMatch('/home/dev/app/src', ['.git']);
|
|
21
|
+
* // { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }
|
|
22
|
+
*
|
|
23
|
+
* @category Filesystem
|
|
24
|
+
* @stage release
|
|
25
|
+
* @throws If a name is absolute or escapes its level, or if `stopAtDir` is not on the chain.
|
|
26
|
+
*/
|
|
8
27
|
export declare function findDirectoryChainMatch(startDir: string, names: ReadonlyArray<string>, options?: DirectoryChainMatchOptions): DirectoryChainMatch | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Returns, for each directory in the chain at or above `startDir`, the first of `names` that exists there.
|
|
30
|
+
*
|
|
31
|
+
* Ordering is nearest first, and a level yields at most one match: the earliest of `names` found there. A level
|
|
32
|
+
* holding none contributes nothing, so an empty result is an ordinary outcome rather than an error. A name matches
|
|
33
|
+
* a directory as readily as a file.
|
|
34
|
+
*
|
|
35
|
+
* Every level is probed. Where only the nearest match matters, `findDirectoryChainMatch` stops at the first.
|
|
36
|
+
*
|
|
37
|
+
* Each name is a path relative to the level against which it is probed, so a nested location such as
|
|
38
|
+
* `.config/stack.config.mjs` works. A name that would leave its level is rejected before any level is probed.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* listDirectoryChainMatches('/home/dev/app/src', ['.git'], { stopAtDir: '/home/dev' });
|
|
42
|
+
* // [{ dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }]
|
|
43
|
+
*
|
|
44
|
+
* @category Filesystem
|
|
45
|
+
* @stage release
|
|
46
|
+
* @throws If a name is absolute or escapes its level, or if `stopAtDir` is not on the chain.
|
|
47
|
+
*/
|
|
9
48
|
export declare function listDirectoryChainMatches(startDir: string, names: ReadonlyArray<string>, options?: DirectoryChainMatchOptions): DirectoryChainMatch[];
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns `startDir` resolved to an absolute path, followed by each of its ancestors, nearest first.
|
|
3
|
+
*
|
|
4
|
+
* The chain runs to the filesystem root unless `stopAtDir` bounds it, and always holds at least the start
|
|
5
|
+
* directory, which is what the return type records. Paths are manipulated as strings; nothing is read from disk.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* listDirectoryChain('/home/dev/app/src', { stopAtDir: '/home/dev' });
|
|
9
|
+
* // ['/home/dev/app/src', '/home/dev/app', '/home/dev']
|
|
10
|
+
*
|
|
11
|
+
* @category Filesystem
|
|
12
|
+
* @stage release
|
|
13
|
+
* @throws If `stopAtDir` is neither the start directory nor one of its ancestors.
|
|
14
|
+
*/
|
|
1
15
|
export declare function listDirectoryChain(startDir: string, options?: ListDirectoryChainOptions): [string, ...string[]];
|
|
2
16
|
export interface ListDirectoryChainOptions {
|
|
17
|
+
/** Bounds the ascent, inclusive. Must be the start directory or one of its ancestors. */
|
|
3
18
|
stopAtDir?: string | undefined;
|
|
4
19
|
}
|
|
@@ -1,17 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads every config file between a starting directory and `stopAtDir`, nearest first.
|
|
3
|
+
*
|
|
4
|
+
* At each level from `startDir` up to and including `stopAtDir`, the first of `fileNames` that exists
|
|
5
|
+
* is taken as that level's config; levels holding none contribute nothing. The matched files are then
|
|
6
|
+
* imported one at a time, nearest first, and `shouldStopAscent` is consulted after each: Once it returns
|
|
7
|
+
* true, the ascent halts and no farther file is imported. Nothing above `stopAtDir` is ever read:
|
|
8
|
+
* Each name must stay within the level against which it is probed, so one that escapes is rejected up front.
|
|
9
|
+
*
|
|
10
|
+
* The boundary is the caller's to choose, which is what keeps this function free of any notion of what
|
|
11
|
+
* marks a project. `findProjectRoot` from `@williamthorsen/toolbelt.packaging` resolves one from markers.
|
|
12
|
+
*
|
|
13
|
+
* Each config is the module's default export; validating its contents is the caller's job.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const { entries } = await loadConfigCascade({
|
|
17
|
+
* fileNames: ['stack.config.mjs'],
|
|
18
|
+
* shouldStopAscent: (config) => config.shouldStopAscent === true,
|
|
19
|
+
* startDir: process.cwd(),
|
|
20
|
+
* stopAtDir: findProjectRoot(process.cwd()).rootDir,
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* @category Filesystem
|
|
24
|
+
* @stage release
|
|
25
|
+
* @throws If a file name is absolute or escapes its level, if `stopAtDir` is off the chain, or if a matched
|
|
26
|
+
* file has no default export.
|
|
27
|
+
*/
|
|
1
28
|
export declare function loadConfigCascade<TConfig = unknown>(options: LoadConfigCascadeOptions<TConfig>): Promise<ConfigCascade<TConfig>>;
|
|
2
29
|
export type CascadeStopReason = 'predicate' | 'stop-dir';
|
|
3
30
|
export interface ConfigCascade<TConfig> {
|
|
31
|
+
/** Ordered nearest first, starting at `startDir`. */
|
|
4
32
|
entries: ConfigEntry<TConfig>[];
|
|
5
33
|
stopReason: CascadeStopReason;
|
|
6
34
|
}
|
|
7
35
|
export interface ConfigEntry<TConfig> {
|
|
8
36
|
config: TConfig;
|
|
37
|
+
/** The cascade level at which the file was found, which differs from the file's own directory when `fileNames` holds a nested path. */
|
|
9
38
|
dir: string;
|
|
10
39
|
filePath: string;
|
|
11
40
|
}
|
|
12
41
|
export interface LoadConfigCascadeOptions<TConfig> {
|
|
42
|
+
/**
|
|
43
|
+
* Paths relative to each level, in precedence order; the first that exists at a level is that level's config.
|
|
44
|
+
* A name that leaves its level (an absolute path, or one whose `..` segments escape it) is rejected, since
|
|
45
|
+
* it would read outside the bounds that the cascade exists to enforce.
|
|
46
|
+
*/
|
|
13
47
|
fileNames: ReadonlyArray<string>;
|
|
48
|
+
/** Called after each config is loaded; returning true halts the ascent before the next import. */
|
|
14
49
|
shouldStopAscent?: ((config: TConfig) => boolean) | undefined;
|
|
15
50
|
startDir: string;
|
|
51
|
+
/** Bounds the ascent, inclusive. Must be the start directory or one of its ancestors. */
|
|
16
52
|
stopAtDir: string;
|
|
17
53
|
}
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes `content` to `filePath`, reporting what the write took rather than throwing.
|
|
3
|
+
*
|
|
4
|
+
* Missing parent directories are created. An existing file is compared against `content` first, and what counts
|
|
5
|
+
* as a difference follows `conflictPolicy`: `'replace'` promises the file holds exactly `content` afterwards, so
|
|
6
|
+
* only byte-identical content reports `up-to-date`; `'skip'` modifies nothing either way, so its comparison
|
|
7
|
+
* ignores trailing whitespace per line and at end of file, which keeps formatter churn from reading as a
|
|
8
|
+
* conflict. `up-to-date` therefore means the same thing under both policies: This one has no work to do.
|
|
9
|
+
*
|
|
10
|
+
* An I/O error is reported as `failed` rather than thrown, which is what lets a caller writing several files
|
|
11
|
+
* collect a result for each instead of losing the rest to the first failure. A dry run writes nothing and
|
|
12
|
+
* creates no directory, returning the outcome that the real call would have produced, short of a write failure,
|
|
13
|
+
* which nothing detects without attempting the write.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* reconcileFile('.config/tool.config.ts', template, { conflictPolicy: 'replace' });
|
|
17
|
+
* // { filePath: '.config/tool.config.ts', outcome: 'overwritten' }
|
|
18
|
+
*
|
|
19
|
+
* @category Filesystem
|
|
20
|
+
* @stage release
|
|
21
|
+
*/
|
|
1
22
|
export declare function reconcileFile(filePath: string, content: string, options?: ReconcileFileOptions): FileReconciliation;
|
|
2
23
|
export type FileReconciliation = {
|
|
3
24
|
filePath: string;
|
|
@@ -12,6 +33,7 @@ export type FileReconciliation = {
|
|
|
12
33
|
error: string;
|
|
13
34
|
};
|
|
14
35
|
export interface ReconcileFileOptions {
|
|
36
|
+
/** What to do with an existing file whose content differs. Defaults to `'skip'`, which never destroys one. */
|
|
15
37
|
conflictPolicy?: 'replace' | 'skip' | undefined;
|
|
16
38
|
isDryRun?: boolean | undefined;
|
|
17
39
|
}
|
|
@@ -1,2 +1,23 @@
|
|
|
1
1
|
import { type FileReconciliation, type ReconcileFileOptions } from './reconcileFile.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reconciles `filePath` against the utf8 text of `sourcePath`, reporting what the write took rather than throwing.
|
|
4
|
+
*
|
|
5
|
+
* Everything past the read is `reconcileFile`: The comparison, the conflict policy, the created parent directories,
|
|
6
|
+
* and the outcome vocabulary are its. A source that cannot be read reports `failed`, and a missing source is not
|
|
7
|
+
* distinguished from an unreadable one. The reason names `sourcePath` and the cause; the path is interpolated
|
|
8
|
+
* because a read-stage failure carries none of its own (`EISDIR: illegal operation on a directory, read`) and the
|
|
9
|
+
* result's `filePath` is the destination.
|
|
10
|
+
*
|
|
11
|
+
* The source is read even under `isDryRun`, because the outcome depends on comparing its content, so a dry run can
|
|
12
|
+
* report `failed` where `reconcileFile` cannot. It still writes nothing.
|
|
13
|
+
*
|
|
14
|
+
* The read is utf8 text, so a binary source is not supported.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* reconcileFileFromFile('.config/git-cliff.toml', bundledTemplatePath);
|
|
18
|
+
* // { filePath: '.config/git-cliff.toml', outcome: 'created' }
|
|
19
|
+
*
|
|
20
|
+
* @category Filesystem
|
|
21
|
+
* @stage release
|
|
22
|
+
*/
|
|
2
23
|
export declare function reconcileFileFromFile(filePath: string, sourcePath: string, options?: ReconcileFileOptions): FileReconciliation;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/toolbelt.filesystem",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
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.7.0"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
50
50
|
"node": ">=24.0.0"
|