From ead35ad12aad944cbb0bdd2be1c15b4247b49414 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 22 Sep 2026 01:08:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(toolchain):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BA=A7=E8=BF=90=E8=A1=8C=E6=97=B6=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 .nvmrc 与 packageManager 作为 Node/pnpm 版本事实,新增 Windows-only 的状态诊断、受控 mise 准备、MCP/Tauri/UI 双投影及 onboarding 自检。 Feature-Confirmed: true Rules-Applied: R01,R04,R06,R07,R08,R10,R12 --- .blueprint/manifest.yaml | 23 +- .../modules/toolchain-capability-pack.md | 139 +++ docs/ai-context/project-context.yaml | 1 + lefthook.yml | 6 +- .../lian-jing-codex/skills/lian-jing/SKILL.md | 7 +- .../.agents/skills/lian-jing/SKILL.md | 4 +- .../.lian-jing/capability-packs.json | 14 +- src-tauri/resources/onboarding/AGENTS.md.tmpl | 9 +- .../resources/onboarding/lefthook.yml.tmpl | 6 +- src-tauri/resources/onboarding/mise.toml.tmpl | 4 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/onboarding.rs | 156 ++- src-tauri/src/commands/toolchain.rs | 914 ++++++++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/mcp_tools.rs | 373 +++++-- src/components/dashboard/ProjectEnvModal.tsx | 146 +++ src/lib/commands.ts | 40 + 17 files changed, 1751 insertions(+), 95 deletions(-) create mode 100644 .blueprint/modules/toolchain-capability-pack.md create mode 100644 src-tauri/resources/onboarding/mise.toml.tmpl create mode 100644 src-tauri/src/commands/toolchain.rs diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 28b950c..3cda51a 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-09-21 +updated: 2026-09-22 agent_button_carrier: tauri @@ -468,6 +468,14 @@ modules: note: "P2 完成:工具无关 canonical context、版本化能力包自检、Codex Skill-only 投影与项目自定义 lefthook 等价能力识别已闭环;不复制 MCP、凭证或部署能力。" updated: 2026-09-21 position: [2250, 800] + + - id: toolchain-capability-pack + name: 项目工具链能力包 + area: infra + status: in_progress + progress: 71 + note: "以项目标准声明为版本事实,mise 负责本地解析;炼境提供诊断、受控准备和 Agent 投影,不追随 Codex bundled runtime。v1 聚焦 Windows 项目的 Node + pnpm,明确不支持 WSL。" + position: [2500, 950] edges: # 前端依赖 - from: workspace-mgmt @@ -765,3 +773,16 @@ edges: - from: project-capability-control-plane to: agent-client-capability-packs type: related + # 项目工具链能力包:复用现有扫描,补齐声明、解析、执行和验证闭环 + - from: project-env-scan + to: toolchain-capability-pack + type: dependency + - from: devtools-scan + to: toolchain-capability-pack + type: dependency + - from: onboarding-pack + to: toolchain-capability-pack + type: dependency + - from: agent-client-capability-packs + to: toolchain-capability-pack + type: related diff --git a/.blueprint/modules/toolchain-capability-pack.md b/.blueprint/modules/toolchain-capability-pack.md new file mode 100644 index 0000000..78cb009 --- /dev/null +++ b/.blueprint/modules/toolchain-capability-pack.md @@ -0,0 +1,139 @@ +--- +id: toolchain-capability-pack +name: 项目工具链能力包 +status: in_progress +progress: 71 +--- + +## 设计决策:项目工具链契约、解析与 Agent 执行闭环 v1 + +### 背景 + +炼境现有 `project-env-scan` 能识别 `.nvmrc`、`.node-version`、Volta 与 lockfile,`devtools-scan` 能读取本机 Node、pnpm 等实际工具,但两者仍是信息展示:项目声明与当前执行环境没有形成可比较、可准备、可复现的闭环。 + +Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex bundled runtime 暴露 pnpm `11.19.0` 且无 Corepack。现有 `check:toolchain` 能正确拦截漂移,却缺少自动选择项目版本的 resolver,导致 Agent 每次会话重复阻塞。 + +### 方案选择 + +| 方案 | 取舍 | 结论 | +| --- | --- | --- | +| 所有项目追随 Codex bundled runtime | 初次零配置,但 Codex 升级会反向驱动项目漂移,且无法覆盖其他 IDE/CI | 否决 | +| 仅 `.nvmrc` + Corepack | 声明简单,但依赖人工切换;Corepack 不保证随 Node/Codex 提供 | 仅兼容 | +| 全量 Dev Container / Docker 开发 | 复现性强,但对 Windows/Tauri/前端热更新和本地磁盘成本过重 | 特殊项目可选 | +| **标准项目声明 + mise resolver + 炼境能力包** | 项目继续拥有版本事实;人、Codex、CI、部署通过各自 adapter 消费同一契约 | **选定** | + +### 统一模型与边界 + +```text +项目版本事实 +├─ .nvmrc Node 精确版本 +├─ package.json#packageManager pnpm 精确版本 +├─ package.json#engines 兼容性/门禁 +└─ pnpm-lock.yaml 依赖图(不承担工具版本切换) + │ + ▼ +工具链 adapter +├─ 本机 / Codex:mise +├─ Gitea CI:从同一声明安装对应 Node/pnpm +└─ production:镜像或构建产物固定版本 + │ + ▼ +炼境:实时诊断、受控准备、自检报告、UI/MCP 双投影 +``` + +- 不新增 `.lian-jing/toolchain.*` 版本配置,避免第二事实来源。 +- v1 只治理 Node + pnpm;Rust 继续由 `rust-toolchain.toml`/rustup 管理,其他语言后续按真实需求增加 adapter。 +- 2026-09-22 用户确认炼境执行面只支持 Windows 项目路径;WSL 不再进入能力包、诊断、准备或消费者验收范围。 +- 实际版本与可执行路径实时读取,不写入 SQLite 作为权威状态;既有 `project_env_tools` 仅保留展示/快照用途。 +- `mise.toml` 只启用 `.nvmrc` 与 `packageManager` 的 idiomatic version file 读取,不重复抄写版本。 +- 缺少 mise 但实际版本匹配时报告 `compliant_unmanaged`,不能伪报成可复现的 `ready_managed`。 +- 安装/下载/信任属于机器状态变更:只允许已登记项目、规范化根路径和显式确认;不得自动信任未知仓库,不执行项目自定义 task/hook,不修改 package/lock 文件。 +- 炼境提供能力与证据,不替项目决定升级版本;升级必须单独走 PR。 + +### 系统边界变更 + +- 新增纯解析层,统一提取项目 Node/pnpm 声明、来源、冲突和 resolver 配置。 +- 新增只读 Tauri command 与 MCP tool `get_toolchain_status(project_id)`,报告 declared/actual/executable/source/status/recommendation。 +- 新增受控 Tauri/MCP 能力 `prepare_toolchain(project_id, confirmed)`,只构造并执行受限的 `mise install` 路径。 +- 项目环境 UI 增加工具链契约卡和准备入口,与 agent 调用复用同一业务函数。 +- onboarding/能力包增加版本化自检和条件式 Agent 指引:存在 resolver 时通过 `mise exec -- pnpm ...` 执行。 +- 不新增数据库表,不改变部署协议,不自动迁移项目版本。 + +### 验证路径 + +- 载体:Tauri command / MCP tool / CLI 消费者验证。 +- 示例:`[Tauri command] get_toolchain_status(enterprise_project_id) → declared Node=24.19.0、pnpm=9.15.0,actual pnpm=11.19.0 时 status=mismatch 且返回实际 executable path。` +- 示例:`[Tauri command] prepare_toolchain(enterprise_project_id, confirmed=true) → 仅调用受限 mise install,不修改 package.json/pnpm-lock.yaml;再次诊断为 ready_managed。` +- 示例:`[CLI] mise exec -- pnpm check:toolchain → Node 24.19.0、pnpm 9.15.0,exit 0。` + +### 风险点 + +- **未知仓库执行配置**:prepare 前校验项目登记、规范化根路径与显式确认;v1 不运行 mise task/hook。 +- **Windows PATH/shim 差异**:状态必须同时返回版本与 executable path;测试覆盖 `.cmd` 解析和 Codex fallback 路径。WSL 明确不支持,不增加双路径兼容分支。 +- **多份版本声明冲突**:解析器返回 conflict 并拒绝 prepare,不擅自选择其中一份。 +- **下载与磁盘占用**:mise 使用用户级缓存、相同版本跨项目复用;UI 在下载前展示预计动作。 +- **CI 与本地 adapter 不同**:要求共享同一声明并在 CI 首步执行版本断言,不要求 CI 为形式统一而安装 mise。 +- **范围膨胀**:v1 不接管依赖安装、数据库、服务启动和部署,只解决运行时/包管理器选择。 + +## 任务卡 + +### ✅ T1. Node/pnpm 工具链契约解析器 +- status: done +- complexity: M +- files: src-tauri/src/commands/toolchain.rs, src-tauri/src/commands/mod.rs +- acceptance: "[cargo test] 临时目录分别提供 `.nvmrc`、`.node-version`、package.json#packageManager/engines 与 mise.toml → 返回 Node/pnpm declared version、source 和 resolver 配置;冲突声明返回 conflict,缺失字段返回 missing,不读取或修改 lockfile。" +- done_note: "2026-09-22:新增纯解析器与 6 个测试,覆盖一致声明、精确冲突、engines 仅作兼容门禁、mise 直 pin、空项目;首次测试暴露 pnpm 来源优先级,已修正为 packageManager 优先。复盘待炼境 MCP 在本会话可用后回填。" + +### ✅ T2. 只读工具链状态 Tauri command +- status: done +- complexity: M +- depends: T1. Node/pnpm 工具链契约解析器 +- 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。" +- done_note: "2026-09-22:新增依赖注入业务函数与薄 Tauri command,只接受已登记项目的 Windows 路径,同时解析 executable path 和 `.cmd` 版本输出;WSL 按用户决策明确不支持。复盘待炼境 MCP 在本会话可用后回填。" + +### ✅ T3. MCP 只读诊断投影与契约文档 +- status: done +- complexity: M +- depends: T2. 只读工具链状态 Tauri command +- files: src-tauri/src/mcp_tools.rs, src-tauri/src/commands/toolchain.rs, docs/ai-context/project-context.yaml +- acceptance: "[MCP tool] get_toolchain_status(project_id) → 与 Tauri command 返回同一状态模型;mise/Codex/Gitea 不可用时返回 unavailable/missing 证据而非伪造 ready;cargo test 覆盖 schema、dispatch 和降级路径。" +- done_note: "2026-09-22:新增 get_toolchain_status MCP schema/dispatch、必填参数降级测试并同步外部契约映射;定向测试全绿。复盘待炼境 MCP 在本会话可用后回填。" + +### ✅ T4. 受控 mise 工具链准备能力 +- status: done +- complexity: M +- 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 +- 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 在本会话可用后回填。" + +### 🔴 T5. 项目环境 UI 工具链契约卡 +- status: blocked +- complexity: M +- depends: T2. 只读工具链状态 Tauri command, T4. 受控 mise 工具链准备能力 +- 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: 环境工具弹窗能看懂期望/实际版本、来源路径和准备结果。" +- blocked_reason: "代码接线与 pnpm typecheck 已通过;本项目按规则不为 React 组件临时引入测试框架,且隔离 worktree 未启动 Tauri UI,尚缺 human_verify。需合并/启动该分支后在环境工具弹窗终验四种状态与确认按钮。" + +### ✅ T6. Onboarding 与 Codex Skill 条件式工具链指引 +- status: done +- complexity: M +- depends: T3. MCP 只读诊断投影与契约文档, T4. 受控 mise 工具链准备能力 +- files: src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/AGENTS.md.tmpl, src-tauri/resources/onboarding/mise.toml.tmpl, src-tauri/resources/onboarding/lefthook.yml.tmpl, src-tauri/resources/onboarding/.lian-jing/capability-packs.json, src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, plugins/lian-jing-codex/skills/lian-jing/SKILL.md, lefthook.yml +- acceptance: "[cargo test] 对已有手写 mise.toml 的项目重跑 onboarding → 不覆盖文件并报告 existing/conflict;对明确选择接入且仅有 `.nvmrc` + packageManager 的临时项目 → 生成不重复版本的 resolver 配置;[Codex Skill] setup 先调用 get_toolchain_status,managed 项目使用 `mise exec -- pnpm`,unavailable 时明确降级而不宣称已准备。" +- done_note: "2026-09-22:接入包新增条件式最小 mise resolver、toolchain-node-pnpm v1.0.0 描述符、AGENTS v1.9.0 与两层 Codex Skill 指引;已有 mise.toml 永不覆盖;commit-msg 改用 Lefthook `{1}` 参数兼容 managed worktree;16 个 onboarding 测试全绿。复盘待炼境 MCP 在本会话可用后回填。" + +### 🔴 T7. Enterprise System 消费者试点与 CI 同源验证 +- status: blocked +- complexity: M +- 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 +- 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/蓝图改动,现阶段不跨项目写入或混合验证。" + +## 执行顺序 + +T1 → T2 → T3;T2 → T4 → T5;T3 + T4 → T6;T3 + T4 + T6 → T7。 + +核心能力先只读、后受控写;炼境实现者验证完成后,必须由 Enterprise System 以消费者角色完成最终闭环。 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 94d9926..9a38b82 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -12,6 +12,7 @@ contracts: paths: - "src-tauri/src/mcp_tools.rs" - "src-tauri/src/commands/delivery_status.rs" + - "src-tauri/src/commands/toolchain.rs" - "src-tauri/src/commands/gitea.rs" doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功" diff --git a/lefthook.yml b/lefthook.yml index 6c1d7bd..fcba178 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -11,8 +11,8 @@ pre-commit: commit-msg: commands: conventional: - run: "head -1 .git/COMMIT_EDITMSG | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1" + run: "head -1 \"{1}\" | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1" feature-confirmed: run: | - head -1 .git/COMMIT_EDITMSG | grep -qE '^feat' || exit 0 - grep -q 'Feature-Confirmed: true' .git/COMMIT_EDITMSG || { printf '\n⚠️ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行:Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; } + head -1 "{1}" | grep -qE '^feat' || exit 0 + grep -q 'Feature-Confirmed: true' "{1}" || { printf '\n⚠️ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行:Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; } diff --git a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md index 8347115..42b9e14 100644 --- a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md +++ b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md @@ -9,8 +9,9 @@ description: Inspect an already onboarded Lian Jing project, use its existing MC 1. 先读取项目根目录的 `AGENTS.md`、`.blueprint/` 规则和 `.lian-jing/context.json`。 2. 仅将 `.lian-jing/context.json` 中的 `project_id` 用作炼境 MCP 调用参数。`.claude/liangjing.json` 只用于识别旧项目并提示迁移,不能作为长期身份来源。 -3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。 -4. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 -5. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 +3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。 +4. Node/pnpm 项目运行包管理命令前先读取工具链状态;`ready_managed` 时使用 `mise exec -- pnpm ...`。只有用户明确确认下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配 Codex bundled runtime 修改项目版本或 lockfile。 +5. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 +6. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。 diff --git a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md index f0234aa..c494479 100644 --- a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -11,6 +11,8 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 1. 所有 MCP 调用显式传入 `project_id`。 2. 优先调用 `get_diagnostics`,将其作为当前项目健康状态的事实来源。 -3. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 +3. 在 Node/pnpm 项目运行包管理命令前调用 `get_toolchain_status(project_id)`;`ready_managed` 时通过 `mise exec -- pnpm ...` 执行,其他状态先报告实际版本与 executable 证据。 +4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 +5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json index eb3d3bf..54caecd 100644 --- a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -3,7 +3,7 @@ "packs": [ { "pack_id": "base-onboarding", - "version": "1.1.0", + "version": "1.2.0", "supported_clients": ["codex", "claude"], "project_artifacts": [ "AGENTS.md", @@ -12,6 +12,18 @@ ], "self_check": "verify_onboarding", "upgrade_hint": "调用 apply_onboarding_pack(project_id) 更新版本化基线;项目身份上下文由 inject_mcp(project_id) 创建或迁移。该升级仅覆盖炼境托管的接入包文件,不覆盖项目定制或手工 MCP 配置。" + }, + { + "pack_id": "toolchain-node-pnpm", + "version": "1.0.0", + "supported_clients": ["codex", "claude", "human-shell"], + "project_artifacts": [ + ".nvmrc", + "package.json#packageManager", + "mise.toml" + ], + "self_check": "get_toolchain_status", + "upgrade_hint": "重跑 apply_onboarding_pack(project_id) 可为已声明 .nvmrc 与 pnpm packageManager 的项目补充分离版本事实的最小 mise resolver;已有 mise.toml 永不覆盖。" } ] } diff --git a/src-tauri/resources/onboarding/AGENTS.md.tmpl b/src-tauri/resources/onboarding/AGENTS.md.tmpl index 4b7a263..9082eab 100644 --- a/src-tauri/resources/onboarding/AGENTS.md.tmpl +++ b/src-tauri/resources/onboarding/AGENTS.md.tmpl @@ -18,7 +18,7 @@ - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(2026-07-15 炼境自身实锤丢失 5 段定制后新增本警示)。 ## 核心开发约束 @@ -73,6 +73,13 @@ - `.lian-jing/capability-packs.json` 记录炼境托管基线的版本。`verify_onboarding(project_id)` 报告 `legacy`、`version_mismatch` 等状态时,按其 `detail` 中的升级提示处理。 - `apply_onboarding_pack(project_id)` 只更新炼境明确托管的接入包文件;项目自己的业务规则、测试、部署声明和手工定义的同名 MCP server 仍归项目所有,不能被升级覆盖。 +### Node/pnpm 工具链 + +- Node 精确版本以 `.nvmrc` 为事实源,pnpm 精确版本以 `package.json#packageManager` 为事实源;不要追随 IDE、Codex 或全局 PATH 的 bundled runtime。 +- Node+pnpm 项目由炼境分发最小 `mise.toml`,只启用上述标准声明,不重复保存版本;已有手写 `mise.toml` 永不覆盖。 +- 开始运行 pnpm 命令前调用 `get_toolchain_status(project_id)`。返回 `ready_managed` 时使用 `mise exec -- pnpm ...`;`mismatch/conflict/unavailable` 时先报告证据。 +- `prepare_toolchain(project_id, confirmed=true)` 会下载/安装项目声明版本,只有用户明确确认本次机器状态变更后才能调用;不得为消除告警而修改项目版本或 lockfile。 + ## agent 按钮与验证路径 功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影 diff --git a/src-tauri/resources/onboarding/lefthook.yml.tmpl b/src-tauri/resources/onboarding/lefthook.yml.tmpl index bbe11a8..eb47189 100644 --- a/src-tauri/resources/onboarding/lefthook.yml.tmpl +++ b/src-tauri/resources/onboarding/lefthook.yml.tmpl @@ -23,8 +23,8 @@ pre-commit: commit-msg: commands: conventional: - run: "head -1 .git/COMMIT_EDITMSG | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1" + run: "head -1 \"{1}\" | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1" feature-confirmed: run: | - head -1 .git/COMMIT_EDITMSG | grep -qE '^feat' || exit 0 - grep -q 'Feature-Confirmed: true' .git/COMMIT_EDITMSG || { printf '\n⚠️ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行:Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; } + head -1 "{1}" | grep -qE '^feat' || exit 0 + grep -q 'Feature-Confirmed: true' "{1}" || { printf '\n⚠️ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行:Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; } diff --git a/src-tauri/resources/onboarding/mise.toml.tmpl b/src-tauri/resources/onboarding/mise.toml.tmpl new file mode 100644 index 0000000..9aa00df --- /dev/null +++ b/src-tauri/resources/onboarding/mise.toml.tmpl @@ -0,0 +1,4 @@ +# 炼境工具链能力包:版本事实仍由 .nvmrc 与 package.json#packageManager 持有。 +# 本文件只让 mise 读取这些标准声明,不重复保存 Node/pnpm 版本。 +[settings] +idiomatic_version_file_enable_tools = ["node", "pnpm"] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d7e0200..1810507 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -36,4 +36,5 @@ pub mod spaces; pub mod ssh_config; pub mod tags; pub mod templates; +pub mod toolchain; pub mod user_todos; diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 4d8cc5a..1c86854 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -23,9 +23,11 @@ const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); const TMPL_CAPABILITY_PACKS: &str = include_str!("../../resources/onboarding/.lian-jing/capability-packs.json"); +const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; -const BASE_ONBOARDING_PACK_VERSION: &str = "1.1.0"; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.2.0"; +const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -102,6 +104,33 @@ fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> Templat } } +fn node_pnpm_contract_present(root: &Path) -> bool { + if !root.join(".nvmrc").is_file() { + return false; + } + std::fs::read_to_string(root.join("package.json")) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|package| { + package + .get("packageManager") + .and_then(|value| value.as_str()) + .map(|value| value.starts_with("pnpm@")) + }) + .unwrap_or(false) +} + +fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult { + if !node_pnpm_contract_present(root) { + return TemplateResult { + name: "mise.toml".to_string(), + action: "skipped".to_string(), + detail: "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包".to_string(), + }; + } + dispatch_one(root, "mise.toml", TMPL_MISE) +} + /// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。 fn extract_managed_block(template: &str) -> Option<&str> { let start = template.find(MANAGED_START)?; @@ -206,6 +235,7 @@ pub fn dispatch_templates(project_path: &str) -> Vec { ".lian-jing/capability-packs.json", TMPL_CAPABILITY_PACKS, ), + dispatch_toolchain_resolver(root), ] } @@ -450,36 +480,74 @@ fn check_capability_packs(content: Option<&str>) -> VerifyCheck { let schema_version = descriptor .get("schema_version") .and_then(|value| value.as_u64()); - let version = descriptor - .get("packs") - .and_then(|value| value.as_array()) - .and_then(|packs| { - packs.iter().find(|pack| { - pack.get("pack_id").and_then(|value| value.as_str()) == Some("base-onboarding") + let pack_version = |pack_id: &str| { + descriptor + .get("packs") + .and_then(|value| value.as_array()) + .and_then(|packs| { + packs.iter().find(|pack| { + pack.get("pack_id").and_then(|value| value.as_str()) == Some(pack_id) + }) }) - }) - .and_then(|pack| pack.get("version")) - .and_then(|value| value.as_str()); + .and_then(|pack| pack.get("version")) + .and_then(|value| value.as_str()) + }; + let base_version = pack_version("base-onboarding"); + let toolchain_version = pack_version("toolchain-node-pnpm"); if schema_version == Some(CAPABILITY_PACK_SCHEMA_VERSION) - && version == Some(BASE_ONBOARDING_PACK_VERSION) + && base_version == Some(BASE_ONBOARDING_PACK_VERSION) + && toolchain_version == Some(TOOLCHAIN_PACK_VERSION) { VerifyCheck::with_state( "基础能力包描述符", VerifyState::Ready, - format!("base-onboarding v{BASE_ONBOARDING_PACK_VERSION}(最新)"), + format!( + "base-onboarding v{BASE_ONBOARDING_PACK_VERSION}、toolchain-node-pnpm v{TOOLCHAIN_PACK_VERSION}(最新)" + ), ) } else { VerifyCheck::with_state( "基础能力包描述符", VerifyState::VersionMismatch, format!( - "能力包 schema={schema_version:?}、base-onboarding={version:?};当前应为 schema={}、v{}——重跑接入包可升级", - CAPABILITY_PACK_SCHEMA_VERSION, BASE_ONBOARDING_PACK_VERSION + "能力包 schema={schema_version:?}、base-onboarding={base_version:?}、toolchain-node-pnpm={toolchain_version:?};当前应为 schema={}、base v{}、toolchain v{}——重跑接入包可升级", + CAPABILITY_PACK_SCHEMA_VERSION, BASE_ONBOARDING_PACK_VERSION, TOOLCHAIN_PACK_VERSION ), ) } } +fn check_toolchain_resolver(root: &Path) -> VerifyCheck { + match crate::commands::toolchain::parse_toolchain_contract(root) { + Err(error) => { + VerifyCheck::with_state("Node/pnpm 工具链 resolver", VerifyState::Unavailable, error) + } + Ok(contract) if !contract.conflicts.is_empty() => VerifyCheck::with_state( + "Node/pnpm 工具链 resolver", + VerifyState::VersionMismatch, + format!("版本声明冲突: {}", contract.conflicts.join(", ")), + ), + Ok(contract) + if contract.resolver.configured + && (contract.resolver.node_idiomatic + || contract.node.source.as_deref() == Some("mise.toml#tools.node")) + && (contract.resolver.pnpm_idiomatic + || contract.pnpm.source.as_deref() == Some("mise.toml#tools.pnpm")) => + { + VerifyCheck::with_state( + "Node/pnpm 工具链 resolver", + VerifyState::Ready, + "mise.toml 已消费项目的 Node/pnpm 精确版本声明", + ) + } + Ok(_) => VerifyCheck::with_state( + "Node/pnpm 工具链 resolver", + VerifyState::Missing, + "缺少可消费 .nvmrc 与 packageManager 的 mise.toml;重跑接入包可补充,已有文件不会覆盖", + ), + } +} + /// 纯函数:pre-commit hook 内容是否由 lefthook 接管。 fn hook_is_lefthook(content: &str) -> bool { content.contains("lefthook") @@ -710,6 +778,9 @@ pub fn verify_onboarding( checks.push(check_capability_packs( read(".lian-jing/capability-packs.json").as_deref(), )); + if node_pnpm_contract_present(root) || root.join("mise.toml").exists() { + checks.push(check_toolchain_resolver(root)); + } let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); checks.push(VerifyCheck::from_pass("Codex 炼境技能已分发", pass, detail)); @@ -846,9 +917,18 @@ mod tests { std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); - // 首次:全部写入 + // 首次:基础接入包全部写入;非 Node/pnpm 项目跳过工具链 resolver let r1 = dispatch_templates(pp); - assert!(r1.iter().all(|t| t.action == "written"), "首次应全部写入"); + assert!( + r1.iter() + .filter(|t| t.name != "mise.toml") + .all(|t| t.action == "written"), + "首次基础模板应全部写入" + ); + assert_eq!( + r1.iter().find(|t| t.name == "mise.toml").unwrap().action, + "skipped" + ); assert!(base.join("AGENTS.md").exists()); assert!(base.join("lefthook.yml").exists()); assert!(base.join("docs/ai-context/git-workflow.md").exists()); @@ -879,7 +959,10 @@ mod tests { // 态一:不存在 → 写全新(含管理块标记) let r1 = dispatch_templates(pp); - assert!(r1.iter().all(|t| t.action == "written")); + assert!(r1 + .iter() + .filter(|t| t.name != "mise.toml") + .all(|t| t.action == "written")); let agents = std::fs::read_to_string(base.join("AGENTS.md")).unwrap(); assert!(agents.contains(MANAGED_START) && agents.contains(MANAGED_END)); @@ -1217,6 +1300,45 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn toolchain_resolver_is_conditional_and_never_overwrites_custom_config() { + let base = std::env::temp_dir().join(format!( + "lianjing_toolchain_pack_test_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join(".nvmrc"), "24.19.0\n").unwrap(); + std::fs::write( + base.join("package.json"), + r#"{"packageManager":"pnpm@9.15.0"}"#, + ) + .unwrap(); + + let first = dispatch_toolchain_resolver(&base); + assert_eq!(first.action, "written"); + assert_eq!(check_toolchain_resolver(&base).state, VerifyState::Ready); + + std::fs::write( + base.join("mise.toml"), + "[tasks.custom]\nrun = 'project-owned'\n", + ) + .unwrap(); + let second = dispatch_toolchain_resolver(&base); + assert_eq!(second.action, "skipped"); + assert_eq!( + std::fs::read_to_string(base.join("mise.toml")).unwrap(), + "[tasks.custom]\nrun = 'project-owned'\n" + ); + assert_ne!( + check_toolchain_resolver(&base).state, + VerifyState::Ready, + "自定义配置未消费 Node/pnpm 声明时不能伪报 ready" + ); + + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn lefthook_keys_extraction_and_check() { // 从内嵌模板自省:至少含 typecheck 与 commit-msg 侧检查段 diff --git a/src-tauri/src/commands/toolchain.rs b/src-tauri/src/commands/toolchain.rs new file mode 100644 index 0000000..c8787cb --- /dev/null +++ b/src-tauri/src/commands/toolchain.rs @@ -0,0 +1,914 @@ +use rusqlite::{params, Connection}; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VersionSource { + pub source: String, + pub version: String, + pub exact: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolDeclaration { + pub tool: String, + pub state: String, + pub version: Option, + pub source: Option, + pub candidates: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResolverContract { + pub kind: String, + pub configured: bool, + pub source: Option, + pub node_idiomatic: bool, + pub pnpm_idiomatic: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolchainContract { + pub node: ToolDeclaration, + pub pnpm: ToolDeclaration, + pub resolver: ResolverContract, + pub conflicts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeProbe { + pub version: Option, + pub executable: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeToolStatus { + pub tool: String, + pub declared_version: Option, + pub declared_source: Option, + pub actual_version: Option, + pub executable: Option, + pub status: String, + pub recommendation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResolverStatus { + pub kind: String, + pub configured: bool, + pub executable: Option, + pub actual_version: Option, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolchainStatus { + pub project_id: String, + pub project_root: String, + pub node: RuntimeToolStatus, + pub pnpm: RuntimeToolStatus, + pub resolver: ResolverStatus, + pub conflicts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PrepareToolchainResult { + pub command: Vec, + pub before: ToolchainStatus, + pub after: ToolchainStatus, + pub verified: bool, + pub stdout: String, + pub stderr: String, +} + +#[derive(Default)] +struct MiseConfig { + exists: bool, + node_idiomatic: bool, + pnpm_idiomatic: bool, + node_version: Option, + pnpm_version: Option, +} + +fn read_trimmed(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_version(value: &str) -> String { + value + .trim() + .trim_matches(|character| character == '"' || character == '\'') + .trim_start_matches('v') + .split('+') + .next() + .unwrap_or_default() + .trim() + .to_string() +} + +fn is_exact_version(value: &str) -> bool { + let value = normalize_version(value); + if value.is_empty() || value.contains(['<', '>', '=', '^', '~', '*', 'x', 'X', '|', ' ']) { + return false; + } + + let core = value.split('-').next().unwrap_or_default(); + let segments = core.split('.').collect::>(); + segments.len() >= 2 + && segments + .iter() + .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit())) +} + +fn push_candidate(candidates: &mut Vec, source: &str, version: &str) { + let normalized = normalize_version(version); + if normalized.is_empty() { + return; + } + candidates.push(VersionSource { + source: source.to_string(), + exact: is_exact_version(&normalized), + version: normalized, + }); +} + +fn push_compatibility(candidates: &mut Vec, source: &str, version: &str) { + let normalized = normalize_version(version); + if normalized.is_empty() { + return; + } + candidates.push(VersionSource { + source: source.to_string(), + version: normalized, + exact: false, + }); +} + +fn package_value<'a>(package: &'a Value, path: &[&str]) -> Option<&'a str> { + let mut value = package; + for key in path { + value = value.get(*key)?; + } + value.as_str() +} + +fn parse_package_json( + root: &Path, + node_candidates: &mut Vec, + pnpm_candidates: &mut Vec, +) -> Result<(), String> { + let path = root.join("package.json"); + if !path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(&path) + .map_err(|error| format!("读取 {} 失败: {error}", path.display()))?; + let package: Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 {} 失败: {error}", path.display()))?; + + if let Some(version) = package_value(&package, &["engines", "node"]) { + push_compatibility(node_candidates, "package.json#engines.node", version); + } + if let Some(version) = package_value(&package, &["volta", "node"]) { + push_candidate(node_candidates, "package.json#volta.node", version); + } + if let Some(package_manager) = package_value(&package, &["packageManager"]) { + if let Some(version) = package_manager.strip_prefix("pnpm@") { + push_candidate(pnpm_candidates, "package.json#packageManager", version); + } + } + if let Some(version) = package_value(&package, &["engines", "pnpm"]) { + push_compatibility(pnpm_candidates, "package.json#engines.pnpm", version); + } + + Ok(()) +} + +fn parse_mise(root: &Path) -> Result { + let path = root.join("mise.toml"); + if !path.exists() { + return Ok(MiseConfig::default()); + } + + let content = std::fs::read_to_string(&path) + .map_err(|error| format!("读取 {} 失败: {error}", path.display()))?; + let mut config = MiseConfig { + exists: true, + ..MiseConfig::default() + }; + let mut section = String::new(); + + for raw_line in content.lines() { + let line = raw_line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + section = line.trim_matches(['[', ']']).trim().to_string(); + continue; + } + let Some((raw_key, raw_value)) = line.split_once('=') else { + continue; + }; + let key = raw_key.trim().trim_matches(['"', '\'']); + let value = raw_value.trim(); + + if section == "tools" { + let parsed = normalize_version(value); + if is_exact_version(&parsed) { + match key { + "node" => config.node_version = Some(parsed), + "pnpm" => config.pnpm_version = Some(parsed), + _ => {} + } + } + } else if section == "settings" && key == "idiomatic_version_file_enable_tools" { + config.node_idiomatic = value + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .any(|token| token == "node"); + config.pnpm_idiomatic = value + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .any(|token| token == "pnpm"); + } + } + + Ok(config) +} + +fn declaration(tool: &str, candidates: Vec) -> ToolDeclaration { + let exact_versions = candidates + .iter() + .filter(|candidate| candidate.exact) + .map(|candidate| candidate.version.clone()) + .collect::>(); + + let (state, version, source) = match exact_versions.len() { + 0 => ("missing".to_string(), None, None), + 1 => { + let version = exact_versions.into_iter().next(); + let source = version.as_ref().and_then(|selected| { + candidates + .iter() + .find(|candidate| candidate.exact && candidate.version == *selected) + .map(|candidate| candidate.source.clone()) + }); + ("declared".to_string(), version, source) + } + _ => ("conflict".to_string(), None, None), + }; + + ToolDeclaration { + tool: tool.to_string(), + state, + version, + source, + candidates, + } +} + +pub fn parse_toolchain_contract(root: &Path) -> Result { + if !root.is_dir() { + return Err(format!("项目目录不存在: {}", root.display())); + } + + let mut node_candidates = Vec::new(); + let mut pnpm_candidates = Vec::new(); + + if let Some(version) = read_trimmed(&root.join(".nvmrc")) { + push_candidate(&mut node_candidates, ".nvmrc", &version); + } + if let Some(version) = read_trimmed(&root.join(".node-version")) { + push_candidate(&mut node_candidates, ".node-version", &version); + } + parse_package_json(root, &mut node_candidates, &mut pnpm_candidates)?; + + let mise = parse_mise(root)?; + if let Some(version) = mise.node_version.as_deref() { + push_candidate(&mut node_candidates, "mise.toml#tools.node", version); + } + if let Some(version) = mise.pnpm_version.as_deref() { + push_candidate(&mut pnpm_candidates, "mise.toml#tools.pnpm", version); + } + + let node = declaration("node", node_candidates); + let pnpm = declaration("pnpm", pnpm_candidates); + let mut conflicts = Vec::new(); + if node.state == "conflict" { + conflicts.push("node".to_string()); + } + if pnpm.state == "conflict" { + conflicts.push("pnpm".to_string()); + } + + Ok(ToolchainContract { + node, + pnpm, + resolver: ResolverContract { + kind: "mise".to_string(), + configured: mise.exists, + source: mise.exists.then(|| "mise.toml".to_string()), + node_idiomatic: mise.node_idiomatic, + pnpm_idiomatic: mise.pnpm_idiomatic, + }, + conflicts, + }) +} + +fn locate_executable(tool: &str) -> Option { + #[cfg(target_os = "windows")] + let output = crate::silent_cmd("where.exe").arg(tool).output().ok()?; + #[cfg(not(target_os = "windows"))] + let output = crate::silent_cmd("which").arg(tool).output().ok()?; + + output + .status + .success() + .then(|| { + String::from_utf8_lossy(&output.stdout) + .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 { + #[cfg(target_os = "windows")] + let output = { + let escaped = path.replace('"', "\"\""); + crate::silent_cmd("cmd") + .args(["/D", "/C", &format!("\"{escaped}\" --version")]) + .output() + .ok()? + }; + #[cfg(not(target_os = "windows"))] + let output = crate::silent_cmd(path).arg("--version").output().ok()?; + + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let value = stdout + .lines() + .chain(stderr.lines()) + .map(str::trim) + .find(|line| !line.is_empty())?; + let normalized = normalize_version(value); + (!normalized.is_empty()).then_some(normalized) +} + +fn probe_runtime(tool: &str) -> RuntimeProbe { + let executable = locate_executable(tool); + let version = executable.as_deref().and_then(executable_version); + RuntimeProbe { + version, + executable, + } +} + +fn probe_mise_tool(root: &Path, mise_path: &str, tool: &str) -> RuntimeProbe { + let executable = crate::silent_cmd(mise_path) + .current_dir(root) + .args(["which", tool]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) + }); + let version = crate::silent_cmd(mise_path) + .current_dir(root) + .args(["exec", "--", tool, "--version"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + stdout + .lines() + .chain(stderr.lines()) + .map(str::trim) + .find(|line| !line.is_empty()) + .map(normalize_version) + }) + .filter(|value| !value.is_empty()); + RuntimeProbe { + version, + executable, + } +} + +fn tool_status( + declaration: &ToolDeclaration, + actual: RuntimeProbe, + managed: bool, +) -> RuntimeToolStatus { + let (status, recommendation) = match declaration.state.as_str() { + "conflict" => ( + "conflict", + Some(format!("统一 {} 的精确版本声明后重试", declaration.tool)), + ), + "missing" => ( + "missing_declaration", + Some(format!("为 {} 添加项目级精确版本声明", declaration.tool)), + ), + _ if actual.version.is_none() || actual.executable.is_none() => ( + "unavailable", + Some(format!("安装或准备项目声明的 {}", declaration.tool)), + ), + _ if actual.version != declaration.version => ( + "mismatch", + Some(format!("通过 mise 准备声明的 {} 版本", declaration.tool)), + ), + _ if managed => ("ready_managed", None), + _ => ( + "compliant_unmanaged", + Some("当前版本匹配,但尚未由项目 resolver 管理".to_string()), + ), + }; + + RuntimeToolStatus { + tool: declaration.tool.clone(), + declared_version: declaration.version.clone(), + declared_source: declaration.source.clone(), + actual_version: actual.version, + executable: actual.executable, + status: status.to_string(), + recommendation, + } +} + +pub fn build_toolchain_status( + project_id: &str, + root: &Path, + contract: ToolchainContract, + node_probe: RuntimeProbe, + pnpm_probe: RuntimeProbe, + mise_probe: RuntimeProbe, +) -> ToolchainStatus { + let resolver_ready = contract.resolver.configured + && mise_probe.version.is_some() + && mise_probe.executable.is_some(); + let node_managed = resolver_ready + && (contract.resolver.node_idiomatic + || contract.node.source.as_deref() == Some("mise.toml#tools.node")); + let pnpm_managed = resolver_ready + && (contract.resolver.pnpm_idiomatic + || contract.pnpm.source.as_deref() == Some("mise.toml#tools.pnpm")); + let resolver_status = match ( + contract.resolver.configured, + mise_probe.executable.is_some(), + ) { + (true, true) => "ready", + (true, false) => "missing", + (false, true) => "available_unconfigured", + (false, false) => "unavailable", + }; + + ToolchainStatus { + project_id: project_id.to_string(), + project_root: root.display().to_string(), + node: tool_status(&contract.node, node_probe, node_managed), + pnpm: tool_status(&contract.pnpm, pnpm_probe, pnpm_managed), + resolver: ResolverStatus { + kind: contract.resolver.kind.clone(), + configured: contract.resolver.configured, + executable: mise_probe.executable, + actual_version: mise_probe.version, + status: resolver_status.to_string(), + }, + conflicts: contract.conflicts, + } +} + +fn registered_project_root(conn: &Connection, project_id: &str) -> Result { + let win_path: Option = conn + .query_row( + "SELECT win_path FROM project_workspaces WHERE id = ?1", + params![project_id], + |row| row.get(0), + ) + .map_err(|_| format!("Project not found: {project_id}"))?; + let configured = win_path + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| format!("项目未配置 Windows 工作目录: {project_id}"))?; + std::fs::canonicalize(&configured) + .map_err(|error| format!("项目目录不可访问 {}: {error}", configured)) +} + +pub fn get_toolchain_status_for( + conn: &Connection, + project_id: &str, +) -> Result { + let root = registered_project_root(conn, project_id)?; + let contract = parse_toolchain_contract(&root)?; + let mise_probe = probe_runtime("mise"); + let mise_path = mise_probe.executable.as_deref(); + let node_is_managed = contract.resolver.configured + && (contract.resolver.node_idiomatic + || contract.node.source.as_deref() == Some("mise.toml#tools.node")); + let pnpm_is_managed = contract.resolver.configured + && (contract.resolver.pnpm_idiomatic + || contract.pnpm.source.as_deref() == Some("mise.toml#tools.pnpm")); + let node_probe = match (node_is_managed, mise_path) { + (true, Some(mise)) => probe_mise_tool(&root, mise, "node"), + _ => probe_runtime("node"), + }; + let pnpm_probe = match (pnpm_is_managed, mise_path) { + (true, Some(mise)) => probe_mise_tool(&root, mise, "pnpm"), + _ => probe_runtime("pnpm"), + }; + Ok(build_toolchain_status( + project_id, &root, contract, node_probe, pnpm_probe, mise_probe, + )) +} + +#[tauri::command] +pub fn get_toolchain_status(project_id: String) -> Result { + let conn = crate::db::pool().get().map_err(|error| error.to_string())?; + get_toolchain_status_for(&conn, &project_id) +} + +fn validate_mise_config_for_prepare(root: &Path) -> Result<(), String> { + let path = root.join("mise.toml"); + let content = std::fs::read_to_string(&path) + .map_err(|error| format!("读取 {} 失败: {error}", path.display()))?; + let mut section = String::new(); + + for raw_line in content.lines() { + let line = raw_line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + section = line.trim_matches(['[', ']']).trim().to_string(); + if section != "tools" && section != "settings" { + return Err(format!( + "mise.toml 含不受控 section [{section}],请人工执行工具链准备" + )); + } + continue; + } + let Some((raw_key, raw_value)) = line.split_once('=') else { + return Err("mise.toml 含无法识别的配置行,请人工检查".to_string()); + }; + let key = raw_key.trim().trim_matches(['"', '\'']); + let value = raw_value.trim(); + let allowed = match section.as_str() { + "tools" => { + matches!(key, "node" | "pnpm") + && !value.contains(['{', '}']) + && is_exact_version(value) + } + "settings" => key == "idiomatic_version_file_enable_tools", + "" => key == "min_version", + _ => false, + }; + if !allowed { + return Err(format!( + "mise.toml 配置 `{key}` 超出炼境安全准备边界,请人工执行 mise install" + )); + } + } + Ok(()) +} + +fn prepare_plan(status: &ToolchainStatus, confirmed: bool) -> Result<(PathBuf, String), String> { + if !confirmed { + return Err("准备工具链需要用户显式确认".to_string()); + } + if !status.conflicts.is_empty() { + return Err(format!( + "工具链声明存在冲突: {}", + status.conflicts.join(", ") + )); + } + if status.node.declared_version.is_none() || status.pnpm.declared_version.is_none() { + return Err("Node/pnpm 必须都有项目级精确版本声明".to_string()); + } + if !status.resolver.configured { + return Err("项目尚未配置 mise.toml".to_string()); + } + let mise = status + .resolver + .executable + .clone() + .ok_or_else(|| "本机未安装 mise;请先完成一次用户级安装".to_string())?; + let root = std::fs::canonicalize(&status.project_root) + .map_err(|error| format!("项目目录不可访问 {}: {error}", status.project_root))?; + validate_mise_config_for_prepare(&root)?; + Ok((root, mise)) +} + +pub fn prepare_toolchain_for( + conn: &Connection, + project_id: &str, + confirmed: bool, +) -> Result { + let before = get_toolchain_status_for(conn, project_id)?; + let (root, mise) = prepare_plan(&before, confirmed)?; + let output = crate::silent_cmd(&mise) + .current_dir(&root) + .arg("install") + .output() + .map_err(|error| format!("执行 mise install 失败: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !output.status.success() { + return Err(format!( + "mise install 未成功(exit={})\n{}", + output.status.code().unwrap_or(-1), + if stderr.is_empty() { &stdout } else { &stderr } + )); + } + let after = get_toolchain_status_for(conn, project_id)?; + let verified = after.node.status == "ready_managed" && after.pnpm.status == "ready_managed"; + Ok(PrepareToolchainResult { + command: vec![mise, "install".to_string()], + before, + after, + verified, + stdout, + stderr, + }) +} + +#[tauri::command] +pub fn prepare_toolchain( + project_id: String, + confirmed: bool, +) -> Result { + let conn = crate::db::pool().get().map_err(|error| error.to_string())?; + prepare_toolchain_for(&conn, &project_id, confirmed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + fn fixture() -> std::path::PathBuf { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let root = + std::env::temp_dir().join(format!("lian-jing-toolchain-{}-{id}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write(root: &Path, name: &str, content: &str) { + std::fs::write(root.join(name), content).unwrap(); + } + + #[test] + fn parses_consistent_node_and_pnpm_contract() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write( + &root, + "package.json", + r#"{ + "packageManager": "pnpm@9.15.0+sha256.deadbeef", + "engines": { "node": "24.19.0", "pnpm": "9.15.0" } + }"#, + ); + write( + &root, + "mise.toml", + "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n", + ); + + let contract = parse_toolchain_contract(&root).unwrap(); + assert_eq!(contract.node.state, "declared"); + assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); + assert_eq!(contract.node.source.as_deref(), Some(".nvmrc")); + assert_eq!(contract.pnpm.state, "declared"); + assert_eq!(contract.pnpm.version.as_deref(), Some("9.15.0")); + assert_eq!( + contract.pnpm.source.as_deref(), + Some("package.json#packageManager") + ); + assert!(contract.resolver.node_idiomatic); + assert!(contract.resolver.pnpm_idiomatic); + assert!(contract.conflicts.is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reports_conflicting_exact_node_versions() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write(&root, ".node-version", "22.14.0\n"); + + let contract = parse_toolchain_contract(&root).unwrap(); + assert_eq!(contract.node.state, "conflict"); + assert_eq!(contract.node.version, None); + assert_eq!(contract.conflicts, vec!["node"]); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn compatible_engine_range_does_not_conflict_with_exact_pin() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write(&root, "package.json", r#"{"engines":{"node":">=22 <25"}}"#); + + let contract = parse_toolchain_contract(&root).unwrap(); + assert_eq!(contract.node.state, "declared"); + assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); + assert_eq!(contract.node.candidates.len(), 2); + assert!(!contract.node.candidates[1].exact); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn exact_engine_values_are_compatibility_only() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write( + &root, + "package.json", + r#"{"engines":{"node":"22.14.0","pnpm":"9.15.0"}}"#, + ); + + let contract = parse_toolchain_contract(&root).unwrap(); + assert_eq!(contract.node.state, "declared"); + assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); + assert!(!contract.node.candidates[1].exact); + assert_eq!(contract.pnpm.state, "missing"); + assert!(contract.conflicts.is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn parses_direct_mise_tool_pins() { + let root = fixture(); + write( + &root, + "mise.toml", + "[tools]\nnode = '24.19.0'\npnpm = \"9.15.0\"\n", + ); + + let contract = parse_toolchain_contract(&root).unwrap(); + assert_eq!(contract.node.version.as_deref(), Some("24.19.0")); + assert_eq!( + contract.node.source.as_deref(), + Some("mise.toml#tools.node") + ); + assert_eq!(contract.pnpm.version.as_deref(), Some("9.15.0")); + assert_eq!( + contract.pnpm.source.as_deref(), + Some("mise.toml#tools.pnpm") + ); + assert!(contract.resolver.configured); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn empty_project_reports_missing_declarations() { + let root = fixture(); + let contract = parse_toolchain_contract(&root).unwrap(); + assert_eq!(contract.node.state, "missing"); + assert_eq!(contract.pnpm.state, "missing"); + assert!(!contract.resolver.configured); + std::fs::remove_dir_all(root).unwrap(); + } + + fn probe(version: Option<&str>, executable: Option<&str>) -> RuntimeProbe { + RuntimeProbe { + version: version.map(str::to_string), + executable: executable.map(str::to_string), + } + } + + #[test] + fn status_distinguishes_managed_mismatch_and_unmanaged() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write(&root, "package.json", r#"{"packageManager":"pnpm@9.15.0"}"#); + write( + &root, + "mise.toml", + "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n", + ); + let contract = parse_toolchain_contract(&root).unwrap(); + + let status = build_toolchain_status( + "p1", + &root, + contract.clone(), + probe(Some("24.19.0"), Some("node.exe")), + probe(Some("11.19.0"), Some("pnpm.cmd")), + probe(Some("2026.9.12"), Some("mise.exe")), + ); + assert_eq!(status.node.status, "ready_managed"); + assert_eq!(status.pnpm.status, "mismatch"); + assert_eq!(status.pnpm.executable.as_deref(), Some("pnpm.cmd")); + + let unmanaged = build_toolchain_status( + "p1", + &root, + ToolchainContract { + resolver: ResolverContract { + configured: false, + node_idiomatic: false, + pnpm_idiomatic: false, + source: None, + kind: "mise".to_string(), + }, + ..contract + }, + probe(Some("24.19.0"), Some("node.exe")), + probe(Some("9.15.0"), Some("pnpm.cmd")), + probe(None, None), + ); + assert_eq!(unmanaged.node.status, "compliant_unmanaged"); + assert_eq!(unmanaged.pnpm.status, "compliant_unmanaged"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn registered_root_rejects_unknown_project() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE project_workspaces ( + id TEXT PRIMARY KEY, + win_path TEXT + );", + ) + .unwrap(); + + let error = registered_project_root(&conn, "missing").unwrap_err(); + assert_eq!(error, "Project not found: missing"); + } + + #[test] + fn prepare_plan_requires_confirmation_and_safe_config() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write(&root, "package.json", r#"{"packageManager":"pnpm@9.15.0"}"#); + write( + &root, + "mise.toml", + "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n", + ); + let contract = parse_toolchain_contract(&root).unwrap(); + let status = build_toolchain_status( + "p1", + &root, + contract, + probe(Some("24.19.0"), Some("node.exe")), + probe(Some("9.15.0"), Some("pnpm.cmd")), + probe(Some("2026.9.12"), Some("mise.exe")), + ); + + assert_eq!( + prepare_plan(&status, false).unwrap_err(), + "准备工具链需要用户显式确认" + ); + let (planned_root, mise) = prepare_plan(&status, true).unwrap(); + assert_eq!(planned_root, std::fs::canonicalize(&root).unwrap()); + assert_eq!(mise, "mise.exe"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn prepare_plan_rejects_custom_mise_tasks() { + let root = fixture(); + write(&root, ".nvmrc", "24.19.0\n"); + write(&root, "package.json", r#"{"packageManager":"pnpm@9.15.0"}"#); + write( + &root, + "mise.toml", + "[settings]\nidiomatic_version_file_enable_tools = ['node', 'pnpm']\n[tasks.install]\nrun = 'dangerous'\n", + ); + let contract = parse_toolchain_contract(&root).unwrap(); + let status = build_toolchain_status( + "p1", + &root, + contract, + probe(Some("24.19.0"), Some("node.exe")), + probe(Some("9.15.0"), Some("pnpm.cmd")), + probe(Some("2026.9.12"), Some("mise.exe")), + ); + + let error = prepare_plan(&status, true).unwrap_err(); + assert!(error.contains("不受控 section [tasks.install]")); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e0f658..dffa659 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,6 +55,7 @@ use commands::{ spaces::*, tags::*, templates::*, + toolchain::*, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -234,6 +235,8 @@ pub fn run() { scan_project_env, get_project_env_tools, save_project_env_tools, + get_toolchain_status, + prepare_toolchain, // shell open_editor, open_terminal, diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index f1d1705..eef1eab 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -196,6 +196,29 @@ pub fn tools_list_result() -> Value { "required": ["project_id"] } }, + { + "name": "get_toolchain_status", + "description": "只读检查项目声明的 Node/pnpm 版本与当前实际 executable/version,并报告 mise resolver 状态。该工具只采集证据,不安装、升级或修改项目文件;缺失、冲突和版本漂移会返回明确状态。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" } + }, + "required": ["project_id"] + } + }, + { + "name": "prepare_toolchain", + "description": "为已登记项目执行受控 mise install。仅在用户已明确确认本次机器状态变更后调用并传 confirmed=true;拒绝未知项目、冲突声明、自定义 mise task/hook/plugin,并且不修改 package.json 或 lockfile。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID" }, + "confirmed": { "type": "boolean", "description": "当前用户是否已明确确认下载/安装项目声明的工具版本" } + }, + "required": ["project_id", "confirmed"] + } + }, { "name": "merge_pull_request", "description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。", @@ -457,6 +480,29 @@ pub async fn tools_call(params: Option<&Value>) -> Value { let status = crate::commands::delivery_status::get_delivery_status(&conn, pid, &root); serde_json::to_string_pretty(&status).map_err(|e| e.to_string()) } + "get_toolchain_status" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + if pid.is_empty() { + return Err("project_id 不能为空".to_string()); + } + let conn = db::pool().get().map_err(|e| e.to_string())?; + let status = crate::commands::toolchain::get_toolchain_status_for(&conn, pid)?; + serde_json::to_string_pretty(&status).map_err(|e| e.to_string()) + } + "prepare_toolchain" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + if pid.is_empty() { + return Err("project_id 不能为空".to_string()); + } + let confirmed = args + .get("confirmed") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let conn = db::pool().get().map_err(|e| e.to_string())?; + let result = + crate::commands::toolchain::prepare_toolchain_for(&conn, pid, confirmed)?; + serde_json::to_string_pretty(&result).map_err(|e| e.to_string()) + } "merge_pull_request" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0); @@ -765,7 +811,9 @@ pub async fn tools_call(params: Option<&Value>) -> Value { match outcome { Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }), Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }), - Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }), + Err(e) => { + json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }) + } } } @@ -788,7 +836,9 @@ fn get_diagnostics() -> Result { out.push_str(&format!("- **服务端口**: {}\n", port)); out.push_str(&format!( "- **启动时间**: {}\n\n", - start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)") + start_time + .as_deref() + .unwrap_or("未知(可能在本次日志之前启动)") )); out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n"); @@ -808,7 +858,9 @@ fn get_diagnostics() -> Result { .map_err(|e| e.to_string())?; let inject_rows: Vec<(Option, String, String, String)> = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); @@ -820,10 +872,17 @@ fn get_diagnostics() -> Result { let project_id = ctx .as_deref() .and_then(|s| serde_json::from_str::(s).ok()) - .and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string())) + .and_then(|v| { + v.get("project_id") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + }) .unwrap_or_else(|| "unknown".to_string()); let icon = if level == "info" { "✅" } else { "⚠️" }; - out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts)); + out.push_str(&format!( + "- {} `{}` — {} _({})\n", + icon, project_id, msg, ts + )); } out.push('\n'); } @@ -838,7 +897,9 @@ fn get_diagnostics() -> Result { .map_err(|e| e.to_string())?; let error_rows: Vec<(String, String, String, String)> = stmt2 - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); @@ -854,7 +915,9 @@ fn get_diagnostics() -> Result { out.push_str("\n### 使者回传覆盖率(近 30 天)\n\n"); match crate::commands::onboarding::silent_projects(&conn) { Ok(list) if list.is_empty() => { - out.push_str("_无沉默项目:有 commit 活动的已接入项目均有使者回传(或近期无活动)_ ✅\n"); + out.push_str( + "_无沉默项目:有 commit 活动的已接入项目均有使者回传(或近期无活动)_ ✅\n", + ); } Ok(list) => { out.push_str("以下项目近 30 天有 commit 活动但 **0 条使者回传**——环的回流段在这些项目上是断的,考虑去该项目会话跑一次 `/envoy`:\n\n"); @@ -887,11 +950,17 @@ fn get_diagnostics() -> Result { out.push_str("\n### 质量指标\n\n"); let q = &report.quality; - out.push_str(&format!("- **休眠项目**(>30天未 push):{} 个\n", q.dormant_projects.len())); + out.push_str(&format!( + "- **休眠项目**(>30天未 push):{} 个\n", + q.dormant_projects.len() + )); for p in &q.dormant_projects { out.push_str(&format!(" - {} ({} 天)\n", p.name, p.days_since_push)); } - out.push_str(&format!("- **孤立项目**(无分组 & 无 repo):{} 个\n", q.orphaned_projects.len())); + out.push_str(&format!( + "- **孤立项目**(无分组 & 无 repo):{} 个\n", + q.orphaned_projects.len() + )); for p in &q.orphaned_projects { out.push_str(&format!(" - {}\n", p.name)); } @@ -902,7 +971,10 @@ fn get_diagnostics() -> Result { for gid in &q.groups_with_inject_warn { out.push_str(&format!(" - `{}`\n", gid)); } - out.push_str(&format!("- **无本地路径项目**:{} 个\n", q.projects_without_path)); + out.push_str(&format!( + "- **无本地路径项目**:{} 个\n", + q.projects_without_path + )); Ok(out) } @@ -946,14 +1018,17 @@ fn list_group_projects(group_id: &str) -> Result { if rows.is_empty() { return Ok("该产品组暂无成员项目。".to_string()); } - Ok(format!("产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n"))) + Ok(format!( + "产品组成员(共 {} 个项目):\n\n{}", + rows.len(), + rows.join("\n\n") + )) } fn get_project_blueprint(project_id: &str) -> Result { let root = resolve_project_root(project_id)?; let path = root.join(".blueprint").join("manifest.yaml"); - std::fs::read_to_string(&path) - .map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e)) + std::fs::read_to_string(&path).map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e)) } fn get_project_file(project_id: &str, relative_path: &str) -> Result { @@ -962,8 +1037,7 @@ fn get_project_file(project_id: &str, relative_path: &str) -> Result Result Result { let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len()); for (_id, project_name, content, created_at) in &rows { let date = &created_at[..created_at.len().min(10)]; - out.push_str(&format!("---\n**来源**: {} | **时间**: {}\n{}\n\n", project_name, date, content)); + out.push_str(&format!( + "---\n**来源**: {} | **时间**: {}\n{}\n\n", + project_name, date, content + )); } out.push_str("---\n💡 处置参考:fix_template → 改接入包模板文件;update_convention → 改 CONVENTIONS.md;log_for_review → 记录待观察,暂不动模板"); Ok(out) @@ -1074,7 +1157,10 @@ fn append_project_note(project_id: &str, content: &str) -> Result Result = std::sync::Mutex::new(()); /// 单条笔记 → JSONL 行(含 project_id,档案自含,脱离文件位置也可重建) -fn note_jsonl_line(id: i64, project_id: &str, source: &str, content: &str, created_at: &str) -> String { +fn note_jsonl_line( + id: i64, + project_id: &str, + source: &str, + content: &str, + created_at: &str, +) -> String { json!({ "id": id, "project_id": project_id, @@ -1127,8 +1219,12 @@ fn archive_note_append(conn: &rusqlite::Connection, note_id: i64) -> Result<(), .append(true) .open(&path) .map_err(|e| e.to_string())?; - writeln!(f, "{}", note_jsonl_line(note_id, &pid, &source, &content, &created_at)) - .map_err(|e| e.to_string())?; + writeln!( + f, + "{}", + note_jsonl_line(note_id, &pid, &source, &content, &created_at) + ) + .map_err(|e| e.to_string())?; Ok(()) } @@ -1141,12 +1237,15 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result ) .map_err(|e| e.to_string())?; let rows: Vec<(i64, String, String, String, String)> = stmt - .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))) + .query_map([], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) + }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); - let mut by_project: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut by_project: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); for (id, pid, source, content, created_at) in &rows { by_project .entry(pid.clone()) @@ -1156,8 +1255,10 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result // 按「解析后的落盘路径」归并——同路径重复登记(mcp-single-server 已知遗留)指向同一文件, // 若按 project_id 逐项目整写会互相覆盖(2026-09-11 真机回填实锤:后写覆盖先写丢 20 条) - let mut by_path: std::collections::BTreeMap, Vec<(i64, String)>)> = - std::collections::BTreeMap::new(); + let mut by_path: std::collections::BTreeMap< + std::path::PathBuf, + (Vec, Vec<(i64, String)>), + > = std::collections::BTreeMap::new(); let mut report = String::from("## notes 档案回填(全量重建)\n\n"); let mut skipped = 0usize; for (pid, entries) in by_project { @@ -1168,7 +1269,12 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result slot.1.extend(entries); } Err(e) => { - report.push_str(&format!("- ⚠️ {}:跳过({}),{} 条仅存 DB\n", pid, e, entries.len())); + report.push_str(&format!( + "- ⚠️ {}:跳过({}),{} 条仅存 DB\n", + pid, + e, + entries.len() + )); skipped += 1; } } @@ -1218,7 +1324,14 @@ fn get_project_snapshots(project_id: &str, days: u32) -> Result let offset = format!("-{} days", days); let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt .query_map(rusqlite::params![project_id, offset], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) @@ -1240,7 +1353,9 @@ fn get_project_snapshots(project_id: &str, days: u32) -> Result md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { - let commit_col = days_commit.map(|d| d.to_string()).unwrap_or_else(|| "—".to_string()); + let commit_col = days_commit + .map(|d| d.to_string()) + .unwrap_or_else(|| "—".to_string()); let rate_col = done_rate .map(|r| format!("{:.0}%", r * 100.0)) .unwrap_or_else(|| "—".to_string()); @@ -1250,7 +1365,9 @@ fn get_project_snapshots(project_id: &str, days: u32) -> Result date, commit_col, rate_col, in_prog, blocked, err_col )); } - md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); + md.push_str( + "\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n", + ); Ok(md) } @@ -1266,9 +1383,23 @@ pub fn list_all_projects() -> Result { ) .map_err(|e| e.to_string())?; - let rows: Vec<(String, String, Option, Option, Option, Option)> = - stmt.query_map([], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + let rows: Vec<( + String, + String, + Option, + Option, + Option, + Option, + )> = stmt + .query_map([], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) @@ -1302,10 +1433,19 @@ pub fn list_all_projects() -> Result { let injected = project_path .as_ref() - .map(|p| std::path::Path::new(p).join(".claude").join("liangjing.json").exists()) + .map(|p| { + std::path::Path::new(p) + .join(".claude") + .join("liangjing.json") + .exists() + }) .unwrap_or(false); - let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" }; + let inject_icon = if injected { + "✅ 已注入" + } else { + "⚠️ 未注入" + }; let group_str = group_id.as_deref().unwrap_or("无产品组"); let path_str = project_path.as_deref().unwrap_or("未配置路径"); out.push_str(&format!( @@ -1353,7 +1493,10 @@ pub fn scan_unregistered_projects() -> Result { if !path.is_dir() { continue; } - let path_str = path.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let path_str = path + .to_string_lossy() + .trim_end_matches(['/', '\\']) + .to_string(); if registered_paths.contains(&path_str) { continue; } @@ -1424,7 +1567,9 @@ pub fn scan_unregistered_projects() -> Result { )); } - out.push_str("---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。"); + out.push_str( + "---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。", + ); Ok(out) } @@ -1438,31 +1583,66 @@ mod tests { fn tools_list_contains_expected_tools() { let result = tools_list_result(); let tools = result["tools"].as_array().expect("tools 应为数组"); - let names: Vec<&str> = tools - .iter() - .filter_map(|t| t["name"].as_str()) - .collect(); + let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); // 核心工具必须存在 - assert!(names.contains(&"apply_onboarding_pack"), "缺少 apply_onboarding_pack"); - assert!(names.contains(&"verify_onboarding"), "缺少 verify_onboarding"); - assert!(names.contains(&"list_all_projects"), "缺少 list_all_projects"); + assert!( + names.contains(&"apply_onboarding_pack"), + "缺少 apply_onboarding_pack" + ); + assert!( + names.contains(&"verify_onboarding"), + "缺少 verify_onboarding" + ); + assert!( + names.contains(&"list_all_projects"), + "缺少 list_all_projects" + ); assert!( names.contains(&"scan_unregistered_projects"), "缺少 scan_unregistered_projects" ); assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); - assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); - assert!(names.contains(&"get_delivery_status"), "缺少 get_delivery_status"); + assert!( + names.contains(&"create_pull_request"), + "缺少 create_pull_request" + ); + assert!( + names.contains(&"get_delivery_status"), + "缺少 get_delivery_status" + ); + assert!( + names.contains(&"get_toolchain_status"), + "缺少 get_toolchain_status" + ); + assert!( + names.contains(&"prepare_toolchain"), + "缺少 prepare_toolchain" + ); assert!(names.contains(&"poll_now"), "缺少 poll_now"); - assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk"); - assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history"); - assert!(names.contains(&"unregister_project"), "缺少 unregister_project"); + assert!( + names.contains(&"predict_task_risk"), + "缺少 predict_task_risk" + ); + assert!( + names.contains(&"ingest_git_history"), + "缺少 ingest_git_history" + ); + assert!( + names.contains(&"unregister_project"), + "缺少 unregister_project" + ); assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo"); // 每个工具都有 description 和 inputSchema for tool in tools { let name = tool["name"].as_str().unwrap_or("?"); - assert!(tool["description"].is_string(), "工具 {name} 缺少 description"); - assert!(tool["inputSchema"].is_object(), "工具 {name} 缺少 inputSchema"); + assert!( + tool["description"].is_string(), + "工具 {name} 缺少 description" + ); + assert!( + tool["inputSchema"].is_object(), + "工具 {name} 缺少 inputSchema" + ); } } @@ -1480,6 +1660,34 @@ mod tests { .contains("project_id 不能为空")); } + #[tokio::test] + async fn toolchain_status_tool_requires_project_id() { + let result = tools_call(Some(&json!({ + "name": "get_toolchain_status", + "arguments": {} + }))) + .await; + assert_eq!(result["isError"], true); + assert!(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("project_id 不能为空")); + } + + #[test] + fn prepare_toolchain_schema_requires_confirmation() { + let result = tools_list_result(); + let tool = result["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "prepare_toolchain") + .unwrap(); + let required = tool["inputSchema"]["required"].as_array().unwrap(); + assert!(required.iter().any(|value| value == "project_id")); + assert!(required.iter().any(|value| value == "confirmed")); + } + /// notes 档案测试用最小 schema(in-memory 或文件库共用) fn setup_notes_schema(conn: &rusqlite::Connection) { conn.execute_batch( @@ -1500,7 +1708,8 @@ mod tests { } fn temp_base(tag: &str) -> std::path::PathBuf { - let base = std::env::temp_dir().join(format!("lianjing_notes_{tag}_{}", std::process::id())); + let base = + std::env::temp_dir().join(format!("lianjing_notes_{tag}_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); base @@ -1545,7 +1754,11 @@ mod tests { ) .unwrap(); // 死登记:空路径(2026-07-15 空路径污染同款场景,必须跳过而非写进 cwd) - conn.execute("INSERT INTO project_workspaces (id, win_path) VALUES ('dead', '')", []).unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, win_path) VALUES ('dead', '')", + [], + ) + .unwrap(); for i in 0..3 { conn.execute( "INSERT INTO project_notes (project_id, content) VALUES ('good', ?1)", @@ -1553,7 +1766,11 @@ mod tests { ) .unwrap(); } - conn.execute("INSERT INTO project_notes (project_id, content) VALUES ('dead', 'x')", []).unwrap(); + conn.execute( + "INSERT INTO project_notes (project_id, content) VALUES ('dead', 'x')", + [], + ) + .unwrap(); let report = backfill_notes_archive(&conn).expect("回填应成功"); let file = base.join(".blueprint").join("notes-archive.jsonl"); @@ -1588,13 +1805,24 @@ mod tests { ) .unwrap(); } - conn.execute("INSERT INTO project_notes (project_id, content) VALUES ('name-form', 'b-0')", []).unwrap(); + conn.execute( + "INSERT INTO project_notes (project_id, content) VALUES ('name-form', 'b-0')", + [], + ) + .unwrap(); let report = backfill_notes_archive(&conn).expect("回填应成功"); let file = base.join(".blueprint").join("notes-archive.jsonl"); let text = std::fs::read_to_string(&file).unwrap(); - assert_eq!(text.lines().count(), 4, "同路径两登记的笔记应全部归并: {report}"); - assert!(report.contains("4 条已归档"), "报告应按笔记数计数: {report}"); + assert_eq!( + text.lines().count(), + 4, + "同路径两登记的笔记应全部归并: {report}" + ); + assert!( + report.contains("4 条已归档"), + "报告应按笔记数计数: {report}" + ); } #[test] @@ -1612,7 +1840,10 @@ mod tests { for i in 0..8 { conn.execute( "INSERT INTO project_notes (project_id, content) VALUES ('p1', ?1)", - [format!("并发内容较长以放大交错概率-{i}-{}", "x".repeat(500))], + [format!( + "并发内容较长以放大交错概率-{i}-{}", + "x".repeat(500) + )], ) .unwrap(); } @@ -1644,18 +1875,30 @@ mod tests { let result = tools_list_result(); let tools = result["tools"].as_array().unwrap(); // verify 必填 project_id;apply 自 U1 起可省略(批量模式) - let verify = tools.iter().find(|t| t["name"] == "verify_onboarding").unwrap(); + let verify = tools + .iter() + .find(|t| t["name"] == "verify_onboarding") + .unwrap(); let fields: Vec<&str> = verify["inputSchema"]["required"] .as_array() .unwrap() .iter() .filter_map(|v| v.as_str()) .collect(); - assert!(fields.contains(&"project_id"), "verify_onboarding 应必填 project_id"); - - let apply = tools.iter().find(|t| t["name"] == "apply_onboarding_pack").unwrap(); assert!( - apply["inputSchema"]["required"].as_array().unwrap().is_empty(), + fields.contains(&"project_id"), + "verify_onboarding 应必填 project_id" + ); + + let apply = tools + .iter() + .find(|t| t["name"] == "apply_onboarding_pack") + .unwrap(); + assert!( + apply["inputSchema"]["required"] + .as_array() + .unwrap() + .is_empty(), "apply_onboarding_pack 的 project_id 应可省略(U1 批量模式)" ); } diff --git a/src/components/dashboard/ProjectEnvModal.tsx b/src/components/dashboard/ProjectEnvModal.tsx index f3b410a..28d155d 100644 --- a/src/components/dashboard/ProjectEnvModal.tsx +++ b/src/components/dashboard/ProjectEnvModal.tsx @@ -4,9 +4,13 @@ import { scanProjectEnv, getProjectEnvTools, saveProjectEnvTools, + getToolchainStatus, + prepareToolchain, type Project, type EnvToolItem, type SaveEnvToolInput, + type RuntimeToolStatus, + type ToolchainStatus, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; @@ -19,6 +23,15 @@ const LAYER_META: Record 3: { label: "第 3 层", desc: "局部内置", color: "bg-gray-50 border-gray-200 text-gray-700" }, }; +const TOOLCHAIN_STATUS_META: Record = { + ready_managed: { label: "已由 mise 管理", color: "text-green-700 bg-green-50 border-green-200" }, + compliant_unmanaged: { label: "版本匹配但未托管", color: "text-amber-700 bg-amber-50 border-amber-200" }, + mismatch: { label: "版本不匹配", color: "text-red-700 bg-red-50 border-red-200" }, + conflict: { label: "声明冲突", color: "text-red-700 bg-red-50 border-red-200" }, + missing_declaration: { label: "缺少声明", color: "text-amber-700 bg-amber-50 border-amber-200" }, + unavailable: { label: "当前不可用", color: "text-gray-600 bg-gray-50 border-gray-200" }, +}; + // ── 主组件 ──────────────────────────────────────────────────────────────────── interface Props { @@ -33,6 +46,7 @@ export function ProjectEnvModal({ project, onClose }: Props) { const [items, setItems] = useState([]); const [scanning, setScanning] = useState(false); const [saving, setSaving] = useState(false); + const [preparing, setPreparing] = useState(false); const [scanned, setScanned] = useState(false); // 加载已保存的数据 @@ -41,6 +55,16 @@ export function ProjectEnvModal({ project, onClose }: Props) { queryFn: () => getProjectEnvTools(project.id), }); + const { + data: toolchain, + isLoading: toolchainLoading, + error: toolchainError, + refetch: refetchToolchain, + } = useQuery({ + queryKey: ["toolchainStatus", project.id], + queryFn: () => getToolchainStatus(project.id), + }); + // 已保存数据加载后,若尚未扫描则显示已保存内容 useEffect(() => { if (savedItems.length > 0 && !scanned) { @@ -88,6 +112,25 @@ export function ProjectEnvModal({ project, onClose }: Props) { } }; + const handlePrepareToolchain = async () => { + const confirmed = window.confirm( + "炼境将通过 mise install 下载并准备项目已声明的 Node/pnpm 版本。不会修改 package.json 或 lockfile,是否继续?", + ); + if (!confirmed) return; + setPreparing(true); + try { + const result = await prepareToolchain(project.id, true); + await refetchToolchain(); + showToast(result.verified + ? `工具链准备并验证完成:${result.command.join(" ")} ✓` + : `⚠️ ${result.command.join(" ")} 已执行,但二次诊断尚未达到 ready_managed,请查看状态证据`); + } catch (e) { + showToast(`❌ ${e}`); + } finally { + setPreparing(false); + } + }; + // 按层分组 const grouped = [0, 1, 2, 3].reduce>((acc, layer) => { const layerItems = items.filter((item) => item.layer === layer); @@ -135,6 +178,13 @@ export function ProjectEnvModal({ project, onClose }: Props) { {/* Content */}
+ {isLoading && !scanned ? (

加载中…

) : items.length === 0 ? ( @@ -205,6 +255,102 @@ export function ProjectEnvModal({ project, onClose }: Props) { ); } +function ToolchainCard({ + status, + loading, + error, + preparing, + onPrepare, +}: { + status?: ToolchainStatus; + loading: boolean; + error: unknown; + preparing: boolean; + onPrepare: () => void; +}) { + if (loading) { + return
正在检查项目工具链…
; + } + if (error || !status) { + return ( +
+ 工具链状态不可用:{String(error ?? "未返回状态")} +
+ ); + } + + const needsPrepare = [status.node.status, status.pnpm.status].some((value) => + ["mismatch", "unavailable", "compliant_unmanaged"].includes(value), + ); + const canPrepare = needsPrepare + && status.conflicts.length === 0 + && status.resolver.configured + && Boolean(status.resolver.executable); + + return ( +
+
+
+

项目工具链契约

+

{status.project_root}

+
+ {canPrepare && ( + + )} +
+
+ + +
+ mise resolver +
+ {status.resolver.status} + {status.resolver.actual_version && {status.resolver.actual_version}} + {status.resolver.executable &&

{status.resolver.executable}

} +
+
+
+ {status.conflicts.length > 0 && ( +

冲突声明:{status.conflicts.join("、")}。炼境不会替项目选择版本。

+ )} + {needsPrepare && !canPrepare && status.conflicts.length === 0 && ( +

+ {!status.resolver.configured + ? "项目尚未配置 mise.toml,请先应用工具链能力包。" + : "本机尚未安装 mise,请先完成一次用户级安装。"} +

+ )} +
+ ); +} + +function ToolchainRow({ label, item }: { label: string; item: RuntimeToolStatus }) { + const meta = TOOLCHAIN_STATUS_META[item.status] ?? TOOLCHAIN_STATUS_META.unavailable; + return ( +
+
+ {label} + {meta.label} +
+
+ 期望:{item.declared_version ?? "未声明"} + 实际:{item.actual_version ?? "不可用"} +
+

+ {item.declared_source ?? "无声明来源"}{item.executable ? ` · ${item.executable}` : ""} +

+ {item.recommendation &&

{item.recommendation}

} +
+ ); +} + // ── 工具行 ──────────────────────────────────────────────────────────────────── function EnvToolRow({ item, onRemove }: { item: EnvToolItem; onRemove: () => void }) { diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 18263b3..79b25a0 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -321,6 +321,46 @@ export const getProjectEnvTools = (projectId: string) => export const saveProjectEnvTools = (projectId: string, items: SaveEnvToolInput[]) => invoke('save_project_env_tools', { projectId, items }); +export interface RuntimeToolStatus { + tool: string; + declared_version?: string; + declared_source?: string; + actual_version?: string; + executable?: string; + status: 'ready_managed' | 'compliant_unmanaged' | 'mismatch' | 'conflict' | 'missing_declaration' | 'unavailable'; + recommendation?: string; +} + +export interface ToolchainStatus { + project_id: string; + project_root: string; + node: RuntimeToolStatus; + pnpm: RuntimeToolStatus; + resolver: { + kind: string; + configured: boolean; + executable?: string; + actual_version?: string; + status: 'ready' | 'missing' | 'available_unconfigured' | 'unavailable'; + }; + conflicts: string[]; +} + +export const getToolchainStatus = (projectId: string) => + invoke('get_toolchain_status', { projectId }); + +export interface PrepareToolchainResult { + command: string[]; + before: ToolchainStatus; + after: ToolchainStatus; + verified: boolean; + stdout: string; + stderr: string; +} + +export const prepareToolchain = (projectId: string, confirmed: boolean) => + invoke('prepare_toolchain', { projectId, confirmed }); + // ── Shell ───────────────────────────────────────────────────────────────────── export const openEditor = (projectId: string, env: "wsl" | "win", editorPath: string) => -- 2.45.2 From 8376bcbfd26cde349fe12a9ad4aa1185c75622a5 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 22 Sep 2026 01:23:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(toolchain):=20=E4=BF=AE=E5=A4=8D=20Wind?= =?UTF-8?q?ows=20wrapper=20=E7=89=88=E6=9C=AC=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优先选择 .exe/.cmd/.bat 路径并直接执行版本命令,补充含空格路径测试;运行态 MCP 已确认 Enterprise 的 Node 与 pnpm 版本可正确识别。 Rules-Applied: R07,R08,R10,R13 --- .../modules/toolchain-capability-pack.md | 8 +- src-tauri/src/commands/toolchain.rs | 74 +++++++++++++++---- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/.blueprint/modules/toolchain-capability-pack.md b/.blueprint/modules/toolchain-capability-pack.md index 78cb009..aa1923c 100644 --- a/.blueprint/modules/toolchain-capability-pack.md +++ b/.blueprint/modules/toolchain-capability-pack.md @@ -90,7 +90,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b - depends: T1. Node/pnpm 工具链契约解析器 - 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。" -- 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 只读诊断投影与契约文档 - status: done @@ -106,7 +106,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b - 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 - 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 工具链契约卡 - status: blocked @@ -114,7 +114,7 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b - depends: T2. 只读工具链状态 Tauri command, T4. 受控 mise 工具链准备能力 - 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: 环境工具弹窗能看懂期望/实际版本、来源路径和准备结果。" -- 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 条件式工具链指引 - 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 条件式工具链指引 - 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 阻塞。" -- 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/蓝图改动,现阶段不跨项目写入或混合验证。" ## 执行顺序 diff --git a/src-tauri/src/commands/toolchain.rs b/src-tauri/src/commands/toolchain.rs index c8787cb..4039f2a 100644 --- a/src-tauri/src/commands/toolchain.rs +++ b/src-tauri/src/commands/toolchain.rs @@ -320,6 +320,31 @@ pub fn parse_toolchain_contract(root: &Path) -> Result Option { + let paths = String::from_utf8_lossy(output) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); + + #[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 { #[cfg(target_os = "windows")] let output = crate::silent_cmd("where.exe").arg(tool).output().ok()?; @@ -329,25 +354,22 @@ fn locate_executable(tool: &str) -> Option { output .status .success() - .then(|| { - String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .unwrap_or_default() - .to_string() - }) - .filter(|path| !path.is_empty()) + .then(|| select_executable_path(&output.stdout)) + .flatten() } fn executable_version(path: &str) -> Option { #[cfg(target_os = "windows")] let output = { - let escaped = path.replace('"', "\"\""); - crate::silent_cmd("cmd") - .args(["/D", "/C", &format!("\"{escaped}\" --version")]) - .output() - .ok()? + let extension = Path::new(path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + 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"))] let output = crate::silent_cmd(path).arg("--version").output().ok()?; @@ -789,6 +811,30 @@ mod tests { 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 { RuntimeProbe { version: version.map(str::to_string), -- 2.45.2 From ccbd2b0e2317e4d177c6ff5659aa37686c3042e7 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 22 Sep 2026 01:41:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?style(env):=20=E6=89=A9=E5=A4=A7=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=B7=A5=E5=85=B7=E6=A8=A1=E6=80=81=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根据 human_verify 调整响应式宽高与内容间距,并将工具链 UI 任务标记完成。 Rules-Applied: R08,R13 --- .blueprint/manifest.yaml | 2 +- .blueprint/modules/toolchain-capability-pack.md | 8 ++++---- src/components/dashboard/ProjectEnvModal.tsx | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 3cda51a..393ed69 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -473,7 +473,7 @@ modules: name: 项目工具链能力包 area: infra status: in_progress - progress: 71 + progress: 86 note: "以项目标准声明为版本事实,mise 负责本地解析;炼境提供诊断、受控准备和 Agent 投影,不追随 Codex bundled runtime。v1 聚焦 Windows 项目的 Node + pnpm,明确不支持 WSL。" position: [2500, 950] edges: diff --git a/.blueprint/modules/toolchain-capability-pack.md b/.blueprint/modules/toolchain-capability-pack.md index aa1923c..dd9d5af 100644 --- a/.blueprint/modules/toolchain-capability-pack.md +++ b/.blueprint/modules/toolchain-capability-pack.md @@ -2,7 +2,7 @@ id: toolchain-capability-pack name: 项目工具链能力包 status: in_progress -progress: 71 +progress: 86 --- ## 设计决策:项目工具链契约、解析与 Agent 执行闭环 v1 @@ -108,13 +108,13 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b - 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,定向测试全绿,未实际下载安装。复盘待炼境 MCP 在本会话可用后回填。" -### 🔴 T5. 项目环境 UI 工具链契约卡 -- status: blocked +### ✅ T5. 项目环境 UI 工具链契约卡 +- status: done - complexity: M - depends: T2. 只读工具链状态 Tauri command, T4. 受控 mise 工具链准备能力 - 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: 环境工具弹窗能看懂期望/实际版本、来源路径和准备结果。" -- 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。需在环境工具弹窗终验状态卡与确认按钮。" +- done_note: "2026-09-22:运行态 MCP 对 Enterprise 返回 Node 24.19.0、pnpm 9.15.0 且均为 compliant_unmanaged,由此发现并修复 Windows `.cmd` wrapper 版本探测;pnpm typecheck 通过。用户截图完成 human_verify,并要求将模态框扩大到 92vw/max-w-5xl、88vh/max-h-900px,确认信息密度与滚动体验符合预期。复盘待炼境 MCP 在本会话可用后回填。" ### ✅ T6. Onboarding 与 Codex Skill 条件式工具链指引 - status: done diff --git a/src/components/dashboard/ProjectEnvModal.tsx b/src/components/dashboard/ProjectEnvModal.tsx index 28d155d..a30716d 100644 --- a/src/components/dashboard/ProjectEnvModal.tsx +++ b/src/components/dashboard/ProjectEnvModal.tsx @@ -139,10 +139,10 @@ export function ProjectEnvModal({ project, onClose }: Props) { }, {}); return ( -
-
+
+
{/* Header */} -
+

环境工具

{project.name}

@@ -151,7 +151,7 @@ export function ProjectEnvModal({ project, onClose }: Props) {
{/* Scan buttons */} -
+
扫描目录: {project.win_path && (