fix(toolchain): 修复 Windows wrapper 版本探测

优先选择 .exe/.cmd/.bat 路径并直接执行版本命令,补充含空格路径测试;运行态 MCP 已确认 Enterprise 的 Node 与 pnpm 版本可正确识别。

Rules-Applied: R07,R08,R10,R13
This commit is contained in:
lanrtop 2026-09-22 01:23:34 +09:00
parent ead35ad12a
commit 8376bcbfd2
2 changed files with 64 additions and 18 deletions

View File

@ -90,7 +90,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b
- depends: T1. Node/pnpm 工具链契约解析器 - depends: T1. Node/pnpm 工具链契约解析器
- files: src-tauri/src/commands/toolchain.rs, src-tauri/src/lib.rs, src/lib/commands.ts - files: src-tauri/src/commands/toolchain.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: "[Tauri command] get_toolchain_status(project_id) → 返回 declared/actual/executable/source/resolver/status/recommendation;项目不存在或路径越界时显式报错;实际版本匹配但无 resolver 时为 compliant_unmanaged,版本不符时为 mismatch。" - acceptance: "[Tauri command] get_toolchain_status(project_id) → 返回 declared/actual/executable/source/resolver/status/recommendation;项目不存在或路径越界时显式报错;实际版本匹配但无 resolver 时为 compliant_unmanaged,版本不符时为 mismatch。"
- done_note: "2026-09-22:新增依赖注入业务函数与薄 Tauri command,只接受已登记项目的 Windows 路径,同时解析 executable path 和 `.cmd` 版本输出;WSL 按用户决策明确不支持。复盘待炼境 MCP 在本会话可用后回填。" - done_note: "2026-09-22:新增依赖注入业务函数与薄 Tauri command,只接受已登记项目的 Windows 路径,同时解析 executable path 和版本输出;运行态 MCP 自检发现 `where pnpm` 优先返回无扩展 shim 且 cmd 引号不稳,已改为优先 `.exe/.cmd/.bat` 并分别使用直接执行或 `cmd /D /S /C`。WSL 按用户决策明确不支持。复盘待炼境 MCP 在本会话可用后回填。"
### ✅ T3. MCP 只读诊断投影与契约文档 ### ✅ T3. MCP 只读诊断投影与契约文档
- status: done - status: done
@ -106,7 +106,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b
- depends: T2. 只读工具链状态 Tauri command - depends: T2. 只读工具链状态 Tauri command
- files: src-tauri/src/commands/toolchain.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/lib.rs, src/lib/commands.ts - files: src-tauri/src/commands/toolchain.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: "[Tauri command] prepare_toolchain(project_id, confirmed) → confirmed=false、未知项目、根路径越界或 contract=conflict 时零执行;confirmed=true 时只执行参数化 `mise install`,不运行项目 task/hook,不修改 package.json/pnpm-lock.yaml,并返回 before/after 状态与命令证据。" - acceptance: "[Tauri command] prepare_toolchain(project_id, confirmed) → confirmed=false、未知项目、根路径越界或 contract=conflict 时零执行;confirmed=true 时只执行参数化 `mise install`,不运行项目 task/hook,不修改 package.json/pnpm-lock.yaml,并返回 before/after 状态与命令证据。"
- done_note: "2026-09-22:新增 Tauri/MCP 受控 prepare_toolchain;resolver 就绪后通过 mise exec 探测项目版本,拒绝未确认、冲突、缺版本、缺 mise 和含自定义 task/hook/plugin 的配置;返回 verified 区分命令成功与二次诊断 ready_managed,13 个定向测试全绿,未实际下载安装。复盘待炼境 MCP 在本会话可用后回填。" - done_note: "2026-09-22:新增 Tauri/MCP 受控 prepare_toolchain;resolver 就绪后通过 mise exec 探测项目版本,拒绝未确认、冲突、缺版本、缺 mise 和含自定义 task/hook/plugin 的配置;返回 verified 区分命令成功与二次诊断 ready_managed,定向测试全绿,未实际下载安装。复盘待炼境 MCP 在本会话可用后回填。"
### 🔴 T5. 项目环境 UI 工具链契约卡 ### 🔴 T5. 项目环境 UI 工具链契约卡
- status: blocked - status: blocked
@ -114,7 +114,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b
- depends: T2. 只读工具链状态 Tauri command, T4. 受控 mise 工具链准备能力 - depends: T2. 只读工具链状态 Tauri command, T4. 受控 mise 工具链准备能力
- files: src/components/dashboard/ProjectEnvModal.tsx, src/lib/commands.ts - files: src/components/dashboard/ProjectEnvModal.tsx, src/lib/commands.ts
- acceptance: "agent_verify: [pnpm typecheck] ProjectEnvModal 消费 ToolchainStatus 类型且无类型错误;[Tauri command mock/fixture] ready_managed、compliant_unmanaged、mismatch、conflict 分别渲染准确状态与可用动作,prepare 必须经过确认;human_verify: 环境工具弹窗能看懂期望/实际版本、来源路径和准备结果。" - acceptance: "agent_verify: [pnpm typecheck] ProjectEnvModal 消费 ToolchainStatus 类型且无类型错误;[Tauri command mock/fixture] ready_managed、compliant_unmanaged、mismatch、conflict 分别渲染准确状态与可用动作,prepare 必须经过确认;human_verify: 环境工具弹窗能看懂期望/实际版本、来源路径和准备结果。"
- blocked_reason: "代码接线与 pnpm typecheck 已通过;本项目按规则不为 React 组件临时引入测试框架,且隔离 worktree 未启动 Tauri UI,尚缺 human_verify。需合并/启动该分支后在环境工具弹窗终验四种状态与确认按钮。" - blocked_reason: "代码接线与 pnpm typecheck 已通过;隔离分支已启动,运行态 MCP 对 Enterprise 返回 Node 24.19.0、pnpm 9.15.0 且均为 compliant_unmanaged,并由此发现并修复 Windows `.cmd` wrapper 版本探测;当前 Computer Use 未暴露 Tauri 原生窗口,本项目也不为 React 组件临时引入测试框架,仍缺 human_verify。需在环境工具弹窗终验状态卡与确认按钮。"
### ✅ T6. Onboarding 与 Codex Skill 条件式工具链指引 ### ✅ T6. Onboarding 与 Codex Skill 条件式工具链指引
- status: done - status: done
@ -130,7 +130,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b
- depends: T3. MCP 只读诊断投影与契约文档, T4. 受控 mise 工具链准备能力, T6. Onboarding 与 Codex Skill 条件式工具链指引 - depends: T3. MCP 只读诊断投影与契约文档, T4. 受控 mise 工具链准备能力, T6. Onboarding 与 Codex Skill 条件式工具链指引
- files: E:/code-project/enterprise-system/mise.toml, E:/code-project/enterprise-system/AGENTS.md, E:/code-project/enterprise-system/.gitea/workflows/ci.yml - files: E:/code-project/enterprise-system/mise.toml, E:/code-project/enterprise-system/AGENTS.md, E:/code-project/enterprise-system/.gitea/workflows/ci.yml
- acceptance: "agent_verify: [CLI] `mise exec -- node --version` → v24.19.0,`mise exec -- pnpm --version` → 9.15.0,`mise exec -- pnpm check:toolchain` → exit 0;[Gitea CI] 从 `.nvmrc` 与 packageManager 安装精确版本并在业务检查前通过 check:toolchain;package.json 与 pnpm-lock.yaml 无非预期改动;human_verify: 新 Codex 会话进入 Enterprise 后不再因 bundled pnpm 11 阻塞。" - acceptance: "agent_verify: [CLI] `mise exec -- node --version` → v24.19.0,`mise exec -- pnpm --version` → 9.15.0,`mise exec -- pnpm check:toolchain` → exit 0;[Gitea CI] 从 `.nvmrc` 与 packageManager 安装精确版本并在业务检查前通过 check:toolchain;package.json 与 pnpm-lock.yaml 无非预期改动;human_verify: 新 Codex 会话进入 Enterprise 后不再因 bundled pnpm 11 阻塞。"
- blocked_reason: "消费者试点依赖本分支先完成审查、合并并重建炼境,使新 onboarding/MCP tool 真正可调用;Enterprise 当前另一个会话还有未提交 T3 migration/蓝图改动,现阶段不跨项目写入或混合验证。" - blocked_reason: "运行态只读试点已确认 Enterprise 的 Node 24.19.0、pnpm 9.15.0 声明与本机版本匹配,但 resolver=unavailable,因此仍不是跨 Agent 可复现的 ready_managed。写入试点依赖本分支先完成审查、合并并重建炼境;Enterprise 当前另一个会话还有未提交 T3 migration/蓝图改动,现阶段不跨项目写入或混合验证。"
## 执行顺序 ## 执行顺序

