@theholocron/fs-utils 1.10.0 → 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,7 @@ pnpm add @theholocron/fs-utils
11
11
  ## Usage
12
12
 
13
13
  ```typescript
14
- import { findRoot, findUpward, hasGitEntry } from "@theholocron/fs-utils";
14
+ import { findExecutable, findExecutableSync, findRoot, findUpward, hasGitEntry } from "@theholocron/fs-utils";
15
15
 
16
16
  // nearest ancestor holding a marker file or directory
17
17
  const repoRoot = await findRoot(process.cwd(), ".git");
@@ -62,6 +62,28 @@ working directory up front, so results are always absolute.
62
62
  const root = findRootSync(cwd, ".git", { fallback: "start", realpath: true });
63
63
  ```
64
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
+
65
87
  ### `hasGitEntry(dir)`
66
88
 
67
89
  Returns `true` when `dir` has a `.git` entry — a directory in a normal
package/dist/index.d.mts CHANGED
@@ -1,3 +1,35 @@
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
1
33
  //#region src/find-root.d.ts
2
34
  export interface FindRootOptions {
3
35
  /**
package/dist/index.mjs CHANGED
@@ -1,6 +1,77 @@
1
- import { realpathSync, statSync } from "node:fs";
2
- import { realpath, stat } from "node:fs/promises";
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import { access, realpath, stat } from "node:fs/promises";
3
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
4
75
  //#region src/find-upward.ts
5
76
  /**
6
77
  * Walks upward from `startDir` — `startDir` itself first, then its parent,
@@ -119,4 +190,4 @@ function findRootSync(startDir, marker, options = {}) {
119
190
  }
120
191
  }
121
192
  //#endregion
122
- export { findRoot, findRootSync, findUpward, hasGitEntry };
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.10.0",
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",