diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 1aa79c73..075a5af6 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -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 diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index c375174b..58a828a2 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -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 { diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 3987115c..72f23d7f 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -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) { + 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);