@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 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 { stat } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
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
- export { findUpward, hasGitEntry };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/fs-utils",
3
- "version": "1.9.2",
3
+ "version": "1.11.0",
4
4
  "description": "Walk a directory tree upward to find the nearest match or project root.",
5
5
  "keywords": [
6
6
  "filesystem",