@theholocron/fs-utils 1.8.3 → 1.10.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 { 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,29 @@ 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
+
38
65
  ### `hasGitEntry(dir)`
39
66
 
40
67
  Returns `true` when `dir` has a `.git` entry — a directory in a normal
package/dist/index.d.mts CHANGED
@@ -1,3 +1,37 @@
1
+ //#region src/find-root.d.ts
2
+ export interface FindRootOptions {
3
+ /**
4
+ * `"start"` returns the start directory instead of `undefined` when no
5
+ * marker is found anywhere up the tree — a soft-skip for callers that
6
+ * treat the start dir as the root anyway. The returned path is always
7
+ * absolute (a relative `startDir` is resolved against the working
8
+ * directory), and symlink-resolved when `realpath` is on and the start
9
+ * exists.
10
+ */
11
+ fallback?: "start";
12
+ /**
13
+ * Resolve symlinks of `startDir` before walking, so a symlinked start
14
+ * dir walks the physical tree instead of the link's own lexical
15
+ * ancestors. A start path that doesn't exist on disk counts as "not
16
+ * found". Any other failure resolving it (e.g. `EACCES`) is thrown.
17
+ */
18
+ realpath?: boolean;
19
+ }
20
+ /**
21
+ * The nearest ancestor of `startDir` (itself first) that contains `marker` —
22
+ * a file or directory name, or a list of names where any one counts. For
23
+ * example `".git"`, `"pnpm-workspace.yaml"`, or `["turbo.json", "nx.json"]`.
24
+ *
25
+ * Each marker must be a bare name (no separators, `.`, `..` or empty) or a
26
+ * `TypeError` is thrown; so is any filesystem error other than "not there".
27
+ * Resolves to `undefined` when nothing is found, unless `options.fallback` is
28
+ * `"start"`. See {@link FindRootOptions}; {@link findRootSync} is the
29
+ * synchronous twin. Built on {@link findUpward}.
30
+ */
31
+ export declare function findRoot(startDir: string, marker: string | readonly string[], options?: FindRootOptions): Promise<string | undefined>;
32
+ /** Synchronous {@link findRoot}. */
33
+ export declare function findRootSync(startDir: string, marker: string | readonly string[], options?: FindRootOptions): string | undefined;
34
+ //#endregion
1
35
  //#region src/find-upward.d.ts
2
36
  /**
3
37
  * Walks upward from `startDir` — `startDir` itself first, then its parent,
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
- import { stat } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
3
4
  //#region src/find-upward.ts
4
5
  /**
5
6
  * Walks upward from `startDir` — `startDir` itself first, then its parent,
@@ -42,4 +43,80 @@ async function hasGitEntry(dir) {
42
43
  }
43
44
  }
44
45
  //#endregion
45
- export { findUpward, hasGitEntry };
46
+ //#region src/find-root.ts
47
+ /** Only "nothing there" means the marker is absent; every other fs error is real. */
48
+ const isAbsent = (err) => {
49
+ const code = err?.code;
50
+ return code === "ENOENT" || code === "ENOTDIR";
51
+ };
52
+ /**
53
+ * Markers are bare names, joined onto each ancestor. Anything that could
54
+ * address a path outside that ancestor (`..`, a separator) or the ancestor
55
+ * itself (`""`, `.`) is rejected rather than probed.
56
+ */
57
+ function asMarkers(marker) {
58
+ const list = typeof marker === "string" ? [marker] : marker;
59
+ if (list.length === 0) throw new TypeError("findRoot: at least one marker is required");
60
+ 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)}`);
61
+ return list;
62
+ }
63
+ async function hasAny(dir, markers) {
64
+ for (const marker of markers) try {
65
+ await stat(join(dir, marker));
66
+ return true;
67
+ } catch (err) {
68
+ if (!isAbsent(err)) throw err;
69
+ }
70
+ return false;
71
+ }
72
+ function hasAnySync(dir, markers) {
73
+ for (const marker of markers) try {
74
+ statSync(join(dir, marker));
75
+ return true;
76
+ } catch (err) {
77
+ if (!isAbsent(err)) throw err;
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * The nearest ancestor of `startDir` (itself first) that contains `marker` —
83
+ * a file or directory name, or a list of names where any one counts. For
84
+ * example `".git"`, `"pnpm-workspace.yaml"`, or `["turbo.json", "nx.json"]`.
85
+ *
86
+ * Each marker must be a bare name (no separators, `.`, `..` or empty) or a
87
+ * `TypeError` is thrown; so is any filesystem error other than "not there".
88
+ * Resolves to `undefined` when nothing is found, unless `options.fallback` is
89
+ * `"start"`. See {@link FindRootOptions}; {@link findRootSync} is the
90
+ * synchronous twin. Built on {@link findUpward}.
91
+ */
92
+ async function findRoot(startDir, marker, options = {}) {
93
+ const markers = asMarkers(marker);
94
+ let start = resolve(startDir);
95
+ if (options.realpath) try {
96
+ start = await realpath(start);
97
+ } catch (err) {
98
+ if (!isAbsent(err)) throw err;
99
+ return options.fallback === "start" ? start : void 0;
100
+ }
101
+ return await findUpward(start, async (dir) => await hasAny(dir, markers) ? dir : void 0) ?? (options.fallback === "start" ? start : void 0);
102
+ }
103
+ /** Synchronous {@link findRoot}. */
104
+ function findRootSync(startDir, marker, options = {}) {
105
+ const markers = asMarkers(marker);
106
+ let start = resolve(startDir);
107
+ if (options.realpath) try {
108
+ start = realpathSync(start);
109
+ } catch (err) {
110
+ if (!isAbsent(err)) throw err;
111
+ return options.fallback === "start" ? start : void 0;
112
+ }
113
+ let dir = start;
114
+ for (;;) {
115
+ if (hasAnySync(dir, markers)) return dir;
116
+ const parent = dirname(dir);
117
+ if (parent === dir) return options.fallback === "start" ? start : void 0;
118
+ dir = parent;
119
+ }
120
+ }
121
+ //#endregion
122
+ export { findRoot, findRootSync, findUpward, hasGitEntry };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/fs-utils",
3
- "version": "1.8.3",
3
+ "version": "1.10.0",
4
4
  "description": "Walk a directory tree upward to find the nearest match or project root.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -31,7 +31,7 @@
31
31
  "dist"
32
32
  ],
33
33
  "devDependencies": {
34
- "@theholocron/cli": "5.0.0-alpha.3",
34
+ "@theholocron/cli": "5.0.0-alpha.114",
35
35
  "@theholocron/tsdown-config": "^8.2.0",
36
36
  "@theholocron/vitest-config": "^8.2.0",
37
37
  "@tsconfig/node-lts": "^24.0.1",