type-a-bin 0.1.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/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { mockBin, } from "./mock-bin.js";
@@ -0,0 +1,21 @@
1
+ interface MockTarget {
2
+ /** "node" redirects the main entry in-process; "spawn" runs a script. */
3
+ kind: "node" | "spawn";
4
+ /** Absolute path of the mock script to run. */
5
+ entry: string;
6
+ /** Interpreter name for "spawn" targets (e.g. "bash", "python"). */
7
+ interpreter?: string;
8
+ /** Regex source; only matching commands run the mock. */
9
+ pattern?: string;
10
+ /** PATH snapshot from before this mock was installed. */
11
+ originalPath?: string;
12
+ }
13
+ interface RunOriginalTarget {
14
+ binName: string;
15
+ originalPath: string;
16
+ }
17
+ interface MocksEnv {
18
+ targets?: Record<string, MockTarget>;
19
+ runOriginal?: RunOriginalTarget;
20
+ }
21
+ export type { MocksEnv, MockTarget };
@@ -0,0 +1,221 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { statSync } from "node:fs";
3
+ import Module, { createRequire, registerHooks } from "node:module";
4
+ import { basename, delimiter, extname, join, relative } from "node:path";
5
+ import process from "node:process";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { threadId } from "node:worker_threads";
8
+ // Loaded through `NODE_OPTIONS --import` into every Node process a test
9
+ // spawns while Windows mocks are active. A mock binary is a hard link of
10
+ // node.exe renamed <bin>.exe, so the command line ("status", "--porcelain",
11
+ // ...) is not a real module. This preload swaps the shim's main entry for
12
+ // the mock script named in TYPE_A_BIN_MOCKS — argv, stdin, stdout, stderr,
13
+ // and exit codes all pass through unchanged.
14
+ const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
15
+ const HELPER_NAME = "mock-a-bin-run-original";
16
+ const PATH_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".com"];
17
+ const TS_EXTENSIONS = [".cts", ".mts", ".ts", ".tsx"];
18
+ const BASH_LIKE_INTERPRETERS = ["bash", "dash", "ksh", "sh", "zsh"];
19
+ const readMocks = () => {
20
+ try {
21
+ return JSON.parse(process.env[MOCKS_VAR] ?? "{}");
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ };
27
+ const mocks = readMocks();
28
+ const invokedName = basename(process.argv[0] ?? "", extname(process.argv[0] ?? ""));
29
+ // Node rewrites the CLI entry in argv[1] to an absolute path before
30
+ // preloads run, losing the argument as the caller typed it. Recover it
31
+ // relative to the working directory when possible, so mocks, patterns,
32
+ // and spawned interpreters see "pr" instead of "C:\repo\pr".
33
+ const denormalizeEntry = (entry) => {
34
+ const relativePath = relative(process.cwd(), entry);
35
+ if (relativePath === "" || relativePath.startsWith(".."))
36
+ return entry;
37
+ return relativePath;
38
+ };
39
+ // The shim's whole command line after the exe are CLI arguments: unlike
40
+ // a Node script there is no "entry" consuming the first positional.
41
+ const cliArgs = process.argv.length === 1
42
+ ? []
43
+ : [denormalizeEntry(process.argv[1] ?? ""), ...process.argv.slice(2)];
44
+ const writeError = (message) => {
45
+ process.stderr.write(`${message}\n`);
46
+ };
47
+ // Lookup failures exit 127, matching the POSIX mock scripts.
48
+ const fail = (message) => {
49
+ writeError(message);
50
+ process.exit(127);
51
+ };
52
+ const isFile = (candidate) => {
53
+ try {
54
+ return statSync(candidate).isFile();
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ };
60
+ const searchPathDirs = (originalPath) => (originalPath ?? process.env.PATH ?? "")
61
+ .split(delimiter)
62
+ .filter((dir) => dir !== "" && !dir.includes("mock-bin-"));
63
+ const pathCandidates = (name, dirs) => {
64
+ const candidates = [];
65
+ for (const dir of dirs)
66
+ for (const extension of PATH_EXTENSIONS) {
67
+ const candidate = join(dir, `${name}${extension}`);
68
+ if (isFile(candidate))
69
+ candidates.push(candidate);
70
+ }
71
+ return candidates;
72
+ };
73
+ const findExecutable = (name, dirs) => pathCandidates(name, dirs)[0] ?? null;
74
+ const spawnRealAndExit = (command, args) => {
75
+ // The real binary must not be re-intercepted by this preload.
76
+ delete process.env[MOCKS_VAR];
77
+ const result = spawnSync(command, args, { stdio: "inherit" });
78
+ if (result.error !== undefined)
79
+ writeError(`Error: '${command}' failed to start: ${String(result.error)}`);
80
+ process.exit(result.status ?? 127);
81
+ };
82
+ const runOriginalCommand = (spec) => {
83
+ if (spec === undefined)
84
+ return fail(`Error: ${HELPER_NAME} used outside a mockBin context`);
85
+ const real = findExecutable(spec.binName, searchPathDirs(spec.originalPath));
86
+ if (real === null)
87
+ return fail(`Error: Original '${spec.binName}' command not found in PATH`);
88
+ return spawnRealAndExit(real, cliArgs);
89
+ };
90
+ const runRealBinary = (target) => {
91
+ const real = findExecutable(invokedName, searchPathDirs(target.originalPath));
92
+ if (real === null)
93
+ return fail(`Error: Real binary '${invokedName}' not found in PATH`);
94
+ return spawnRealAndExit(real, cliArgs);
95
+ };
96
+ // WSL's bash launcher lives in the Windows system directories; it cannot
97
+ // run Windows-path scripts, so bash-like interpreters prefer a native
98
+ // shell (e.g. Git for Windows) and fall back to well-known Git installs.
99
+ const isWslLauncher = (candidate) => {
100
+ const lower = candidate.toLowerCase();
101
+ return (lower.includes("\\windows\\system32\\") || lower.includes("\\windowsapps\\"));
102
+ };
103
+ const gitBashCandidates = () => {
104
+ const roots = [
105
+ process.env.ProgramFiles ?? "",
106
+ process.env["ProgramFiles(x86)"] ?? "",
107
+ join(process.env.LOCALAPPDATA ?? "", "Programs"),
108
+ ];
109
+ const locations = [];
110
+ for (const root of roots)
111
+ if (root !== "") {
112
+ locations.push(join(root, "Git", "bin", "bash.exe"));
113
+ locations.push(join(root, "Git", "usr", "bin", "bash.exe"));
114
+ }
115
+ return locations;
116
+ };
117
+ const resolveInterpreter = (interpreter) => {
118
+ const dirs = (process.env.PATH ?? "")
119
+ .split(delimiter)
120
+ .filter((dir) => dir !== "");
121
+ const candidates = pathCandidates(interpreter, dirs);
122
+ if (BASH_LIKE_INTERPRETERS.includes(interpreter)) {
123
+ const native = candidates.find((candidate) => !isWslLauncher(candidate));
124
+ if (native !== undefined)
125
+ return native;
126
+ for (const location of gitBashCandidates())
127
+ if (isFile(location))
128
+ return location;
129
+ }
130
+ return candidates[0] ?? null;
131
+ };
132
+ const runInterpreterAndExit = (interpreter, entry) => {
133
+ const interpreterPath = resolveInterpreter(interpreter);
134
+ if (interpreterPath === null)
135
+ return fail(`Error: Interpreter '${interpreter}' not found in PATH`);
136
+ const result = spawnSync(interpreterPath, [entry, ...cliArgs], {
137
+ stdio: "inherit",
138
+ });
139
+ if (result.error !== undefined)
140
+ writeError(`Error: Interpreter '${interpreter}' failed: ${String(result.error)}`);
141
+ process.exit(result.status ?? 127);
142
+ };
143
+ const asPath = (specifier) => specifier.startsWith("file:") ? fileURLToPath(specifier) : specifier;
144
+ // tsx registers its CommonJS hooks from a separate entry point; load it
145
+ // lazily so non-TypeScript mocks never require the tsx package.
146
+ const loadTsxCommonJs = (entry) => {
147
+ if (!TS_EXTENSIONS.includes(extname(entry).toLowerCase()))
148
+ return;
149
+ try {
150
+ createRequire(import.meta.url)("tsx/cjs");
151
+ }
152
+ catch {
153
+ // tsx is not installed: node's native type stripping applies instead.
154
+ }
155
+ };
156
+ const redirectNodeEntry = (entry) => {
157
+ // Node normalizes the CLI entry to an absolute path in argv[1] before
158
+ // preloads run. Capture it, then reposition argv so the mock sees the
159
+ // CLI arguments at process.argv.slice(2) like a real Node CLI script.
160
+ const originalEntry = process.argv[1] ?? "";
161
+ process.argv.length = 1;
162
+ process.argv.push(entry, ...cliArgs);
163
+ // ESM main entry: resolve hooks see the main module with no parent URL,
164
+ // so redirect that one resolution to the mock script. The tsx loader
165
+ // (registered before this preload in NODE_OPTIONS) transforms .ts
166
+ // entries as part of the same resolve chain.
167
+ registerHooks({
168
+ resolve: (specifier, context, nextResolve) => {
169
+ if (context.parentURL == null && asPath(specifier) === originalEntry)
170
+ return nextResolve(pathToFileURL(entry).href, context);
171
+ return nextResolve(specifier, context);
172
+ },
173
+ });
174
+ // CommonJS main entry: Module._load receives the CLI entry with isMain
175
+ // set — load the mock through the CommonJS loader instead.
176
+ const moduleApi = Module;
177
+ const originalLoad = moduleApi._load;
178
+ moduleApi._load = (request, parent, isMain) => {
179
+ if (isMain && request === originalEntry) {
180
+ moduleApi._load = originalLoad;
181
+ loadTsxCommonJs(entry);
182
+ return originalLoad(entry, null, true);
183
+ }
184
+ return originalLoad(request, parent, isMain);
185
+ };
186
+ };
187
+ // A spawn with no CLI arguments leaves node without an entry (the REPL
188
+ // would start), so the mock module is imported directly instead.
189
+ const runEntryDirectly = async (entry) => {
190
+ process.argv.length = 1;
191
+ process.argv.push(entry, ...cliArgs);
192
+ await import(pathToFileURL(entry).href);
193
+ process.exit(process.exitCode);
194
+ };
195
+ const intercept = async () => {
196
+ if (invokedName === HELPER_NAME)
197
+ return runOriginalCommand(mocks.runOriginal);
198
+ const target = mocks.targets?.[invokedName];
199
+ if (target === undefined)
200
+ return;
201
+ // A process whose CLI entry is a real file is not a shim: mock scripts
202
+ // spawned through process.execPath (tsx's esbuild service, `node -e`
203
+ // helpers) inherit the shim exe's name, but their entry exists on disk
204
+ // while a shim's "subcommand" entry never does.
205
+ if (isFile(process.argv[1] ?? ""))
206
+ return;
207
+ const commandLine = `${invokedName} ${cliArgs.join(" ")}`;
208
+ const mocked = target.pattern === undefined ||
209
+ new RegExp(target.pattern).test(commandLine);
210
+ if (!mocked)
211
+ return runRealBinary(target);
212
+ if (target.kind !== "node")
213
+ return runInterpreterAndExit(target.interpreter ?? "bash", target.entry);
214
+ if (process.argv[1] === undefined)
215
+ return runEntryDirectly(target.entry);
216
+ return redirectNodeEntry(target.entry);
217
+ };
218
+ // Only a shim's own main thread may intercept: loaders such as tsx spawn
219
+ // workers whose entry-point load must pass through untouched.
220
+ if (process.env[MOCKS_VAR] !== undefined && threadId === 0)
221
+ await intercept();
@@ -0,0 +1,10 @@
1
+ import type { MockBinScriptFile } from "./mock-bin.js";
2
+ /**
3
+ * Windows implementation of `mockBin`; see mock-bin.ts for the public
4
+ * overloads. Installs `<binName>.exe` (a hard link of node.exe) and the
5
+ * `mock-a-bin-run-original.exe` helper into a temp directory prepended
6
+ * to PATH, registers the mock in the preload's registry, and extends
7
+ * NODE_OPTIONS with the preload import.
8
+ */
9
+ declare const mockBinWindows: (binName: string, pattern: string | undefined, shebangOrOutput: string, codeOrScript: string | MockBinScriptFile | undefined) => Promise<() => void>;
10
+ export { mockBinWindows };
@@ -0,0 +1,182 @@
1
+ import { copyFileSync, linkSync, rmSync } from "node:fs";
2
+ import { mkdtemp, stat, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ // Windows twin of the POSIX mockBin: PATH interception needs a real
9
+ // executable there (node refuses to spawn .cmd/.bat shims without a
10
+ // shell, and an extensionless #! script cannot execute at all). Each
11
+ // mock is a hard link of node.exe named <bin>.exe, and a preload
12
+ // (mock-bin-preload) registered through NODE_OPTIONS turns a spawn of
13
+ // that exe into the mock script — argv, stdin, stdout, stderr, and exit
14
+ // codes all pass through. The registry the preload reads (and its env
15
+ // var name) must stay in sync with mock-bin-preload.
16
+ const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
17
+ const HELPER_NAME = "mock-a-bin-run-original";
18
+ // The preload ships next to this module: mock-bin-preload.js in the
19
+ // published dist build, mock-bin-preload.ts when running from source
20
+ // (node's native type stripping loads the .ts form in child processes).
21
+ const ownPath = fileURLToPath(import.meta.url);
22
+ const preloadPath = path.join(path.dirname(ownPath), `mock-bin-preload${path.extname(ownPath)}`);
23
+ const readMocks = () => {
24
+ try {
25
+ return JSON.parse(process.env[MOCKS_VAR] ?? "{}");
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ };
31
+ const restoreEnv = (name, previous) => {
32
+ if (previous === undefined)
33
+ delete process.env[name];
34
+ else
35
+ process.env[name] = previous;
36
+ };
37
+ /** Hard links node.exe as `destination`, copying across volume boundaries. */
38
+ const linkNodeExecutable = (destination) => {
39
+ try {
40
+ linkSync(process.execPath, destination);
41
+ }
42
+ catch {
43
+ // Different volume than the node install: fall back to a real copy.
44
+ copyFileSync(process.execPath, destination);
45
+ }
46
+ };
47
+ /**
48
+ * Splits an interpreter string into words, accepting both a bare name
49
+ * ("bash"), a full shebang ("#!/usr/bin/env bash"), or an absolute
50
+ * shebang path ("#!/bin/bash").
51
+ */
52
+ const toInterpreterWords = (shebang) => shebang
53
+ .replace(/^#!\s*/, "")
54
+ .replace(/^\/(?:usr\/)?bin\/env\s+/, "")
55
+ .trim()
56
+ .split(/\s+/)
57
+ .filter((word) => word !== "");
58
+ const basenameWithoutExecutableExtension = (word) => path.basename(word).replace(/\.(?:bat|cmd|com|exe)$/iu, "");
59
+ const isNodeInterpreter = (words) => basenameWithoutExecutableExtension(words[0] ?? "").toLowerCase() === "node";
60
+ const interpreterExecutableName = (word) => word === undefined ? "bash" : basenameWithoutExecutableExtension(word);
61
+ const usesTsx = (words) => words.some((word) => word === "tsx" || word.startsWith("tsx/"));
62
+ const resolveTsxImportUrl = () => {
63
+ try {
64
+ return pathToFileURL(createRequire(import.meta.url).resolve("tsx")).href;
65
+ }
66
+ catch {
67
+ throw new Error("mockBin: the interpreter requires the tsx package, but tsx is not " +
68
+ "installed. Add tsx to your devDependencies to mock through node " +
69
+ "--import tsx on Windows.");
70
+ }
71
+ };
72
+ const toShebangLine = (interpreter) => interpreter.startsWith("#!") ? interpreter : `#!/usr/bin/env ${interpreter}`;
73
+ /**
74
+ * Builds the CommonJS mock for the output shorthand. bash `echo` expands
75
+ * positional parameters, so the Node twin expands `$1`..`$9`, `$*`, and
76
+ * `$@` from the command line to keep the shorthand's behaviour aligned.
77
+ */
78
+ const echoScript = (output) => `
79
+ const args = process.argv.slice(2);
80
+ const expand = (text) =>
81
+ text
82
+ .replace(/\\$[@*]/g, args.join(" "))
83
+ .replace(/\\$(\\d)/g, (_, digit) => args[Number(digit) - 1] ?? "");
84
+ console.log(expand(${JSON.stringify(output)}));
85
+ `;
86
+ const assertScriptFile = async (file) => {
87
+ const stats = await stat(file).catch(() => null);
88
+ if (!stats?.isFile())
89
+ throw new Error(`mockBin: script file not found: ${file}`);
90
+ };
91
+ /**
92
+ * Windows implementation of `mockBin`; see mock-bin.ts for the public
93
+ * overloads. Installs `<binName>.exe` (a hard link of node.exe) and the
94
+ * `mock-a-bin-run-original.exe` helper into a temp directory prepended
95
+ * to PATH, registers the mock in the preload's registry, and extends
96
+ * NODE_OPTIONS with the preload import.
97
+ */
98
+ const mockBinWindows = async (binName, pattern, shebangOrOutput, codeOrScript) => {
99
+ if (typeof codeOrScript === "object")
100
+ await assertScriptFile(codeOrScript.file);
101
+ const originalPath = process.env.PATH ?? "";
102
+ const previousNodeOptions = process.env.NODE_OPTIONS;
103
+ const previousMocks = process.env[MOCKS_VAR];
104
+ // A binName already carrying .exe stays a valid executable name while
105
+ // the registry key matches the shim's argv[0] basename.
106
+ const binBase = binName.replace(/\.exe$/iu, "");
107
+ const tempDir = await mkdtemp(path.join(tmpdir(), "mock-bin-"));
108
+ linkNodeExecutable(path.join(tempDir, `${binBase}.exe`));
109
+ linkNodeExecutable(path.join(tempDir, `${HELPER_NAME}.exe`));
110
+ const words = codeOrScript === undefined ? ["node"] : toInterpreterWords(shebangOrOutput);
111
+ const isNodeMock = codeOrScript === undefined || isNodeInterpreter(words);
112
+ const interpreter = isNodeMock
113
+ ? undefined
114
+ : interpreterExecutableName(words[0]);
115
+ let entry;
116
+ if (typeof codeOrScript === "object") {
117
+ // Script files keep their real extension, so extension-aware loaders
118
+ // (e.g. node --import tsx) parse them.
119
+ entry = path.resolve(codeOrScript.file);
120
+ }
121
+ else if (isNodeMock) {
122
+ // Inline code runs as CommonJS, matching the extensionless scripts
123
+ // the POSIX implementation writes (node parses those as CommonJS).
124
+ entry = path.join(tempDir, `${binBase}-mock.cjs`);
125
+ const code = codeOrScript ?? echoScript(shebangOrOutput);
126
+ await writeFile(entry, `${code}\n`);
127
+ }
128
+ else {
129
+ entry = path.join(tempDir, `${binBase}-mock.sh`);
130
+ await writeFile(entry, `${toShebangLine(shebangOrOutput)}\n${codeOrScript}\n`);
131
+ }
132
+ const target = {
133
+ kind: isNodeMock ? "node" : "spawn",
134
+ entry,
135
+ ...(interpreter === undefined ? {} : { interpreter }),
136
+ ...(pattern ? { pattern } : {}),
137
+ originalPath,
138
+ };
139
+ const mocks = readMocks();
140
+ process.env[MOCKS_VAR] = JSON.stringify({
141
+ ...mocks,
142
+ targets: { ...mocks.targets, [binBase]: target },
143
+ runOriginal: { binName: binBase, originalPath },
144
+ });
145
+ // The preload rides along after the tsx loader when the interpreter
146
+ // names it, so .ts entries resolve through tsx's ESM hooks in the
147
+ // same resolve chain that redirects the shim entry.
148
+ const imports = [
149
+ ...(isNodeMock && usesTsx(words)
150
+ ? [`--import ${resolveTsxImportUrl()}`]
151
+ : []),
152
+ `--import ${pathToFileURL(preloadPath).href}`,
153
+ ];
154
+ process.env.NODE_OPTIONS =
155
+ previousNodeOptions === undefined || previousNodeOptions === ""
156
+ ? imports.join(" ")
157
+ : `${imports.join(" ")} ${previousNodeOptions}`;
158
+ process.env.PATH = `${tempDir}${path.delimiter}${originalPath}`;
159
+ return () => {
160
+ if (originalPath === "")
161
+ delete process.env.PATH;
162
+ else
163
+ process.env.PATH = originalPath;
164
+ restoreEnv("NODE_OPTIONS", previousNodeOptions);
165
+ restoreEnv(MOCKS_VAR, previousMocks);
166
+ try {
167
+ // Windows can transiently deny deleting files a just-exited
168
+ // process still holds (the shim exes), so rmSync's retry options
169
+ // cover that.
170
+ rmSync(tempDir, {
171
+ recursive: true,
172
+ force: true,
173
+ maxRetries: 40,
174
+ retryDelay: 250,
175
+ });
176
+ }
177
+ catch (error) {
178
+ console.warn(`Warning: Failed to remove mock-bin temp directory ${tempDir}: ${String(error)}`);
179
+ }
180
+ };
181
+ };
182
+ export { mockBinWindows };
@@ -0,0 +1,64 @@
1
+ type MockBinCleanup = () => void;
2
+ interface MockBinConfig {
3
+ /** The name of the binary to mock (e.g., "gh", "git") */
4
+ binName: string;
5
+ /** Optional regex pattern. Only commands matching it are mocked. */
6
+ pattern?: string;
7
+ }
8
+ interface MockBinScriptFile {
9
+ /**
10
+ * Script executed when the mock runs. The file keeps its real
11
+ * extension, so extension-aware loaders (e.g. `node --import tsx`)
12
+ * parse it — embedding the source inline fails because the mock
13
+ * binary is written to an extensionless temp file.
14
+ */
15
+ file: string;
16
+ }
17
+ /**
18
+ * Creates a mock executable that replaces a real binary on the PATH.
19
+ *
20
+ * The mock script can call `mock-a-bin-run-original` to execute the
21
+ * original command, enabling conditional mocking where some subcommands
22
+ * are mocked while others pass through to the real binary.
23
+ *
24
+ * There are three calling conventions:
25
+ *
26
+ * 1. **Output shorthand** — pass the plain text the mock should print.
27
+ * The interpreter defaults to `bash` and the output is echoed.
28
+ * 2. **Full script** — pass an interpreter (`shebang`) and arbitrary
29
+ * script `code` to run when the mock binary is invoked.
30
+ * 3. **Script file** — pass an interpreter (`shebang`) and a
31
+ * `{ file }` object pointing at a script on disk. The file keeps its
32
+ * own extension, so extension-aware loaders work (e.g.
33
+ * `node --import tsx` with a `.ts` file).
34
+ *
35
+ * @param binNameOrConfig - Binary name or a config object with `binName`
36
+ * and an optional `pattern`
37
+ * @returns A cleanup function that restores the original PATH
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * // Output shorthand
42
+ * const cleanup = await mockBin("gh", "mocked!!")
43
+ * ```
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * // Full script
48
+ * const cleanup = await mockBin("gh", "bash", 'echo "mocked!!"')
49
+ * // ... run your tests ...
50
+ * cleanup() // Restore original PATH
51
+ * ```
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * // Script file (keeps its extension, so tsx transforms it)
56
+ * const cleanup = await mockBin("dragon", "node --import tsx", {
57
+ * file: "./src/tests/hoard-script.ts",
58
+ * })
59
+ * ```
60
+ */
61
+ declare function mockBin(binNameOrConfig: string | MockBinConfig, output: string): Promise<MockBinCleanup>;
62
+ declare function mockBin(binNameOrConfig: string | MockBinConfig, shebang: string, code: string): Promise<MockBinCleanup>;
63
+ declare function mockBin(binNameOrConfig: string | MockBinConfig, shebang: string, script: MockBinScriptFile): Promise<MockBinCleanup>;
64
+ export { type MockBinCleanup, type MockBinConfig, type MockBinScriptFile, mockBin, };
@@ -0,0 +1,147 @@
1
+ import { rmSync } from "node:fs";
2
+ import { chmod, mkdtemp, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { mockBinWindows } from "./mock-bin-windows.js";
6
+ /**
7
+ * Finds the path to a binary within the given PATH directories.
8
+ *
9
+ * @param binName - The name of the binary to find
10
+ * @param pathDirs - Array of directories to search in
11
+ * @returns The full path to the binary, or null if not found
12
+ */
13
+ const findBinaryInPath = async (binName, pathDirs) => {
14
+ for (const dir of pathDirs) {
15
+ if (!dir)
16
+ continue;
17
+ const binaryPath = path.join(dir, binName);
18
+ try {
19
+ const stats = await stat(binaryPath);
20
+ if (stats.isFile())
21
+ return binaryPath;
22
+ }
23
+ catch {
24
+ // File doesn't exist, continue searching
25
+ }
26
+ }
27
+ return null;
28
+ };
29
+ /** Wraps a bare interpreter in a `#!/usr/bin/env …` shebang line. */
30
+ const toShebangLine = (interpreter) => interpreter.startsWith("#!") ? interpreter : `#!/usr/bin/env ${interpreter}`;
31
+ /**
32
+ * Validates the script file exists, then builds an `exec` wrapper that
33
+ * delegates to it through the given interpreter. The file keeps its real
34
+ * extension so extension-aware loaders (e.g. tsx) parse it.
35
+ */
36
+ const resolveScriptFile = async (shebang, { file }) => {
37
+ const resolvedFile = path.resolve(file);
38
+ const stats = await stat(resolvedFile).catch(() => null);
39
+ if (!stats?.isFile()) {
40
+ throw new Error(`mockBin: script file not found: ${file}`);
41
+ }
42
+ // Accept either a bare interpreter ("node --import tsx") or a full
43
+ // shebang line ("#!/usr/bin/env node"); strip "#!" for the exec line.
44
+ const interpreter = shebang.startsWith("#!")
45
+ ? shebang.slice(2).trim()
46
+ : shebang;
47
+ return {
48
+ shebang: "#!/bin/sh",
49
+ body: `exec ${interpreter} "${resolvedFile}" "$@"`,
50
+ };
51
+ };
52
+ /**
53
+ * Resolves the shebang and body for inline code or the output shorthand.
54
+ * With no `code`, `shebangOrOutput` is echoed via bash.
55
+ */
56
+ const resolveInlineCode = (shebangOrOutput, code) => {
57
+ const shebang = code === undefined ? "bash" : shebangOrOutput;
58
+ const body = code ?? `echo "${shebangOrOutput}"`;
59
+ return { shebang: toShebangLine(shebang), body };
60
+ };
61
+ async function mockBin(binNameOrConfig, shebangOrOutput, codeOrScript) {
62
+ const config = typeof binNameOrConfig === "string"
63
+ ? { binName: binNameOrConfig }
64
+ : binNameOrConfig;
65
+ const { binName, pattern } = config;
66
+ // Windows needs a real .exe on the PATH plus a NODE_OPTIONS preload
67
+ // that redirects the shim's entry to the mock script; everything else
68
+ // (pattern handling, cleanup contract) behaves the same.
69
+ if (process.platform === "win32")
70
+ return mockBinWindows(binName, pattern, shebangOrOutput, codeOrScript);
71
+ const originalPath = process.env.PATH ?? "";
72
+ const pathSeparator = path.delimiter;
73
+ const tempDir = await mkdtemp(path.join(tmpdir(), "mock-bin-"));
74
+ const mockScriptPath = path.join(tempDir, binName);
75
+ const runOriginalBinaryPath = path.join(tempDir, "mock-a-bin-run-original");
76
+ // Create the 'mock-a-bin-run-original' helper binary. The user's mock
77
+ // script can call it to delegate back to the real binary.
78
+ const runOriginalScript = `#!/bin/bash
79
+ # This binary finds and executes the original command
80
+ # It's called when the mock script decides to delegate to the real binary
81
+
82
+ # Restore original PATH to find the real binary
83
+ export PATH="${originalPath}"
84
+
85
+ # Find the original binary (excluding our temp directory)
86
+ ORIGINAL_BIN=$(command -v "${binName}" 2>/dev/null)
87
+
88
+ if [ -n "$ORIGINAL_BIN" ]; then
89
+ # Execute the original binary with all arguments
90
+ exec "$ORIGINAL_BIN" "$@"
91
+ else
92
+ echo "Error: Original '${binName}' command not found in PATH" >&2
93
+ exit 127
94
+ fi
95
+ `;
96
+ await writeFile(runOriginalBinaryPath, runOriginalScript);
97
+ await chmod(runOriginalBinaryPath, 0o755);
98
+ const { shebang, body } = typeof codeOrScript === "object"
99
+ ? await resolveScriptFile(shebangOrOutput, codeOrScript)
100
+ : resolveInlineCode(shebangOrOutput, codeOrScript);
101
+ // When a pattern is given, wrap the body so only matching commands
102
+ // are mocked; everything else is delegated to the real binary.
103
+ let userScriptContent;
104
+ if (pattern) {
105
+ const pathsWithoutTemp = originalPath
106
+ .split(pathSeparator)
107
+ .filter((p) => p && !p.includes("mock-bin-"));
108
+ const realBinaryPath = await findBinaryInPath(binName, pathsWithoutTemp);
109
+ userScriptContent = `${shebang}
110
+ # Construct the full command with arguments
111
+ FULL_COMMAND="${binName} $*"
112
+
113
+ # Check if the command matches the pattern
114
+ if echo "$FULL_COMMAND" | grep -qE '${pattern}'; then
115
+ # Pattern matches - execute mock code
116
+ ${body}
117
+ else
118
+ # Pattern doesn't match - execute the real binary
119
+ ${realBinaryPath ? `exec "${realBinaryPath}" "$@"` : `echo "Error: Real binary '${binName}' not found in PATH" >&2; exit 127`}
120
+ fi
121
+ `;
122
+ }
123
+ else {
124
+ userScriptContent = `${shebang}\n${body}\n`;
125
+ }
126
+ // Write the mock script directly to the binary path so it replaces the
127
+ // real binary on the PATH (no wrapper indirection needed: the shebang
128
+ // selects the interpreter).
129
+ await writeFile(mockScriptPath, userScriptContent);
130
+ await chmod(mockScriptPath, 0o755);
131
+ // Prepend the temp directory to PATH so the mock takes precedence.
132
+ process.env.PATH = `${tempDir}${pathSeparator}${originalPath}`;
133
+ return () => {
134
+ if (originalPath)
135
+ process.env.PATH = originalPath;
136
+ else
137
+ delete process.env.PATH;
138
+ try {
139
+ rmSync(tempDir, { recursive: true });
140
+ }
141
+ catch (error) {
142
+ // Ignore cleanup errors - temp dir will be cleaned up eventually
143
+ console.warn(`Warning: Failed to remove mock-bin temp directory ${tempDir}: ${String(error)}`);
144
+ }
145
+ };
146
+ }
147
+ export { mockBin, };