@williamthorsen/toolbelt.filesystem 0.0.0 → 0.2.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 +13 -0
- package/LICENSE +7 -0
- package/README.md +117 -1
- package/dist/esm/1-proposed/index.d.ts +1 -0
- package/dist/esm/1-proposed/index.js +1 -0
- package/dist/esm/2-draft/index.d.ts +1 -0
- package/dist/esm/2-draft/index.js +1 -0
- package/dist/esm/3-candidate/index.d.ts +1 -0
- package/dist/esm/3-candidate/index.js +1 -0
- package/dist/esm/4-release/findProjectRoot.d.ts +11 -0
- package/dist/esm/4-release/findProjectRoot.js +33 -0
- package/dist/esm/4-release/index.d.ts +2 -0
- package/dist/esm/4-release/index.js +2 -0
- package/dist/esm/4-release/loadConfigCascade.d.ts +19 -0
- package/dist/esm/4-release/loadConfigCascade.js +66 -0
- package/package.json +42 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 — 2026-07-24
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Add the filesystem package with bounded cascading config discovery (#71)
|
|
10
|
+
|
|
11
|
+
Adds a new package, `@williamthorsen/toolbelt.filesystem`, and its first functions: `findProjectRoot`, which locates a project's root directory, and `loadConfigCascade`, which loads the configuration files layered across the directories between a starting point and that root.
|
|
12
|
+
|
|
13
|
+
<!-- Generated by release-kit. Do not edit this file. Use .meta/changelog-overrides.json to override entries. -->
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
UNLICENSED SOFTWARE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 William Thorsen
|
|
4
|
+
|
|
5
|
+
All rights reserved.
|
|
6
|
+
|
|
7
|
+
This software is provided 'as-is', without any express or implied warranty. No rights are granted to use, copy, modify, or distribute this software for any purpose. For full legal details, consult a legal expert. This notice may not be removed or altered from any distribution.
|
package/README.md
CHANGED
|
@@ -1 +1,117 @@
|
|
|
1
|
-
|
|
1
|
+
# @williamthorsen/toolbelt.filesystem
|
|
2
|
+
|
|
3
|
+
Filesystem utilities for TypeScript and JavaScript.
|
|
4
|
+
|
|
5
|
+
<!-- section:release-notes -->
|
|
6
|
+
## Release notes — v0.2.0 (2026-07-24)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Add the filesystem package with bounded cascading config discovery (#71)
|
|
11
|
+
|
|
12
|
+
Adds a new package, `@williamthorsen/toolbelt.filesystem`, and its first functions: `findProjectRoot`, which locates a project's root directory, and `loadConfigCascade`, which loads the configuration files layered across the directories between a starting point and that root.
|
|
13
|
+
<!-- /section:release-notes -->
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add @williamthorsen/toolbelt.filesystem
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Runtime requirements
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
`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.
|
|
26
|
+
|
|
27
|
+
## `findProjectRoot`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
findProjectRoot(startDir: string, options?: { markers?: ReadonlyArray<string> }): ProjectRoot;
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
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:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface ProjectRoot {
|
|
37
|
+
marker: string | null; // the marker that matched, or null when a fallback answered
|
|
38
|
+
rootDir: string;
|
|
39
|
+
source: 'marker' | 'package-json' | 'start-dir';
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`DEFAULT_ROOT_MARKERS` is consulted in order, so the earliest entry wins when one directory carries several:
|
|
44
|
+
|
|
45
|
+
1. `.git`, matching either a directory (an ordinary clone) or a file (a worktree or submodule);
|
|
46
|
+
2. `pnpm-workspace.yaml`;
|
|
47
|
+
3. `pnpm-lock.yaml`;
|
|
48
|
+
4. `package-lock.json`;
|
|
49
|
+
5. `yarn.lock`;
|
|
50
|
+
6. `bun.lock`.
|
|
51
|
+
|
|
52
|
+
Passing `markers` replaces that list rather than extending it. Spread `DEFAULT_ROOT_MARKERS` to add to it:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { DEFAULT_ROOT_MARKERS, findProjectRoot } from '@williamthorsen/toolbelt.filesystem';
|
|
56
|
+
|
|
57
|
+
findProjectRoot(process.cwd(), { markers: [...DEFAULT_ROOT_MARKERS, 'deno.json'] });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
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:
|
|
61
|
+
|
|
62
|
+
1. the nearest ancestor holding a `package.json`, reported as `source: 'package-json'`;
|
|
63
|
+
2. `startDir` itself, reported as `source: 'start-dir'`.
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
## `loadConfigCascade`
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
loadConfigCascade<TConfig>(options: {
|
|
71
|
+
fileNames: ReadonlyArray<string>;
|
|
72
|
+
markers?: ReadonlyArray<string>;
|
|
73
|
+
shouldStopAscent?: (config: TConfig) => boolean;
|
|
74
|
+
startDir: string;
|
|
75
|
+
}): Promise<ConfigCascade<TConfig>>;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Loads every config file between `startDir` and its project root, nearest first, and reads nothing above that root.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
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:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
interface ConfigCascade<TConfig> {
|
|
86
|
+
entries: Array<{
|
|
87
|
+
config: TConfig;
|
|
88
|
+
dir: string; // the cascade level, which differs from the file's own directory for a nested file name
|
|
89
|
+
filePath: string;
|
|
90
|
+
}>;
|
|
91
|
+
projectRoot: ProjectRoot;
|
|
92
|
+
stopReason: 'predicate' | 'project-root';
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A config is the module's default export. A matched module declaring none is rejected by name; validating what a config contains stays with the caller, which is what lets one mechanism serve schemas sharing no fields.
|
|
97
|
+
|
|
98
|
+
### The `shouldStopAscent` convention
|
|
99
|
+
|
|
100
|
+
The predicate is the caller's whole stop policy, so any field can drive it. By convention a config declares a boolean `shouldStopAscent`, which a consumer reads directly:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { loadConfigCascade } from '@williamthorsen/toolbelt.filesystem';
|
|
104
|
+
|
|
105
|
+
interface StackConfig {
|
|
106
|
+
rules?: Record<string, string>;
|
|
107
|
+
shouldStopAscent?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { entries, projectRoot, stopReason } = await loadConfigCascade<StackConfig>({
|
|
111
|
+
fileNames: ['stack.config.mjs', 'stack.config.js'],
|
|
112
|
+
shouldStopAscent: (config) => config.shouldStopAscent === true,
|
|
113
|
+
startDir: process.cwd(),
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`projectRoot` and `stopReason` are provenance for the caller to surface, so a user can see which directory bounded the cascade and what ended it.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
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';
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { DEFAULT_ROOT_MARKERS, findProjectRoot, type FindProjectRootOptions, type ProjectRoot, type ProjectRootSource, } from './findProjectRoot.js';
|
|
2
|
+
export { type CascadeStopReason, type ConfigCascade, type ConfigEntry, loadConfigCascade, type LoadConfigCascadeOptions, } from './loadConfigCascade.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ProjectRoot } from './findProjectRoot.js';
|
|
2
|
+
export declare function loadConfigCascade<TConfig = unknown>(options: LoadConfigCascadeOptions<TConfig>): Promise<ConfigCascade<TConfig>>;
|
|
3
|
+
export type CascadeStopReason = 'predicate' | 'project-root';
|
|
4
|
+
export interface ConfigCascade<TConfig> {
|
|
5
|
+
entries: ConfigEntry<TConfig>[];
|
|
6
|
+
projectRoot: ProjectRoot;
|
|
7
|
+
stopReason: CascadeStopReason;
|
|
8
|
+
}
|
|
9
|
+
export interface ConfigEntry<TConfig> {
|
|
10
|
+
config: TConfig;
|
|
11
|
+
dir: string;
|
|
12
|
+
filePath: string;
|
|
13
|
+
}
|
|
14
|
+
export interface LoadConfigCascadeOptions<TConfig> {
|
|
15
|
+
fileNames: ReadonlyArray<string>;
|
|
16
|
+
markers?: ReadonlyArray<string> | undefined;
|
|
17
|
+
shouldStopAscent?: ((config: TConfig) => boolean) | undefined;
|
|
18
|
+
startDir: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { findProjectRoot } from "./findProjectRoot.js";
|
|
13
|
+
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);
|
|
18
|
+
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 });
|
|
23
|
+
if (shouldStopAscent?.(config) === true) {
|
|
24
|
+
stopReason = 'predicate';
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
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;
|
|
59
|
+
}
|
|
60
|
+
async function importDefaultExport(filePath) {
|
|
61
|
+
const configModule = await import(__rewriteRelativeImportExtension(pathToFileURL(filePath).href));
|
|
62
|
+
if (!('default' in configModule)) {
|
|
63
|
+
throw new Error(`Config file has no default export: ${filePath}`);
|
|
64
|
+
}
|
|
65
|
+
return configModule.default;
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/toolbelt.filesystem",
|
|
3
|
-
"version": "0.
|
|
4
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Filesystem utilities",
|
|
5
|
+
"keywords": [],
|
|
6
|
+
"homepage": "https://github.com/williamthorsen/toolbelt/tree/main/packages/filesystem#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/williamthorsen/toolbelt/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/williamthorsen/toolbelt.git",
|
|
13
|
+
"directory": "packages/filesystem"
|
|
14
|
+
},
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/esm/4-release/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./candidate": {
|
|
23
|
+
"import": "./dist/esm/3-candidate/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./draft": {
|
|
26
|
+
"import": "./dist/esm/2-draft/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./proposed": {
|
|
29
|
+
"import": "./dist/esm/1-proposed/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist/*",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24.0.0"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {}
|
|
44
|
+
}
|