Merge pull request #987 from pikasTech/fix/issue986-hwpod-node-command-resolution

fix: stabilize hwpod-node Windows git command resolution
This commit is contained in:
Lyon
2026-06-06 13:50:37 +08:00
committed by GitHub
3 changed files with 75 additions and 3 deletions
+2
View File
@@ -130,6 +130,8 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`
| `io.uart.jsonrpc` | 通过 UART 做 JSON-RPC |
| `cmd.run` | 临时维护命令透传,仅用于 `hwpod-ctl` 或早期薄 node 调试 |
`hwpod-node` 是 HWPOD 的唯一受控节点执行器。`cmd.run` 的命令解析、PATH 稳定性和跨平台差异必须在 `hwpod-node` 本体内处理;当 Windows service、后台进程或 D601 host 的环境变量与交互 shell 不一致时,不得用 gateway shell、手工 PowerShell、预先手工创建 worktree 或其他旁路代替 `hwpod-node-ops -> hwpod-node`。D601/Windows 实地调试应使用 UniDesk SSH 透传 `D601:win` 观察进程、PATH、工具安装位置和日志,但修复必须回到 `hwpod-node` 源码、配置或启动入口,并用 `/v1/hwpod-node-ops` 原链路复测。
标准 plan 形态:
```json
+25 -1
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { createHwpodNodeServer, executeHwpodNodeOpsPlan } from "./src/hwpod-node-lib.ts";
import { createHwpodNodeServer, executeHwpodNodeOpsPlan, resolveHwpodNodeCommand } from "./src/hwpod-node-lib.ts";
test("hwpod-node executes minimal workspace node ops", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-"));
@@ -95,6 +95,30 @@ test("hwpod-node reports cmd.run non-zero exits as failed results", async () =>
}
});
test("hwpod-node resolves Git for Windows when service PATH omits git", async () => {
const result = await resolveHwpodNodeCommand("git", {
platform: "win32",
env: {},
fileExists: async (candidate: string) => candidate === "C:\\Program Files\\Git\\cmd\\git.exe"
});
assert.equal(result.command, "C:\\Program Files\\Git\\cmd\\git.exe");
assert.equal(result.resolved, true);
assert.equal(result.source, "windows-well-known-tool");
});
test("hwpod-node keeps explicit executable paths unchanged", async () => {
const result = await resolveHwpodNodeCommand("C:\\Tools\\Git\\cmd\\git.exe", {
platform: "win32",
env: {},
fileExists: async () => true
});
assert.equal(result.command, "C:\\Tools\\Git\\cmd\\git.exe");
assert.equal(result.resolved, false);
assert.equal(result.source, "explicit-path");
});
test("hwpod-node applies workspace patches through the stable node op", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-patch-"));
try {
+48 -2
View File
@@ -436,7 +436,9 @@ async function spawnShellOutput(command: string, { cwd, timeoutMs }: any) {
}
async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs }: any) {
const proc = Bun.spawn(command, { cwd, stdin: stdinText ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe" });
const resolution = await resolveHwpodNodeCommand(command[0]);
const resolvedCommand = [resolution.command, ...command.slice(1)];
const proc = Bun.spawn(resolvedCommand, { cwd, stdin: stdinText ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe" });
if (stdinText && proc.stdin) {
proc.stdin.write(stdinText);
proc.stdin.end();
@@ -444,12 +446,56 @@ async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs }
const timeout = setTimeout(() => proc.kill(), timeoutMs);
try {
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
return { exitCode, stdout, stderr, ok: exitCode === 0 };
return { exitCode, stdout, stderr, ok: exitCode === 0, commandResolution: resolution };
} finally {
clearTimeout(timeout);
}
}
export async function resolveHwpodNodeCommand(command: string, options: any = {}) {
const requested = requiredText(command, "command");
const platform = text(options.platform) || process.platform;
if (isExplicitExecutablePath(requested)) {
return { requested, command: requested, resolved: false, source: "explicit-path" };
}
if (platform !== "win32") {
return { requested, command: requested, resolved: false, source: "process-path" };
}
const candidates = windowsWellKnownCommandCandidates(requested, options.env ?? process.env);
const exists = options.fileExists ?? fileExists;
for (const candidate of candidates) {
if (await exists(candidate)) {
return { requested, command: candidate, resolved: true, source: "windows-well-known-tool", candidates };
}
}
return { requested, command: requested, resolved: false, source: "process-path", candidates };
}
function isExplicitExecutablePath(command: string) {
return command.includes("/") || command.includes("\\") || path.isAbsolute(command) || isWindowsAbsolutePath(command);
}
function windowsWellKnownCommandCandidates(command: string, env: Record<string, unknown>) {
const normalized = command.toLowerCase().replace(/\.exe$/u, "");
if (normalized !== "git") return [];
return [
text(env.HWPOD_NODE_GIT_EXE),
"C:\\Program Files\\Git\\cmd\\git.exe",
"C:\\Program Files\\Git\\bin\\git.exe",
"C:\\Program Files (x86)\\Git\\cmd\\git.exe",
"C:\\Program Files (x86)\\Git\\bin\\git.exe"
].filter(Boolean);
}
async function fileExists(candidate: string) {
try {
const info = await stat(candidate);
return info.isFile() || !info.isDirectory();
} catch {
return false;
}
}
function workspaceRoot(args: any) {
const workspacePath = text(args.workspacePath) || process.env.HWPOD_WORKSPACE_PATH || process.cwd();
return isWindowsAbsolutePath(workspacePath) ? normalizeWindowsPath(workspacePath) : path.resolve(workspacePath);