View File

@ -320,6 +320,31 @@ pub fn parse_toolchain_contract(root: &Path) -> Result<ToolchainContract, String
}) })
} }
fn select_executable_path(output: &[u8]) -> Option<String> {
let paths = String::from_utf8_lossy(output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
#[cfg(target_os = "windows")]
if let Some(native_wrapper) = paths.iter().find(|path| {
matches!(
Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref(),
Some("exe" | "cmd" | "bat")
)
}) {
return Some(native_wrapper.clone());
}
paths.into_iter().next()
}
fn locate_executable(tool: &str) -> Option<String> { fn locate_executable(tool: &str) -> Option<String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let output = crate::silent_cmd("where.exe").arg(tool).output().ok()?; let output = crate::silent_cmd("where.exe").arg(tool).output().ok()?;
@ -329,25 +354,22 @@ fn locate_executable(tool: &str) -> Option<String> {
output output
.status .status
.success() .success()
.then(|| { .then(|| select_executable_path(&output.stdout))
String::from_utf8_lossy(&output.stdout) .flatten()
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or_default()
.to_string()
})
.filter(|path| !path.is_empty())
} }
fn executable_version(path: &str) -> Option<String> { fn executable_version(path: &str) -> Option<String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let output = { let output = {
let escaped = path.replace('"', "\"\""); let extension = Path::new(path)
crate::silent_cmd("cmd") .extension()
.args(["/D", "/C", &format!("\"{escaped}\" --version")]) .and_then(|value| value.to_str())
.output() .unwrap_or_default();
.ok()? if extension.eq_ignore_ascii_case("exe") {
crate::silent_cmd(path).arg("--version").output().ok()?
} else {
crate::silent_cmd(path).arg("--version").output().ok()?
}
}; };
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let output = crate::silent_cmd(path).arg("--version").output().ok()?; let output = crate::silent_cmd(path).arg("--version").output().ok()?;
@ -789,6 +811,30 @@ mod tests {
std::fs::remove_dir_all(root).unwrap(); std::fs::remove_dir_all(root).unwrap();
} }
#[test]
fn windows_executable_selection_prefers_native_wrapper() {
let output = b"C:\\nvm4w\\nodejs\\pnpm\r\nC:\\nvm4w\\nodejs\\pnpm.cmd\r\n";
assert_eq!(
select_executable_path(output).as_deref(),
Some("C:\\nvm4w\\nodejs\\pnpm.cmd")
);
}
#[cfg(target_os = "windows")]
#[test]
fn probes_windows_cmd_wrapper_with_spaces_in_path() {
let root = fixture().join("path with spaces");
std::fs::create_dir_all(&root).unwrap();
let wrapper = root.join("tool.cmd");
std::fs::write(&wrapper, "@echo 9.15.0\r\n").unwrap();
assert_eq!(
executable_version(wrapper.to_str().unwrap()).as_deref(),
Some("9.15.0")
);
std::fs::remove_dir_all(root.parent().unwrap()).unwrap();
}
fn probe(version: Option<&str>, executable: Option<&str>) -> RuntimeProbe { fn probe(version: Option<&str>, executable: Option<&str>) -> RuntimeProbe {
RuntimeProbe { RuntimeProbe {
version: version.map(str::to_string), version: version.map(str::to_string),