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.
- package/README.md +289 -32
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/mock-bin-behaviour-runtime.d.ts +21 -0
- package/dist/mock-bin-behaviour-runtime.js +164 -0
- package/dist/mock-bin-behaviour.d.ts +84 -0
- package/dist/mock-bin-behaviour.js +109 -0
- package/dist/mock-bin-env.d.ts +22 -0
- package/dist/mock-bin-env.js +31 -0
- package/dist/mock-bin-preload.d.ts +1 -21
- package/dist/mock-bin-preload.js +23 -216
- package/dist/mock-bin-runtime.d.ts +41 -0
- package/dist/mock-bin-runtime.js +293 -0
- package/dist/mock-bin-tsx.d.ts +4 -0
- package/dist/mock-bin-tsx.js +30 -0
- package/dist/mock-bin-windows.d.ts +6 -4
- package/dist/mock-bin-windows.js +137 -67
- package/dist/mock-bin.d.ts +31 -3
- package/dist/mock-bin.js +50 -12
- package/dist/native/win32/arm64/type-a-bin-trampoline.exe +0 -0
- package/dist/native/win32/x64/type-a-bin-trampoline.exe +0 -0
- package/dist/rm-scratch.d.ts +11 -0
- package/dist/rm-scratch.js +25 -0
- package/package.json +2 -2
|
@@ -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,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.
|
|
5
|
-
* `mock-a-bin-run-original.exe` helper
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 };
|
package/dist/mock-bin-windows.js
CHANGED
|
@@ -1,25 +1,73 @@
|
|
|
1
|
-
import { copyFileSync,
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
|
19
|
-
//
|
|
20
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
118
|
+
return fileURLToPath(word).split(/[\\/]/).includes("tsx");
|
|
65
119
|
}
|
|
66
120
|
catch {
|
|
67
|
-
|
|
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.
|
|
94
|
-
* `mock-a-bin-run-original.exe` helper
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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 };
|