300 lines
7.9 KiB
TypeScript
300 lines
7.9 KiB
TypeScript
import { readFileSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export type WindowsSpawnResolution =
|
|
| "direct"
|
|
| "node-entrypoint"
|
|
| "exe-entrypoint"
|
|
| "shell-fallback";
|
|
|
|
export type WindowsSpawnCandidateResolution = Exclude<WindowsSpawnResolution, "shell-fallback">;
|
|
export type WindowsSpawnProgramCandidate = {
|
|
command: string;
|
|
leadingArgv: string[];
|
|
resolution: WindowsSpawnCandidateResolution | "unresolved-wrapper";
|
|
windowsHide?: boolean;
|
|
};
|
|
|
|
export type WindowsSpawnProgram = {
|
|
command: string;
|
|
leadingArgv: string[];
|
|
resolution: WindowsSpawnResolution;
|
|
shell?: boolean;
|
|
windowsHide?: boolean;
|
|
};
|
|
|
|
export type WindowsSpawnInvocation = {
|
|
command: string;
|
|
argv: string[];
|
|
resolution: WindowsSpawnResolution;
|
|
shell?: boolean;
|
|
windowsHide?: boolean;
|
|
};
|
|
|
|
export type ResolveWindowsSpawnProgramParams = {
|
|
command: string;
|
|
platform?: NodeJS.Platform;
|
|
env?: NodeJS.ProcessEnv;
|
|
execPath?: string;
|
|
packageName?: string;
|
|
allowShellFallback?: boolean;
|
|
};
|
|
export type ResolveWindowsSpawnProgramCandidateParams = Omit<
|
|
ResolveWindowsSpawnProgramParams,
|
|
"allowShellFallback"
|
|
>;
|
|
|
|
function isFilePath(candidate: string): boolean {
|
|
try {
|
|
return statSync(candidate).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string {
|
|
if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) {
|
|
return command;
|
|
}
|
|
|
|
const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
|
|
const pathEntries = pathValue
|
|
.split(";")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
const hasExtension = path.extname(command).length > 0;
|
|
const pathExtRaw =
|
|
env.PATHEXT ??
|
|
env.Pathext ??
|
|
process.env.PATHEXT ??
|
|
process.env.Pathext ??
|
|
".EXE;.CMD;.BAT;.COM";
|
|
const pathExt = hasExtension
|
|
? [""]
|
|
: pathExtRaw
|
|
.split(";")
|
|
.map((ext) => ext.trim())
|
|
.filter(Boolean)
|
|
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
|
|
|
|
for (const dir of pathEntries) {
|
|
for (const ext of pathExt) {
|
|
for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) {
|
|
const candidate = path.join(dir, `${command}${candidateExt}`);
|
|
if (isFilePath(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return command;
|
|
}
|
|
|
|
function resolveEntrypointFromCmdShim(wrapperPath: string): string | null {
|
|
if (!isFilePath(wrapperPath)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(wrapperPath, "utf8");
|
|
const candidates: string[] = [];
|
|
for (const match of content.matchAll(/"([^"\r\n]*)"/g)) {
|
|
const token = match[1] ?? "";
|
|
const relMatch = token.match(/%~?dp0%?\s*[\\/]*(.*)$/i);
|
|
const relative = relMatch?.[1]?.trim();
|
|
if (!relative) {
|
|
continue;
|
|
}
|
|
const normalizedRelative = relative.replace(/[\\/]+/g, path.sep).replace(/^[\\/]+/, "");
|
|
const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative);
|
|
if (isFilePath(candidate)) {
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
const nonNode = candidates.find((candidate) => {
|
|
const base = path.basename(candidate).toLowerCase();
|
|
return base !== "node.exe" && base !== "node";
|
|
});
|
|
return nonNode ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveBinEntry(
|
|
packageName: string | undefined,
|
|
binField: string | Record<string, string> | undefined,
|
|
): string | null {
|
|
if (typeof binField === "string") {
|
|
const trimmed = binField.trim();
|
|
return trimmed || null;
|
|
}
|
|
if (!binField || typeof binField !== "object") {
|
|
return null;
|
|
}
|
|
|
|
if (packageName) {
|
|
const preferred = binField[packageName];
|
|
if (typeof preferred === "string" && preferred.trim()) {
|
|
return preferred.trim();
|
|
}
|
|
}
|
|
|
|
for (const value of Object.values(binField)) {
|
|
if (typeof value === "string" && value.trim()) {
|
|
return value.trim();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveEntrypointFromPackageJson(
|
|
wrapperPath: string,
|
|
packageName?: string,
|
|
): string | null {
|
|
if (!packageName) {
|
|
return null;
|
|
}
|
|
|
|
const wrapperDir = path.dirname(wrapperPath);
|
|
const packageDirs = [
|
|
path.resolve(wrapperDir, "..", packageName),
|
|
path.resolve(wrapperDir, "node_modules", packageName),
|
|
];
|
|
|
|
for (const packageDir of packageDirs) {
|
|
const packageJsonPath = path.join(packageDir, "package.json");
|
|
if (!isFilePath(packageJsonPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
bin?: string | Record<string, string>;
|
|
};
|
|
const entryRel = resolveBinEntry(packageName, packageJson.bin);
|
|
if (!entryRel) {
|
|
continue;
|
|
}
|
|
const entryPath = path.resolve(packageDir, entryRel);
|
|
if (isFilePath(entryPath)) {
|
|
return entryPath;
|
|
}
|
|
} catch {
|
|
// Ignore malformed package metadata.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveWindowsSpawnProgramCandidate(
|
|
params: ResolveWindowsSpawnProgramCandidateParams,
|
|
): WindowsSpawnProgramCandidate {
|
|
const platform = params.platform ?? process.platform;
|
|
const env = params.env ?? process.env;
|
|
const execPath = params.execPath ?? process.execPath;
|
|
|
|
if (platform !== "win32") {
|
|
return {
|
|
command: params.command,
|
|
leadingArgv: [],
|
|
resolution: "direct",
|
|
};
|
|
}
|
|
|
|
const resolvedCommand = resolveWindowsExecutablePath(params.command, env);
|
|
const ext = path.extname(resolvedCommand).toLowerCase();
|
|
if (ext === ".js" || ext === ".cjs" || ext === ".mjs") {
|
|
return {
|
|
command: execPath,
|
|
leadingArgv: [resolvedCommand],
|
|
resolution: "node-entrypoint",
|
|
windowsHide: true,
|
|
};
|
|
}
|
|
|
|
if (ext === ".cmd" || ext === ".bat") {
|
|
const entrypoint =
|
|
resolveEntrypointFromCmdShim(resolvedCommand) ??
|
|
resolveEntrypointFromPackageJson(resolvedCommand, params.packageName);
|
|
if (entrypoint) {
|
|
const entryExt = path.extname(entrypoint).toLowerCase();
|
|
if (entryExt === ".exe") {
|
|
return {
|
|
command: entrypoint,
|
|
leadingArgv: [],
|
|
resolution: "exe-entrypoint",
|
|
windowsHide: true,
|
|
};
|
|
}
|
|
return {
|
|
command: execPath,
|
|
leadingArgv: [entrypoint],
|
|
resolution: "node-entrypoint",
|
|
windowsHide: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: resolvedCommand,
|
|
leadingArgv: [],
|
|
resolution: "unresolved-wrapper",
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: resolvedCommand,
|
|
leadingArgv: [],
|
|
resolution: "direct",
|
|
};
|
|
}
|
|
|
|
export function applyWindowsSpawnProgramPolicy(params: {
|
|
candidate: WindowsSpawnProgramCandidate;
|
|
allowShellFallback?: boolean;
|
|
}): WindowsSpawnProgram {
|
|
if (params.candidate.resolution !== "unresolved-wrapper") {
|
|
return {
|
|
command: params.candidate.command,
|
|
leadingArgv: params.candidate.leadingArgv,
|
|
resolution: params.candidate.resolution,
|
|
windowsHide: params.candidate.windowsHide,
|
|
};
|
|
}
|
|
if (params.allowShellFallback !== false) {
|
|
return {
|
|
command: params.candidate.command,
|
|
leadingArgv: [],
|
|
resolution: "shell-fallback",
|
|
shell: true,
|
|
};
|
|
}
|
|
throw new Error(
|
|
`${path.basename(params.candidate.command)} wrapper resolved, but no executable/Node entrypoint could be resolved without shell execution.`,
|
|
);
|
|
}
|
|
|
|
export function resolveWindowsSpawnProgram(
|
|
params: ResolveWindowsSpawnProgramParams,
|
|
): WindowsSpawnProgram {
|
|
const candidate = resolveWindowsSpawnProgramCandidate(params);
|
|
return applyWindowsSpawnProgramPolicy({
|
|
candidate,
|
|
allowShellFallback: params.allowShellFallback,
|
|
});
|
|
}
|
|
|
|
export function materializeWindowsSpawnProgram(
|
|
program: WindowsSpawnProgram,
|
|
argv: string[],
|
|
): WindowsSpawnInvocation {
|
|
return {
|
|
command: program.command,
|
|
argv: [...program.leadingArgv, ...argv],
|
|
resolution: program.resolution,
|
|
shell: program.shell,
|
|
windowsHide: program.windowsHide,
|
|
};
|
|
}
|