mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor(lobster): extract windows spawn resolver
This commit is contained in:
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
type LobsterEnvelope =
|
||||
| {
|
||||
@@ -84,148 +85,6 @@ function resolveCwd(cwdRaw: unknown): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isFilePath(value: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(value);
|
||||
return stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string {
|
||||
if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) {
|
||||
return execPath;
|
||||
}
|
||||
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(execPath).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, `${execPath}${candidateExt}`);
|
||||
if (isFilePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return execPath;
|
||||
}
|
||||
|
||||
function resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null {
|
||||
const wrapperDir = path.dirname(wrapperPath);
|
||||
const packageDirs = [
|
||||
// Local install: <repo>/node_modules/.bin/lobster.cmd -> ../lobster
|
||||
path.resolve(wrapperDir, "..", "lobster"),
|
||||
// Global npm install: <npm-prefix>/lobster.cmd -> ./node_modules/lobster
|
||||
path.resolve(wrapperDir, "node_modules", "lobster"),
|
||||
];
|
||||
for (const packageDir of packageDirs) {
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
if (!isFilePath(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const binField = packageJson.bin;
|
||||
const scriptRel =
|
||||
typeof binField === "string"
|
||||
? binField
|
||||
: typeof binField === "object" && binField
|
||||
? typeof binField.lobster === "string"
|
||||
? binField.lobster
|
||||
: (() => {
|
||||
const first = Object.values(binField).find((value) => typeof value === "string");
|
||||
return typeof first === "string" ? first : null;
|
||||
})()
|
||||
: null;
|
||||
if (!scriptRel) {
|
||||
continue;
|
||||
}
|
||||
const scriptPath = path.resolve(packageDir, scriptRel);
|
||||
if (isFilePath(scriptPath)) {
|
||||
return scriptPath;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed package metadata; caller will throw a guided error.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null {
|
||||
if (!isFilePath(wrapperPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(wrapperPath, "utf8");
|
||||
// npm-style cmd shims usually reference the script as "%dp0%\\...".
|
||||
const candidates: string[] = [];
|
||||
const matches = content.matchAll(/"%~?dp0%\\([^"\r\n]+)"/gi);
|
||||
for (const match of matches) {
|
||||
const relative = match[1];
|
||||
if (!relative) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRelative = relative.replace(/[\\/]+/g, path.sep);
|
||||
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";
|
||||
});
|
||||
if (nonNode) {
|
||||
return nonNode;
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable shims; caller will throw a guided error.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWindowsLobsterSpawn(execPath: string, argv: string[], env: NodeJS.ProcessEnv) {
|
||||
const resolvedExecPath = resolveWindowsExecutablePath(execPath, env);
|
||||
const ext = path.extname(resolvedExecPath).toLowerCase();
|
||||
if (ext !== ".cmd" && ext !== ".bat") {
|
||||
return { command: resolvedExecPath, argv };
|
||||
}
|
||||
const scriptPath =
|
||||
resolveLobsterScriptFromCmdShim(resolvedExecPath) ??
|
||||
resolveLobsterScriptFromPackageJson(resolvedExecPath);
|
||||
if (!scriptPath) {
|
||||
throw new Error(
|
||||
`lobsterPath resolved to ${path.basename(resolvedExecPath)} wrapper, but no Node entrypoint could be resolved without shell execution. Configure pluginConfig.lobsterPath to lobster.exe.`,
|
||||
);
|
||||
}
|
||||
const entryExt = path.extname(scriptPath).toLowerCase();
|
||||
if (entryExt === ".exe") {
|
||||
return { command: scriptPath, argv, windowsHide: true };
|
||||
}
|
||||
return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true };
|
||||
}
|
||||
|
||||
async function runLobsterSubprocessOnce(params: {
|
||||
execPath: string;
|
||||
argv: string[];
|
||||
@@ -258,6 +117,30 @@ async function runLobsterSubprocessOnce(params: {
|
||||
let stdout = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const settle = (
|
||||
result: { ok: true; value: { stdout: string } } | { ok: false; error: Error },
|
||||
) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (result.ok) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
reject(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const failAndTerminate = (message: string) => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
settle({ ok: false, error: new Error(message) });
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
@@ -266,11 +149,7 @@ async function runLobsterSubprocessOnce(params: {
|
||||
const str = String(chunk);
|
||||
stdoutBytes += Buffer.byteLength(str, "utf8");
|
||||
if (stdoutBytes > maxStdoutBytes) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
reject(new Error("lobster output exceeded maxStdoutBytes"));
|
||||
}
|
||||
failAndTerminate("lobster output exceeded maxStdoutBytes");
|
||||
return;
|
||||
}
|
||||
stdout += str;
|
||||
@@ -281,25 +160,22 @@ async function runLobsterSubprocessOnce(params: {
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
reject(new Error("lobster subprocess timed out"));
|
||||
}
|
||||
failAndTerminate("lobster subprocess timed out");
|
||||
}, timeoutMs);
|
||||
|
||||
child.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
settle({ ok: false, error: err });
|
||||
});
|
||||
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
|
||||
settle({
|
||||
ok: false,
|
||||
error: new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({ stdout });
|
||||
settle({ ok: true, value: { stdout } });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
176
extensions/lobster/src/windows-spawn.ts
Normal file
176
extensions/lobster/src/windows-spawn.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type SpawnTarget = {
|
||||
command: string;
|
||||
argv: string[];
|
||||
windowsHide?: boolean;
|
||||
};
|
||||
|
||||
function isFilePath(value: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(value);
|
||||
return stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string {
|
||||
if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) {
|
||||
return execPath;
|
||||
}
|
||||
|
||||
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(execPath).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, `${execPath}${candidateExt}`);
|
||||
if (isFilePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return execPath;
|
||||
}
|
||||
|
||||
function resolveBinEntry(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;
|
||||
}
|
||||
|
||||
const preferred = binField.lobster;
|
||||
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 resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null {
|
||||
const wrapperDir = path.dirname(wrapperPath);
|
||||
const packageDirs = [
|
||||
// Local install: <repo>/node_modules/.bin/lobster.cmd -> ../lobster
|
||||
path.resolve(wrapperDir, "..", "lobster"),
|
||||
// Global npm install: <npm-prefix>/lobster.cmd -> ./node_modules/lobster
|
||||
path.resolve(wrapperDir, "node_modules", "lobster"),
|
||||
];
|
||||
|
||||
for (const packageDir of packageDirs) {
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
if (!isFilePath(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const scriptRel = resolveBinEntry(packageJson.bin);
|
||||
if (!scriptRel) {
|
||||
continue;
|
||||
}
|
||||
const scriptPath = path.resolve(packageDir, scriptRel);
|
||||
if (isFilePath(scriptPath)) {
|
||||
return scriptPath;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed package metadata; caller will throw a guided error.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null {
|
||||
if (!isFilePath(wrapperPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(wrapperPath, "utf8");
|
||||
const candidates: string[] = [];
|
||||
const matches = content.matchAll(/"%~?dp0%\\([^"\r\n]+)"/gi);
|
||||
for (const match of matches) {
|
||||
const relative = match[1];
|
||||
if (!relative) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRelative = relative.replace(/[\\/]+/g, path.sep);
|
||||
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";
|
||||
});
|
||||
if (nonNode) {
|
||||
return nonNode;
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable shims; caller will throw a guided error.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWindowsLobsterSpawn(
|
||||
execPath: string,
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): SpawnTarget {
|
||||
const resolvedExecPath = resolveWindowsExecutablePath(execPath, env);
|
||||
const ext = path.extname(resolvedExecPath).toLowerCase();
|
||||
if (ext !== ".cmd" && ext !== ".bat") {
|
||||
return { command: resolvedExecPath, argv };
|
||||
}
|
||||
|
||||
const scriptPath =
|
||||
resolveLobsterScriptFromCmdShim(resolvedExecPath) ??
|
||||
resolveLobsterScriptFromPackageJson(resolvedExecPath);
|
||||
if (!scriptPath) {
|
||||
throw new Error(
|
||||
`lobsterPath resolved to ${path.basename(resolvedExecPath)} wrapper, but no Node entrypoint could be resolved without shell execution. Configure pluginConfig.lobsterPath to lobster.exe.`,
|
||||
);
|
||||
}
|
||||
|
||||
const entryExt = path.extname(scriptPath).toLowerCase();
|
||||
if (entryExt === ".exe") {
|
||||
return { command: scriptPath, argv, windowsHide: true };
|
||||
}
|
||||
return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true };
|
||||
}
|
||||
Reference in New Issue
Block a user