type-a-bin 0.1.2 → 0.1.3

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.
@@ -0,0 +1,293 @@
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
+ // Shared Windows dispatch behind both entry points of a mock:
8
+ //
9
+ // - the trampoline launcher (native/trampoline.c) starts Node with a
10
+ // generated bootstrap that calls runTrampoline(), and
11
+ // - the NODE_OPTIONS preload (mock-bin-preload) calls interceptShim()
12
+ // for the legacy node.exe-hardlink shims.
13
+ //
14
+ // This module runs inside spawned child processes — as the compiled
15
+ // .js from the published package or as the .ts source under node's
16
+ // native type stripping — so it must stay free of imports beyond
17
+ // node's own modules. The registry env var name is duplicated from
18
+ // mock-bin-env for the same reason; the two must stay in sync.
19
+ const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
20
+ const HELPER_NAME = "mock-a-bin-run-original";
21
+ const PATH_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".com"];
22
+ const TS_EXTENSIONS = [".cts", ".mts", ".ts", ".tsx"];
23
+ const BASH_LIKE_INTERPRETERS = ["bash", "dash", "ksh", "sh", "zsh"];
24
+ const readMocks = () => {
25
+ try {
26
+ return JSON.parse(process.env[MOCKS_VAR] ?? "{}");
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ };
32
+ const writeError = (message) => {
33
+ process.stderr.write(`${message}\n`);
34
+ };
35
+ // Lookup failures exit 127, matching the POSIX mock scripts.
36
+ const fail = (message) => {
37
+ writeError(message);
38
+ process.exit(127);
39
+ };
40
+ const isFile = (candidate) => {
41
+ try {
42
+ return statSync(candidate).isFile();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ };
48
+ const searchPathDirs = (originalPath) => (originalPath ?? process.env.PATH ?? "")
49
+ .split(delimiter)
50
+ .filter((dir) => dir !== "" && !dir.includes("mock-bin-"));
51
+ const pathCandidates = (name, dirs) => {
52
+ const candidates = [];
53
+ for (const dir of dirs)
54
+ for (const extension of PATH_EXTENSIONS) {
55
+ const candidate = join(dir, `${name}${extension}`);
56
+ if (isFile(candidate))
57
+ candidates.push(candidate);
58
+ }
59
+ return candidates;
60
+ };
61
+ const findExecutable = (name, dirs) => pathCandidates(name, dirs)[0] ?? null;
62
+ // Runs a resolved command with the caller's stdio and exits with its
63
+ // status; a failed spawn exits 127.
64
+ const spawnAndExit = (command, args, errorPrefix) => {
65
+ const result = spawnSync(command, args, { stdio: "inherit" });
66
+ if (result.error !== undefined)
67
+ writeError(`${errorPrefix}: ${String(result.error)}`);
68
+ process.exit(result.status ?? 127);
69
+ };
70
+ // The real binary must not be re-intercepted by a shim preload.
71
+ const spawnRealAndExit = (command, args) => spawnAndExit(command, args, `Error: '${command}' failed to start`);
72
+ const runOriginalCommand = (spec, cliArgs) => {
73
+ if (spec === undefined)
74
+ return fail(`Error: ${HELPER_NAME} used outside a mockBin context`);
75
+ const real = findExecutable(spec.binName, searchPathDirs(spec.originalPath));
76
+ if (real === null)
77
+ return fail(`Error: Original '${spec.binName}' command not found in PATH`);
78
+ return spawnRealAndExit(real, cliArgs);
79
+ };
80
+ const runRealBinary = (invokedName, cliArgs, originalPath) => {
81
+ const real = findExecutable(invokedName, searchPathDirs(originalPath));
82
+ if (real === null)
83
+ return fail(`Error: Real binary '${invokedName}' not found in PATH`);
84
+ return spawnRealAndExit(real, cliArgs);
85
+ };
86
+ // WSL's bash launcher lives in the Windows system directories; it cannot
87
+ // run Windows-path scripts, so bash-like interpreters prefer a native
88
+ // shell (e.g. Git for Windows) and fall back to well-known Git installs.
89
+ const isWslLauncher = (candidate) => {
90
+ const lower = candidate.toLowerCase();
91
+ return (lower.includes("\\windows\\system32\\") || lower.includes("\\windowsapps\\"));
92
+ };
93
+ const gitBashCandidates = () => {
94
+ const roots = [
95
+ process.env.ProgramFiles ?? "",
96
+ process.env["ProgramFiles(x86)"] ?? "",
97
+ join(process.env.LOCALAPPDATA ?? "", "Programs"),
98
+ ];
99
+ const locations = [];
100
+ for (const root of roots)
101
+ if (root !== "") {
102
+ locations.push(join(root, "Git", "bin", "bash.exe"));
103
+ locations.push(join(root, "Git", "usr", "bin", "bash.exe"));
104
+ }
105
+ return locations;
106
+ };
107
+ const resolveInterpreter = (interpreter) => {
108
+ const dirs = (process.env.PATH ?? "")
109
+ .split(delimiter)
110
+ .filter((dir) => dir !== "");
111
+ const candidates = pathCandidates(interpreter, dirs);
112
+ if (BASH_LIKE_INTERPRETERS.includes(interpreter)) {
113
+ const native = candidates.find((candidate) => !isWslLauncher(candidate));
114
+ if (native !== undefined)
115
+ return native;
116
+ for (const location of gitBashCandidates())
117
+ if (isFile(location))
118
+ return location;
119
+ }
120
+ return candidates[0] ?? null;
121
+ };
122
+ const runInterpreterAndExit = (interpreter, entry, cliArgs) => {
123
+ const interpreterPath = resolveInterpreter(interpreter);
124
+ if (interpreterPath === null)
125
+ return fail(`Error: Interpreter '${interpreter}' not found in PATH`);
126
+ return spawnAndExit(interpreterPath, [entry, ...cliArgs], `Error: Interpreter '${interpreter}' failed`);
127
+ };
128
+ // tsx registers its CommonJS hooks from a separate entry point; load it
129
+ // lazily so non-TypeScript mocks never require the tsx package.
130
+ const loadTsxCommonJs = (entry) => {
131
+ if (!TS_EXTENSIONS.includes(extname(entry).toLowerCase()))
132
+ return;
133
+ try {
134
+ createRequire(import.meta.url)("tsx/cjs");
135
+ }
136
+ catch {
137
+ // tsx is not installed: node's native type stripping applies instead.
138
+ }
139
+ };
140
+ // Node rewrites the CLI entry in argv[1] to an absolute path before
141
+ // preloads run, losing the argument as the caller typed it. Recover it
142
+ // relative to the working directory when possible, so mocks, patterns,
143
+ // and spawned interpreters see "pr" instead of "C:\repo\pr".
144
+ const denormalizeEntry = (entry) => {
145
+ const relativePath = relative(process.cwd(), entry);
146
+ if (relativePath === "" || relativePath.startsWith(".."))
147
+ return entry;
148
+ return relativePath;
149
+ };
150
+ // Eval/print runs carry their program in execArgv, so argv[1] is
151
+ // undefined — the shape runEntryDirectly would otherwise hijack — and a
152
+ // helper spawned from inside a mock through the shim must run its
153
+ // snippet untouched.
154
+ const EVAL_FLAG = /^(?:-e|-p|--eval|--print)(?:=|$)/;
155
+ const isEvalRun = (execArgv) => execArgv.some((arg) => EVAL_FLAG.test(arg));
156
+ const asPath = (specifier) => specifier.startsWith("file:") ? fileURLToPath(specifier) : specifier;
157
+ // Repositions argv to [node, entry, ...cliArgs] so the mock reads its
158
+ // CLI arguments at process.argv.slice(2) like a real Node CLI script.
159
+ const setArgvEntry = (entry, cliArgs) => {
160
+ process.argv.length = 1;
161
+ process.argv.push(entry, ...cliArgs);
162
+ };
163
+ const redirectNodeEntry = (entry, cliArgs) => {
164
+ // Capture the CLI entry before repositioning argv: node normalized it
165
+ // to an absolute path, and the redirects below must recognize it.
166
+ const originalEntry = process.argv[1] ?? "";
167
+ setArgvEntry(entry, cliArgs);
168
+ // ESM main entry: resolve hooks see the main module with no parent URL,
169
+ // so redirect that one resolution to the mock script. The tsx loader
170
+ // (registered before this preload in NODE_OPTIONS) transforms .ts
171
+ // entries as part of the same resolve chain.
172
+ registerHooks({
173
+ resolve: (specifier, context, nextResolve) => {
174
+ if (context.parentURL == null && asPath(specifier) === originalEntry)
175
+ return nextResolve(pathToFileURL(entry).href, context);
176
+ return nextResolve(specifier, context);
177
+ },
178
+ });
179
+ // CommonJS main entry: Module._load receives the CLI entry with isMain
180
+ // set — load the mock through the CommonJS loader instead.
181
+ const moduleApi = Module;
182
+ const originalLoad = moduleApi._load;
183
+ moduleApi._load = (request, parent, isMain) => {
184
+ if (isMain && request === originalEntry) {
185
+ moduleApi._load = originalLoad;
186
+ loadTsxCommonJs(entry);
187
+ return originalLoad(entry, null, true);
188
+ }
189
+ return originalLoad(request, parent, isMain);
190
+ };
191
+ };
192
+ // A shim spawn with no CLI arguments leaves node without an entry (the
193
+ // REPL would start), so the mock module is imported directly instead.
194
+ const runEntryDirectly = async (entry, cliArgs) => {
195
+ setArgvEntry(entry, cliArgs);
196
+ await import(pathToFileURL(entry).href);
197
+ // An import settles when the entry's top level finishes — before the
198
+ // output of a mock that defers work (timers, stdin). Node would start
199
+ // the REPL the moment this preload settles, so hold until the event
200
+ // loop drains, then exit with whatever the mock set. A mock holding
201
+ // the loop open on purpose (trapped signals) never drains, and so
202
+ // lives until it is killed.
203
+ await new Promise(() => process.once("beforeExit", () => process.exit(process.exitCode)));
204
+ };
205
+ /**
206
+ * Runs a node-kind mock entry as the process's main module from the
207
+ * trampoline bootstrap. Unlike a shim, the process already has a real
208
+ * main module (the bootstrap itself), so there is no REPL to avoid and
209
+ * no entry redirection needed. CommonJS entries load through
210
+ * `Module._load` with `isMain` set, so `require.main` matches a script
211
+ * started as `node entry.cjs`; other entries load as ESM, with tsx
212
+ * registered first when the target carries a loader URL.
213
+ */
214
+ const runNodeEntryAsMain = async (entry, cliArgs, tsxImportUrl) => {
215
+ setArgvEntry(entry, cliArgs);
216
+ if (extname(entry).toLowerCase() === ".cjs") {
217
+ const moduleApi = Module;
218
+ moduleApi._load(entry, null, true);
219
+ return;
220
+ }
221
+ if (tsxImportUrl !== undefined)
222
+ await import(tsxImportUrl);
223
+ await import(pathToFileURL(entry).href);
224
+ };
225
+ // Dispatch shared by both entry points: a pattern miss hands the
226
+ // invocation to the real binary, non-node targets run through their
227
+ // interpreter, and node targets load however the entry point chooses.
228
+ const dispatchTarget = async (target, invokedName, cliArgs, runNodeEntry) => {
229
+ const commandLine = `${invokedName} ${cliArgs.join(" ")}`;
230
+ const mocked = target.pattern === undefined ||
231
+ new RegExp(target.pattern).test(commandLine);
232
+ if (!mocked)
233
+ return runRealBinary(invokedName, cliArgs, target.originalPath);
234
+ if (target.kind !== "node")
235
+ return runInterpreterAndExit(target.interpreter ?? "bash", target.entry, cliArgs);
236
+ return runNodeEntry(target.entry, cliArgs);
237
+ };
238
+ // Basename without the executable extension: mock-a-bin-run-original.exe
239
+ // and claude.exe both register under their extensionless names.
240
+ const toInvokedName = (exePath) => basename(exePath, extname(exePath));
241
+ /**
242
+ * Trampoline entry point. The native launcher starts Node as
243
+ * `[node, mock-bin-trampoline.cjs, <mock>.exe, ...originalArgs]`, so the
244
+ * invoked binary is the path in argv[2] and every argument after it is
245
+ * the caller's original argv — Node's option parser never sees it.
246
+ */
247
+ const runTrampoline = async () => {
248
+ const invokedExe = process.argv[2];
249
+ if (invokedExe === undefined)
250
+ return fail("Error: type-a-bin trampoline invoked without a mock path");
251
+ const args = process.argv.slice(3);
252
+ const invokedName = toInvokedName(invokedExe);
253
+ const mocks = readMocks();
254
+ if (invokedName === HELPER_NAME)
255
+ return runOriginalCommand(mocks.runOriginal, args);
256
+ const target = mocks.targets?.[invokedName];
257
+ // No registry entry (e.g. a spawn through withoutMocks) must not land
258
+ // in a REPL or a crash: hand the invocation to the real binary.
259
+ if (target === undefined)
260
+ return runRealBinary(invokedName, args, undefined);
261
+ return dispatchTarget(target, invokedName, args, (entry, cliArgs) => runNodeEntryAsMain(entry, cliArgs, target.tsxImportUrl));
262
+ };
263
+ /**
264
+ * Shim entry point for the NODE_OPTIONS preload: the process itself is
265
+ * a hard link of node.exe named after the mocked binary, so the main
266
+ * entry is redirected in-process once the registry recognizes the
267
+ * invocation. Kept as the fallback behind the trampoline rollout.
268
+ */
269
+ const interceptShim = async () => {
270
+ const invokedName = toInvokedName(process.argv[0] ?? "");
271
+ // The shim's whole command line after the exe are CLI arguments:
272
+ // unlike a Node script there is no "entry" consuming the first
273
+ // positional.
274
+ const cliArgs = process.argv.length === 1
275
+ ? []
276
+ : [denormalizeEntry(process.argv[1] ?? ""), ...process.argv.slice(2)];
277
+ if (isEvalRun(process.execArgv))
278
+ return;
279
+ const mocks = readMocks();
280
+ if (invokedName === HELPER_NAME)
281
+ return runOriginalCommand(mocks.runOriginal, cliArgs);
282
+ const target = mocks.targets?.[invokedName];
283
+ if (target === undefined)
284
+ return;
285
+ // A process whose CLI entry is a real file is not a shim: mock scripts
286
+ // spawned through process.execPath (tsx's esbuild service, `node -e`
287
+ // helpers) inherit the shim exe's name, but their entry exists on
288
+ // disk while a shim's "subcommand" entry never does.
289
+ if (isFile(process.argv[1] ?? ""))
290
+ return;
291
+ return dispatchTarget(target, invokedName, cliArgs, process.argv[1] === undefined ? runEntryDirectly : redirectNodeEntry);
292
+ };
293
+ export { interceptShim, runTrampoline, };
@@ -0,0 +1,4 @@
1
+ declare const isTypeScriptFile: (file: string) => boolean;
2
+ /** Absolute file URL of the tsx loader, or null when tsx is not installed. */
3
+ declare const resolveTsxImportUrl: (scriptFile?: string) => string | null;
4
+ export { isTypeScriptFile, resolveTsxImportUrl };
@@ -0,0 +1,30 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ // A mock run through `node --import tsx` only resolves the bare `tsx`
5
+ // specifier while its working directory sits inside the package that
6
+ // installed tsx; a mock spawned from a temp directory loses it. The
7
+ // loader is therefore resolved to an absolute file URL up front — the
8
+ // POSIX mock embeds it in its exec line, the Windows shim carries it in
9
+ // NODE_OPTIONS — from the script's own package first (workspace-local
10
+ // installs included) and type-a-bin's own tree second (scripts written
11
+ // to a temp directory). When neither resolves, null comes back and
12
+ // node's native type stripping parses the script instead.
13
+ const TS_EXTENSIONS = [".cts", ".mts", ".ts", ".tsx"];
14
+ const isTypeScriptFile = (file) => TS_EXTENSIONS.includes(path.extname(file).toLowerCase());
15
+ /** Absolute file URL of the tsx loader, or null when tsx is not installed. */
16
+ const resolveTsxImportUrl = (scriptFile) => {
17
+ const bases = [
18
+ ...(scriptFile === undefined ? [] : [path.resolve(scriptFile)]),
19
+ import.meta.url,
20
+ ];
21
+ for (const base of bases)
22
+ try {
23
+ return pathToFileURL(createRequire(base).resolve("tsx")).href;
24
+ }
25
+ catch {
26
+ // tsx is not resolvable from this base — try the next one.
27
+ }
28
+ return null;
29
+ };
30
+ export { isTypeScriptFile, resolveTsxImportUrl };
@@ -1,10 +1,12 @@
1
1
  import type { MockBinScriptFile } from "./mock-bin.js";
2
2
  /**
3
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.
4
+ * overloads. By default it installs copies of the trampoline launcher
5
+ * as `<binName>.exe` and the `mock-a-bin-run-original.exe` helper plus
6
+ * a generated bootstrap into a temp directory prepended to PATH, and
7
+ * registers the mock in the dispatch registry. Setting
8
+ * TYPE_A_BIN_DISABLE_TRAMPOLINE=1 (or a missing launcher) falls back to
9
+ * the legacy node.exe hard links with the NODE_OPTIONS preload.
8
10
  */
9
11
  declare const mockBinWindows: (binName: string, pattern: string | undefined, shebangOrOutput: string, codeOrScript: string | MockBinScriptFile | undefined) => Promise<() => void>;
10
12
  export { mockBinWindows };
@@ -1,25 +1,73 @@
1
- import { copyFileSync, linkSync, rmSync } from "node:fs";
1
+ import { copyFileSync, existsSync, linkSync } from "node:fs";
2
2
  import { mkdtemp, stat, writeFile } from "node:fs/promises";
3
- import { createRequire } from "node:module";
4
3
  import { tmpdir } from "node:os";
5
4
  import path from "node:path";
6
5
  import process from "node:process";
7
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { MOCKS_VAR } from "./mock-bin-env.js";
8
+ import { resolveTsxImportUrl } from "./mock-bin-tsx.js";
9
+ import { rmScratch } from "./rm-scratch.js";
8
10
  // Windows twin of the POSIX mockBin: PATH interception needs a real
9
11
  // executable there (node refuses to spawn .cmd/.bat shims without a
10
12
  // 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";
13
+ // mock is a copy of a tiny native trampoline (native/trampoline.c)
14
+ // named <bin>.exe. The trampoline launches Node with a generated
15
+ // bootstrap as the script and the invoked mock plus the original argv
16
+ // as plain arguments so leading flags, quotes, and line breaks reach
17
+ // the mock verbatim instead of being parsed by Node's own CLI parser
18
+ // (the limitation of the previous node.exe-hardlink mechanism). The
19
+ // bootstrap dispatches through mock-bin-runtime; argv, stdin, stdout,
20
+ // stderr, and exit codes all pass through. The registry travels in the
21
+ // env var named by MOCKS_VAR; mock-bin-runtime keeps its own copy of
22
+ // the name, so the two must stay in sync.
17
23
  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).
24
+ // The Node executable to launch, recorded when the mock is installed
25
+ // and read by the native trampoline (which also owns its name).
26
+ const NODE_EXE_VAR = "TYPE_A_BIN_NODE_EXE";
27
+ // Temporary escape hatch: set to "1" to force the legacy node.exe
28
+ // hard-link shims plus the NODE_OPTIONS preload while the trampoline
29
+ // rollout is validated. Removed once both Windows architectures are
30
+ // covered.
31
+ const DISABLE_TRAMPOLINE_VAR = "TYPE_A_BIN_DISABLE_TRAMPOLINE";
32
+ const TRAMPOLINE_NAME = "type-a-bin-trampoline.exe";
33
+ const BOOTSTRAP_NAME = "mock-bin-trampoline.cjs";
34
+ // The dispatch module ships next to this one: mock-bin-runtime.js in
35
+ // the published dist build, mock-bin-runtime.ts when running from
36
+ // source (node's native type stripping loads the .ts form in child
37
+ // processes).
21
38
  const ownPath = fileURLToPath(import.meta.url);
39
+ const runtimeUrl = pathToFileURL(path.join(path.dirname(ownPath), `mock-bin-runtime${path.extname(ownPath)}`)).href;
40
+ // The preload ships the same way and remains the fallback mechanism.
22
41
  const preloadPath = path.join(path.dirname(ownPath), `mock-bin-preload${path.extname(ownPath)}`);
42
+ /**
43
+ * Locates the checked-in trampoline launcher for this platform: the
44
+ * copy under `dist/native` in the published package first, the source
45
+ * checkout's `native/bin` second (tests running before a build). Both
46
+ * supported Windows architectures ship prebuilt; anything else falls
47
+ * back to the legacy shims.
48
+ */
49
+ const trampolineSourcePath = () => {
50
+ if (process.platform !== "win32")
51
+ return null;
52
+ if (process.arch !== "x64" && process.arch !== "arm64")
53
+ return null;
54
+ const ownDir = path.dirname(ownPath);
55
+ const candidates = [
56
+ path.join(ownDir, "native", "win32", process.arch, TRAMPOLINE_NAME),
57
+ path.join(path.dirname(ownDir), "native", "bin", "win32", process.arch, TRAMPOLINE_NAME),
58
+ ];
59
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
60
+ };
61
+ /**
62
+ * The bootstrap the native trampoline runs: Node's main module, whose
63
+ * only job is handing the invoked mock and the original argv to the
64
+ * shared dispatch in mock-bin-runtime.
65
+ */
66
+ const bootstrapCode = (url) => `// Generated by type-a-bin: the Windows trampoline launcher starts\n` +
67
+ `// Node with this script, passing the invoked mock and the original\n` +
68
+ `// argv as plain arguments.\n` +
69
+ `import(${JSON.stringify(url)}).then((runtime) =>\n` +
70
+ ` runtime.runTrampoline(),\n);\n`;
23
71
  const readMocks = () => {
24
72
  try {
25
73
  return JSON.parse(process.env[MOCKS_VAR] ?? "{}");
@@ -58,15 +106,19 @@ const toInterpreterWords = (shebang) => shebang
58
106
  const basenameWithoutExecutableExtension = (word) => path.basename(word).replace(/\.(?:bat|cmd|com|exe)$/iu, "");
59
107
  const isNodeInterpreter = (words) => basenameWithoutExecutableExtension(words[0] ?? "").toLowerCase() === "node";
60
108
  const interpreterExecutableName = (word) => word === undefined ? "bash" : basenameWithoutExecutableExtension(word);
61
- const usesTsx = (words) => words.some((word) => word === "tsx" || word.startsWith("tsx/"));
62
- const resolveTsxImportUrl = () => {
109
+ // A `--import` word names tsx when it is the bare package ("tsx",
110
+ // "tsx/cjs") or a file URL resolving into it — the script-file
111
+ // shorthand embeds an absolute loader URL.
112
+ const isTsxImport = (word) => {
113
+ if (word === "tsx" || word.startsWith("tsx/"))
114
+ return true;
115
+ if (!word.startsWith("file:"))
116
+ return false;
63
117
  try {
64
- return pathToFileURL(createRequire(import.meta.url).resolve("tsx")).href;
118
+ return fileURLToPath(word).split(/[\\/]/).includes("tsx");
65
119
  }
66
120
  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.");
121
+ return false;
70
122
  }
71
123
  };
72
124
  const toShebangLine = (interpreter) => interpreter.startsWith("#!") ? interpreter : `#!/usr/bin/env ${interpreter}`;
@@ -88,12 +140,48 @@ const assertScriptFile = async (file) => {
88
140
  if (!stats?.isFile())
89
141
  throw new Error(`mockBin: script file not found: ${file}`);
90
142
  };
143
+ /** Copies the trampoline launcher into the mock directory. */
144
+ const installTrampoline = async (tempDir, binBase, source) => {
145
+ copyFileSync(source, path.join(tempDir, `${binBase}.exe`));
146
+ copyFileSync(source, path.join(tempDir, `${HELPER_NAME}.exe`));
147
+ await writeFile(path.join(tempDir, BOOTSTRAP_NAME), bootstrapCode(runtimeUrl));
148
+ };
149
+ /**
150
+ * Writes the mock's entry script into the mock directory and returns
151
+ * its path. Script files keep their real extension so extension-aware
152
+ * loaders (e.g. node --import tsx) parse them; inline node code runs as
153
+ * CommonJS, matching the extensionless scripts the POSIX implementation
154
+ * writes; other interpreters get a shebang script.
155
+ */
156
+ const writeMockEntry = async (tempDir, binBase, shebangOrOutput, codeOrScript, isNodeMock) => {
157
+ if (typeof codeOrScript === "object")
158
+ return path.resolve(codeOrScript.file);
159
+ if (isNodeMock) {
160
+ const entry = path.join(tempDir, `${binBase}-mock.cjs`);
161
+ const code = codeOrScript ?? echoScript(shebangOrOutput);
162
+ await writeFile(entry, `${code}\n`);
163
+ return entry;
164
+ }
165
+ const entry = path.join(tempDir, `${binBase}-mock.sh`);
166
+ await writeFile(entry, `${toShebangLine(shebangOrOutput)}\n${codeOrScript}\n`);
167
+ return entry;
168
+ };
169
+ // Prepends --import flags ahead of a previous NODE_OPTIONS value so
170
+ // the preload chain runs first in spawned processes.
171
+ const importsAheadOf = (imports, previous) => {
172
+ const prepended = imports.join(" ");
173
+ return previous === undefined || previous === ""
174
+ ? prepended
175
+ : `${prepended} ${previous}`;
176
+ };
91
177
  /**
92
178
  * 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.
179
+ * overloads. By default it installs copies of the trampoline launcher
180
+ * as `<binName>.exe` and the `mock-a-bin-run-original.exe` helper plus
181
+ * a generated bootstrap into a temp directory prepended to PATH, and
182
+ * registers the mock in the dispatch registry. Setting
183
+ * TYPE_A_BIN_DISABLE_TRAMPOLINE=1 (or a missing launcher) falls back to
184
+ * the legacy node.exe hard links with the NODE_OPTIONS preload.
97
185
  */
98
186
  const mockBinWindows = async (binName, pattern, shebangOrOutput, codeOrScript) => {
99
187
  if (typeof codeOrScript === "object")
@@ -101,39 +189,39 @@ const mockBinWindows = async (binName, pattern, shebangOrOutput, codeOrScript) =
101
189
  const originalPath = process.env.PATH ?? "";
102
190
  const previousNodeOptions = process.env.NODE_OPTIONS;
103
191
  const previousMocks = process.env[MOCKS_VAR];
192
+ const previousNodeExe = process.env[NODE_EXE_VAR];
104
193
  // A binName already carrying .exe stays a valid executable name while
105
194
  // the registry key matches the shim's argv[0] basename.
106
195
  const binBase = binName.replace(/\.exe$/iu, "");
107
196
  const tempDir = await mkdtemp(path.join(tmpdir(), "mock-bin-"));
108
- linkNodeExecutable(path.join(tempDir, `${binBase}.exe`));
109
- linkNodeExecutable(path.join(tempDir, `${HELPER_NAME}.exe`));
197
+ const trampolineSource = process.env[DISABLE_TRAMPOLINE_VAR] === "1" ? null : trampolineSourcePath();
198
+ // The null case falls back to the legacy node.exe hard links.
199
+ if (trampolineSource !== null)
200
+ await installTrampoline(tempDir, binBase, trampolineSource);
201
+ else {
202
+ linkNodeExecutable(path.join(tempDir, `${binBase}.exe`));
203
+ linkNodeExecutable(path.join(tempDir, `${HELPER_NAME}.exe`));
204
+ }
110
205
  const words = codeOrScript === undefined ? ["node"] : toInterpreterWords(shebangOrOutput);
111
206
  const isNodeMock = codeOrScript === undefined || isNodeInterpreter(words);
112
207
  const interpreter = isNodeMock
113
208
  ? undefined
114
209
  : 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
- }
210
+ const entry = await writeMockEntry(tempDir, binBase, shebangOrOutput, codeOrScript, isNodeMock);
211
+ // The tsx loader is resolved to an absolute file URL up front: the
212
+ // trampoline bootstrap imports it before a .ts entry, and the legacy
213
+ // NODE_OPTIONS chain carries it ahead of the preload. Without tsx,
214
+ // node's native type stripping applies.
215
+ const scriptFile = typeof codeOrScript === "object" ? codeOrScript.file : undefined;
216
+ const tsxImportUrl = isNodeMock && words.some(isTsxImport)
217
+ ? resolveTsxImportUrl(scriptFile)
218
+ : null;
132
219
  const target = {
133
220
  kind: isNodeMock ? "node" : "spawn",
134
221
  entry,
135
222
  ...(interpreter === undefined ? {} : { interpreter }),
136
223
  ...(pattern ? { pattern } : {}),
224
+ ...(tsxImportUrl === null ? {} : { tsxImportUrl }),
137
225
  originalPath,
138
226
  };
139
227
  const mocks = readMocks();
@@ -142,19 +230,13 @@ const mockBinWindows = async (binName, pattern, shebangOrOutput, codeOrScript) =
142
230
  targets: { ...mocks.targets, [binBase]: target },
143
231
  runOriginal: { binName: binBase, originalPath },
144
232
  });
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}`;
233
+ if (trampolineSource !== null)
234
+ process.env[NODE_EXE_VAR] = process.execPath;
235
+ else
236
+ process.env.NODE_OPTIONS = importsAheadOf([
237
+ ...(tsxImportUrl === null ? [] : [`--import ${tsxImportUrl}`]),
238
+ `--import ${pathToFileURL(preloadPath).href}`,
239
+ ], previousNodeOptions);
158
240
  process.env.PATH = `${tempDir}${path.delimiter}${originalPath}`;
159
241
  return () => {
160
242
  if (originalPath === "")
@@ -163,20 +245,8 @@ const mockBinWindows = async (binName, pattern, shebangOrOutput, codeOrScript) =
163
245
  process.env.PATH = originalPath;
164
246
  restoreEnv("NODE_OPTIONS", previousNodeOptions);
165
247
  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
- }
248
+ restoreEnv(NODE_EXE_VAR, previousNodeExe);
249
+ rmScratch(tempDir);
180
250
  };
181
251
  };
182
252
  export { mockBinWindows };