@theholocron/fs-utils 1.9.2 → 1.11.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/README.md +50 -1
- package/dist/index.d.mts +66 -0
- package/dist/index.mjs +151 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,11 @@ pnpm add @theholocron/fs-utils
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import { findUpward, hasGitEntry } from "@theholocron/fs-utils";
|
|
14
|
+
import { findExecutable, findExecutableSync, findRoot, findUpward, hasGitEntry } from "@theholocron/fs-utils";
|
|
15
|
+
|
|
16
|
+
// nearest ancestor holding a marker file or directory
|
|
17
|
+
const repoRoot = await findRoot(process.cwd(), ".git");
|
|
18
|
+
const monorepoRoot = await findRoot(process.cwd(), ["pnpm-workspace.yaml", "turbo.json"]);
|
|
15
19
|
|
|
16
20
|
// find the nearest ancestor holding a config file, stopping at the repo root
|
|
17
21
|
const configDir = await findUpward(
|
|
@@ -35,6 +39,51 @@ with no result, instead of continuing to the parent. Without `stop`,
|
|
|
35
39
|
walks all the way to the filesystem root. Always terminates: directory
|
|
36
40
|
nesting is finite.
|
|
37
41
|
|
|
42
|
+
### `findRoot(startDir, marker, options?)` / `findRootSync(...)`
|
|
43
|
+
|
|
44
|
+
The nearest ancestor of `startDir` — itself first — that contains `marker`: a
|
|
45
|
+
file or directory name, or a list of names where any one counts. Resolves to
|
|
46
|
+
`undefined` when nothing is found. `findRootSync` is the synchronous twin.
|
|
47
|
+
Each marker must be a bare name — an empty string, `.`, `..` or anything with a
|
|
48
|
+
path separator throws a `TypeError`, as does any filesystem error other than
|
|
49
|
+
"not there" (e.g. `EACCES`). A relative `startDir` is resolved against the
|
|
50
|
+
working directory up front, so results are always absolute.
|
|
51
|
+
|
|
52
|
+
- `options.fallback: "start"` — return the start directory instead of
|
|
53
|
+
`undefined` when nothing is found (a soft-skip for callers that treat the
|
|
54
|
+
start dir as the root anyway; always absolute).
|
|
55
|
+
- `options.realpath: true` — resolve symlinks of `startDir` before
|
|
56
|
+
walking, so a symlinked start walks the physical tree instead of the link's
|
|
57
|
+
own ancestors. A start path that doesn't exist
|
|
58
|
+
counts as not found.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// a git root that never throws and never returns undefined
|
|
62
|
+
const root = findRootSync(cwd, ".git", { fallback: "start", realpath: true });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `findExecutable(bin, options?)` / `findExecutableSync(...)`
|
|
66
|
+
|
|
67
|
+
The first runnable file named `bin` on `options.path` (default `process.env.PATH`),
|
|
68
|
+
or `undefined` — a `which`. Directories and files without the exec bit don't
|
|
69
|
+
count. `bin` must be a bare command name (an empty string, `.`, `..` or anything
|
|
70
|
+
with a path separator throws a `TypeError`), as does any filesystem error other
|
|
71
|
+
than "not there" / "not permitted".
|
|
72
|
+
|
|
73
|
+
- `options.path` — directories to search, delimited like `PATH` on the target
|
|
74
|
+
platform (`:` on POSIX, `;` on Windows). Empty entries are skipped; relative
|
|
75
|
+
ones are resolved against the working directory when the call starts, so
|
|
76
|
+
results are always absolute.
|
|
77
|
+
- `options.pathExt` / `options.platform` — on Windows there is no exec bit:
|
|
78
|
+
`bin` is tried with each `PATHEXT` extension (default `.COM;.EXE;.BAT;.CMD`)
|
|
79
|
+
unless it already ends in one. An extension containing a path separator
|
|
80
|
+
throws a `TypeError`.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// a project-local binary first, then the real PATH
|
|
84
|
+
const tsc = findExecutableSync("tsc", { path: join(cwd, "node_modules", ".bin") }) ?? findExecutableSync("tsc");
|
|
85
|
+
```
|
|
86
|
+
|
|
38
87
|
### `hasGitEntry(dir)`
|
|
39
88
|
|
|
40
89
|
Returns `true` when `dir` has a `.git` entry — a directory in a normal
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
+
//#region src/find-executable.d.ts
|
|
2
|
+
export interface FindExecutableOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Directories to search, delimited the way `PATH` is on `platform` (`:` on
|
|
5
|
+
* POSIX, `;` on Windows). Defaults to `process.env.PATH`. Empty entries are
|
|
6
|
+
* skipped rather than read as the working directory; relative ones are
|
|
7
|
+
* resolved against the working directory when the call starts.
|
|
8
|
+
*/
|
|
9
|
+
path?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Windows only: the extensions that make a file runnable, delimited with
|
|
12
|
+
* `;`. Defaults to `process.env.PATHEXT`, else `.COM;.EXE;.BAT;.CMD`. Each
|
|
13
|
+
* must be a bare extension — one with a path separator throws a `TypeError`,
|
|
14
|
+
* since it would otherwise reach outside the searched directory.
|
|
15
|
+
*/
|
|
16
|
+
pathExt?: string;
|
|
17
|
+
/** Defaults to `process.platform`. Windows has no exec bit, so only the extension decides. */
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The first runnable file named `bin` in `options.path` (default
|
|
22
|
+
* `process.env.PATH`), as an absolute path, or `undefined`.
|
|
23
|
+
* Directories and files without the exec bit don't count.
|
|
24
|
+
*
|
|
25
|
+
* `bin` must be a bare name — an empty string, `.`, `..` or anything with a
|
|
26
|
+
* path separator throws a `TypeError`, as does any filesystem error other
|
|
27
|
+
* than "not there" / "not permitted".
|
|
28
|
+
*/
|
|
29
|
+
export declare function findExecutable(bin: string, options?: FindExecutableOptions): Promise<string | undefined>;
|
|
30
|
+
/** Synchronous twin of {@link findExecutable}. */
|
|
31
|
+
export declare function findExecutableSync(bin: string, options?: FindExecutableOptions): string | undefined;
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/find-root.d.ts
|
|
34
|
+
export interface FindRootOptions {
|
|
35
|
+
/**
|
|
36
|
+
* `"start"` returns the start directory instead of `undefined` when no
|
|
37
|
+
* marker is found anywhere up the tree — a soft-skip for callers that
|
|
38
|
+
* treat the start dir as the root anyway. The returned path is always
|
|
39
|
+
* absolute (a relative `startDir` is resolved against the working
|
|
40
|
+
* directory), and symlink-resolved when `realpath` is on and the start
|
|
41
|
+
* exists.
|
|
42
|
+
*/
|
|
43
|
+
fallback?: "start";
|
|
44
|
+
/**
|
|
45
|
+
* Resolve symlinks of `startDir` before walking, so a symlinked start
|
|
46
|
+
* dir walks the physical tree instead of the link's own lexical
|
|
47
|
+
* ancestors. A start path that doesn't exist on disk counts as "not
|
|
48
|
+
* found". Any other failure resolving it (e.g. `EACCES`) is thrown.
|
|
49
|
+
*/
|
|
50
|
+
realpath?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The nearest ancestor of `startDir` (itself first) that contains `marker` —
|
|
54
|
+
* a file or directory name, or a list of names where any one counts. For
|
|
55
|
+
* example `".git"`, `"pnpm-workspace.yaml"`, or `["turbo.json", "nx.json"]`.
|
|
56
|
+
*
|
|
57
|
+
* Each marker must be a bare name (no separators, `.`, `..` or empty) or a
|
|
58
|
+
* `TypeError` is thrown; so is any filesystem error other than "not there".
|
|
59
|
+
* Resolves to `undefined` when nothing is found, unless `options.fallback` is
|
|
60
|
+
* `"start"`. See {@link FindRootOptions}; {@link findRootSync} is the
|
|
61
|
+
* synchronous twin. Built on {@link findUpward}.
|
|
62
|
+
*/
|
|
63
|
+
export declare function findRoot(startDir: string, marker: string | readonly string[], options?: FindRootOptions): Promise<string | undefined>;
|
|
64
|
+
/** Synchronous {@link findRoot}. */
|
|
65
|
+
export declare function findRootSync(startDir: string, marker: string | readonly string[], options?: FindRootOptions): string | undefined;
|
|
66
|
+
//#endregion
|
|
1
67
|
//#region src/find-upward.d.ts
|
|
2
68
|
/**
|
|
3
69
|
* Walks upward from `startDir` — `startDir` itself first, then its parent,
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { access, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
//#region src/find-executable.ts
|
|
5
|
+
const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
6
|
+
/** A bare command name, joined onto each search directory — never a path of its own. */
|
|
7
|
+
function assertBare(bin) {
|
|
8
|
+
if (bin === "" || bin === "." || bin === ".." || /[\\/]/.test(bin) || bin.includes("\0")) throw new TypeError(`findExecutable: bin must be a bare command name, got ${JSON.stringify(bin)}`);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Every file name `bin` could run as in one directory, in the order they are
|
|
12
|
+
* tried. Computed once per call so the order can't shift mid-search if the
|
|
13
|
+
* environment changes underneath an in-flight lookup.
|
|
14
|
+
*/
|
|
15
|
+
function namesFor(bin, options) {
|
|
16
|
+
if ((options.platform ?? process.platform) !== "win32") return [bin];
|
|
17
|
+
const exts = (options.pathExt ?? process.env["PATHEXT"] ?? DEFAULT_PATHEXT).split(";").filter(Boolean);
|
|
18
|
+
for (const ext of exts) if (/[\\/]/.test(ext) || ext.includes("\0")) throw new TypeError(`findExecutable: pathExt entries must not contain path separators, got ${JSON.stringify(ext)}`);
|
|
19
|
+
if (exts.some((ext) => bin.toLowerCase().endsWith(ext.toLowerCase()))) return [bin];
|
|
20
|
+
return exts.map((ext) => bin + ext);
|
|
21
|
+
}
|
|
22
|
+
/** The search directories, absolute, so a `chdir` during the awaits can't change what a relative entry means. */
|
|
23
|
+
function dirsOf(options) {
|
|
24
|
+
const win = (options.platform ?? process.platform) === "win32";
|
|
25
|
+
return (options.path ?? process.env["PATH"] ?? "").split(win ? ";" : ":").filter(Boolean).map((dir) => resolve(dir));
|
|
26
|
+
}
|
|
27
|
+
/** Only "nothing there" or "not runnable" means this candidate isn't it; anything else is real. */
|
|
28
|
+
const isMiss = (err) => {
|
|
29
|
+
const code = err?.code;
|
|
30
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "EPERM";
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The first runnable file named `bin` in `options.path` (default
|
|
34
|
+
* `process.env.PATH`), as an absolute path, or `undefined`.
|
|
35
|
+
* Directories and files without the exec bit don't count.
|
|
36
|
+
*
|
|
37
|
+
* `bin` must be a bare name — an empty string, `.`, `..` or anything with a
|
|
38
|
+
* path separator throws a `TypeError`, as does any filesystem error other
|
|
39
|
+
* than "not there" / "not permitted".
|
|
40
|
+
*/
|
|
41
|
+
async function findExecutable(bin, options = {}) {
|
|
42
|
+
assertBare(bin);
|
|
43
|
+
const win = (options.platform ?? process.platform) === "win32";
|
|
44
|
+
const dirs = dirsOf(options);
|
|
45
|
+
const names = namesFor(bin, options);
|
|
46
|
+
for (const dir of dirs) for (const name of names) {
|
|
47
|
+
const candidate = join(dir, name);
|
|
48
|
+
try {
|
|
49
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
50
|
+
if (!win) await access(candidate, constants.X_OK);
|
|
51
|
+
return candidate;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (!isMiss(err)) throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Synchronous twin of {@link findExecutable}. */
|
|
58
|
+
function findExecutableSync(bin, options = {}) {
|
|
59
|
+
assertBare(bin);
|
|
60
|
+
const win = (options.platform ?? process.platform) === "win32";
|
|
61
|
+
const dirs = dirsOf(options);
|
|
62
|
+
const names = namesFor(bin, options);
|
|
63
|
+
for (const dir of dirs) for (const name of names) {
|
|
64
|
+
const candidate = join(dir, name);
|
|
65
|
+
try {
|
|
66
|
+
if (!statSync(candidate).isFile()) continue;
|
|
67
|
+
if (!win) accessSync(candidate, constants.X_OK);
|
|
68
|
+
return candidate;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!isMiss(err)) throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
3
75
|
//#region src/find-upward.ts
|
|
4
76
|
/**
|
|
5
77
|
* Walks upward from `startDir` — `startDir` itself first, then its parent,
|
|
@@ -42,4 +114,80 @@ async function hasGitEntry(dir) {
|
|
|
42
114
|
}
|
|
43
115
|
}
|
|
44
116
|
//#endregion
|
|
45
|
-
|
|
117
|
+
//#region src/find-root.ts
|
|
118
|
+
/** Only "nothing there" means the marker is absent; every other fs error is real. */
|
|
119
|
+
const isAbsent = (err) => {
|
|
120
|
+
const code = err?.code;
|
|
121
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Markers are bare names, joined onto each ancestor. Anything that could
|
|
125
|
+
* address a path outside that ancestor (`..`, a separator) or the ancestor
|
|
126
|
+
* itself (`""`, `.`) is rejected rather than probed.
|
|
127
|
+
*/
|
|
128
|
+
function asMarkers(marker) {
|
|
129
|
+
const list = typeof marker === "string" ? [marker] : marker;
|
|
130
|
+
if (list.length === 0) throw new TypeError("findRoot: at least one marker is required");
|
|
131
|
+
for (const m of list) if (m === "" || m === "." || m === ".." || /[\\/]/.test(m) || m.includes("\0")) throw new TypeError(`findRoot: marker must be a single file or directory name, got ${JSON.stringify(m)}`);
|
|
132
|
+
return list;
|
|
133
|
+
}
|
|
134
|
+
async function hasAny(dir, markers) {
|
|
135
|
+
for (const marker of markers) try {
|
|
136
|
+
await stat(join(dir, marker));
|
|
137
|
+
return true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (!isAbsent(err)) throw err;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
function hasAnySync(dir, markers) {
|
|
144
|
+
for (const marker of markers) try {
|
|
145
|
+
statSync(join(dir, marker));
|
|
146
|
+
return true;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (!isAbsent(err)) throw err;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The nearest ancestor of `startDir` (itself first) that contains `marker` —
|
|
154
|
+
* a file or directory name, or a list of names where any one counts. For
|
|
155
|
+
* example `".git"`, `"pnpm-workspace.yaml"`, or `["turbo.json", "nx.json"]`.
|
|
156
|
+
*
|
|
157
|
+
* Each marker must be a bare name (no separators, `.`, `..` or empty) or a
|
|
158
|
+
* `TypeError` is thrown; so is any filesystem error other than "not there".
|
|
159
|
+
* Resolves to `undefined` when nothing is found, unless `options.fallback` is
|
|
160
|
+
* `"start"`. See {@link FindRootOptions}; {@link findRootSync} is the
|
|
161
|
+
* synchronous twin. Built on {@link findUpward}.
|
|
162
|
+
*/
|
|
163
|
+
async function findRoot(startDir, marker, options = {}) {
|
|
164
|
+
const markers = asMarkers(marker);
|
|
165
|
+
let start = resolve(startDir);
|
|
166
|
+
if (options.realpath) try {
|
|
167
|
+
start = await realpath(start);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!isAbsent(err)) throw err;
|
|
170
|
+
return options.fallback === "start" ? start : void 0;
|
|
171
|
+
}
|
|
172
|
+
return await findUpward(start, async (dir) => await hasAny(dir, markers) ? dir : void 0) ?? (options.fallback === "start" ? start : void 0);
|
|
173
|
+
}
|
|
174
|
+
/** Synchronous {@link findRoot}. */
|
|
175
|
+
function findRootSync(startDir, marker, options = {}) {
|
|
176
|
+
const markers = asMarkers(marker);
|
|
177
|
+
let start = resolve(startDir);
|
|
178
|
+
if (options.realpath) try {
|
|
179
|
+
start = realpathSync(start);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (!isAbsent(err)) throw err;
|
|
182
|
+
return options.fallback === "start" ? start : void 0;
|
|
183
|
+
}
|
|
184
|
+
let dir = start;
|
|
185
|
+
for (;;) {
|
|
186
|
+
if (hasAnySync(dir, markers)) return dir;
|
|
187
|
+
const parent = dirname(dir);
|
|
188
|
+
if (parent === dir) return options.fallback === "start" ? start : void 0;
|
|
189
|
+
dir = parent;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
export { findExecutable, findExecutableSync, findRoot, findRootSync, findUpward, hasGitEntry };
|