Compare commits

..

4 Commits

Author SHA1 Message Date
e57d612212 Merge pull request 'feat(toolchain): 增加项目级运行时能力包' (#11) from feat/toolchain-capability-pack into master
All checks were successful
Push & PR Check / check (push) Successful in 59s
2026-09-22 00:46:38 +08:00
lanrtop
ccbd2b0e23 style(env): 扩大环境工具模态框
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
Push & PR Check / check (pull_request) Successful in 59s
根据 human_verify 调整响应式宽高与内容间距,并将工具链 UI 任务标记完成。

Rules-Applied: R08,R13
2026-09-22 01:41:30 +09:00
lanrtop
8376bcbfd2 fix(toolchain): 修复 Windows wrapper 版本探测
优先选择 .exe/.cmd/.bat 路径并直接执行版本命令,补充含空格路径测试;运行态 MCP 已确认 Enterprise 的 Node 与 pnpm 版本可正确识别。

Rules-Applied: R07,R08,R10,R13
2026-09-22 01:23:34 +09:00
lanrtop
ead35ad12a feat(toolchain): 增加项目级运行时能力包
以 .nvmrc 与 packageManager 作为 Node/pnpm 版本事实,新增 Windows-only 的状态诊断、受控 mise 准备、MCP/Tauri/UI 双投影及 onboarding 自检。

Feature-Confirmed: true

Rules-Applied: R01,R04,R06,R07,R08,R10,R12
2026-09-22 01:08:31 +09:00
17 changed files with 1802 additions and 100 deletions

View File

@ -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: 86
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

View File

@ -0,0 +1,139 @@
---
id: toolchain-capability-pack
name: 项目工具链能力包
status: in_progress
progress: 86
---
## 设计决策:项目工具链契约、解析与 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 和版本输出;运行态 MCP 自检发现 `where pnpm` 优先返回无扩展 shim 且 cmd 引号不稳,已改为优先 `.exe/.cmd/.bat` 并分别使用直接执行或 `cmd /D /S /C`。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,定向测试全绿,未实际下载安装。复盘待炼境 MCP 在本会话可用后回填。"
### ✅ 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: 环境工具弹窗能看懂期望/实际版本、来源路径和准备结果。"
- 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
- 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: "运行态只读试点已确认 Enterprise 的 Node 24.19.0、pnpm 9.15.0 声明与本机版本匹配,但 resolver=unavailable,因此仍不是跨 Agent 可复现的 ready_managed。写入试点依赖本分支先完成审查、合并并重建炼境;Enterprise 当前另一个会话还有未提交 T3 migration/蓝图改动,现阶段不跨项目写入或混合验证。"
## 执行顺序
T1 → T2 → T3;T2 → T4 → T5;T3 + T4 → T6;T3 + T4 + T6 → T7。
核心能力先只读、后受控写;炼境实现者验证完成后,必须由 Enterprise System 以消费者角色完成最终闭环。

View File

@ -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 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"

View File

@ -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; }

View File

@ -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 映射;这些都属于项目接入与炼境控制面。

View File

@ -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 的本地工作;不得伪造诊断结果。

View File

@ -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 永不覆盖。"
}
]
}

View File

@ -18,7 +18,7 @@
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
<!-- ONBOARDING_MANAGED_START version="1.8.0" -->
<!-- ONBOARDING_MANAGED_START version="1.9.0" -->
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢(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 的投影

View File

@ -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; }

View File

@ -0,0 +1,4 @@
# 炼境工具链能力包:版本事实仍由 .nvmrc 与 package.json#packageManager 持有。
# 本文件只让 mise 读取这些标准声明,不重复保存 Node/pnpm 版本。
[settings]
idiomatic_version_file_enable_tools = ["node", "pnpm"]

View File

@ -36,4 +36,5 @@ pub mod spaces;
pub mod ssh_config;
pub mod tags;
pub mod templates;
pub mod toolchain;
pub mod user_todos;

View File

@ -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::<serde_json::Value>(&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<TemplateResult> {
".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 侧检查段

View File

@ -0,0 +1,960 @@
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<String>,
pub source: Option<String>,
pub candidates: Vec<VersionSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolverContract {
pub kind: String,
pub configured: bool,
pub source: Option<String>,
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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RuntimeProbe {
pub version: Option<String>,
pub executable: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RuntimeToolStatus {
pub tool: String,
pub declared_version: Option<String>,
pub declared_source: Option<String>,
pub actual_version: Option<String>,
pub executable: Option<String>,
pub status: String,
pub recommendation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolverStatus {
pub kind: String,
pub configured: bool,
pub executable: Option<String>,
pub actual_version: Option<String>,
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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PrepareToolchainResult {
pub command: Vec<String>,
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<String>,
pnpm_version: Option<String>,
}
fn read_trimmed(path: &Path) -> Option<String> {
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::<Vec<_>>();
segments.len() >= 2
&& segments
.iter()
.all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
}
fn push_candidate(candidates: &mut Vec<VersionSource>, 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<VersionSource>, 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<VersionSource>,
pnpm_candidates: &mut Vec<VersionSource>,
) -> 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<MiseConfig, String> {
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<VersionSource>) -> ToolDeclaration {
let exact_versions = candidates
.iter()
.filter(|candidate| candidate.exact)
.map(|candidate| candidate.version.clone())
.collect::<BTreeSet<_>>();
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<ToolchainContract, String> {
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 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> {
#[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(|| select_executable_path(&output.stdout))
.flatten()
}
fn executable_version(path: &str) -> Option<String> {
#[cfg(target_os = "windows")]
let output = {
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()?;
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<PathBuf, String> {
let win_path: Option<String> = 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<ToolchainStatus, String> {
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<ToolchainStatus, String> {
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<PrepareToolchainResult, String> {
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<PrepareToolchainResult, String> {
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();
}
#[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),
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();
}
}

View File

@ -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,

View File

@ -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<String, String> {
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<String, String> {
.map_err(|e| e.to_string())?;
let inject_rows: Vec<(Option<String>, 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<String, String> {
let project_id = ctx
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(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<String, String> {
.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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
@ -962,8 +1037,7 @@ fn get_project_file(project_id: &str, relative_path: &str) -> Result<String, Str
}
let root = resolve_project_root(project_id)?;
let path = root.join(relative_path);
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))
}
/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤)
@ -974,16 +1048,22 @@ pub fn resolve_project_root(project_id: &str) -> Result<std::path::PathBuf, Stri
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row
.map_err(|_| format!("项目 {} 不存在", project_id))?;
let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
let distro: String = conn
.query_row("SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0))
.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(std::path::PathBuf::from(format!("\\\\wsl.localhost\\{}\\{}", distro, inner)))
Ok(std::path::PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
Ok(std::path::PathBuf::from(win))
@ -1048,7 +1128,10 @@ fn list_envoy_returns() -> Result<String, String> {
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<String, String
&format!("notes 档案双写失败(DB 已保存,backfill_notes_archive 可自愈): {e}"),
None,
);
return Ok("笔记已保存 ✅(项目档案双写失败已记日志,可调 backfill_notes_archive 自愈)".to_string());
return Ok(
"笔记已保存 ✅(项目档案双写失败已记日志,可调 backfill_notes_archive 自愈)"
.to_string(),
);
}
Ok("笔记已保存 ✅(已同步项目 .blueprint/notes-archive.jsonl)".to_string())
}
@ -1088,7 +1174,13 @@ fn append_project_note(project_id: &str, content: &str) -> Result<String, String
static NOTES_ARCHIVE_LOCK: std::sync::Mutex<()> = 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<String, String>
)
.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<String, Vec<(i64, String)>> = std::collections::BTreeMap::new();
let mut by_project: std::collections::BTreeMap<String, Vec<(i64, String)>> =
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<String, String>
// 按「解析后的落盘路径」归并——同路径重复登记(mcp-single-server 已知遗留)指向同一文件,
// 若按 project_id 逐项目整写会互相覆盖(2026-09-11 真机回填实锤:后写覆盖先写丢 20 条)
let mut by_path: std::collections::BTreeMap<std::path::PathBuf, (Vec<String>, Vec<(i64, String)>)> =
std::collections::BTreeMap::new();
let mut by_path: std::collections::BTreeMap<
std::path::PathBuf,
(Vec<String>, 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<String, String>
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<String, String>
let offset = format!("-{} days", days);
let rows: Vec<(String, Option<i64>, Option<f64>, 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<String, String>
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<String, String>
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<String, String> {
)
.map_err(|e| e.to_string())?;
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, Option<String>)> =
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<String>,
Option<String>,
Option<String>,
Option<String>,
)> = 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<String, String> {
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<String, String> {
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<String, String> {
));
}
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 批量模式)"
);
}

View File

@ -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<number, { label: string; desc: string; color: string }>
3: { label: "第 3 层", desc: "局部内置", color: "bg-gray-50 border-gray-200 text-gray-700" },
};
const TOOLCHAIN_STATUS_META: Record<string, { label: string; color: string }> = {
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<EnvToolItem[]>([]);
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<Record<number, EnvToolItem[]>>((acc, layer) => {
const layerItems = items.filter((item) => item.layer === layer);
@ -96,10 +139,10 @@ export function ProjectEnvModal({ project, onClose }: Props) {
}, {});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-[560px] max-h-[80vh] flex flex-col overflow-hidden">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 sm:p-6">
<div className="flex h-[88vh] max-h-[900px] w-[92vw] max-w-5xl flex-col overflow-hidden rounded-xl bg-white shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div className="flex items-center justify-between border-b border-gray-100 px-8 py-5">
<div>
<h2 className="font-semibold text-gray-800">环境工具</h2>
<p className="text-xs text-gray-400 mt-0.5">{project.name}</p>
@ -108,7 +151,7 @@ export function ProjectEnvModal({ project, onClose }: Props) {
</div>
{/* Scan buttons */}
<div className="flex gap-2 px-6 py-3 border-b border-gray-50 bg-gray-50">
<div className="flex gap-2 border-b border-gray-50 bg-gray-50 px-8 py-4">
<span className="text-xs text-gray-500 self-center mr-1">扫描目录:</span>
{project.win_path && (
<button
@ -134,7 +177,14 @@ export function ProjectEnvModal({ project, onClose }: Props) {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-8 py-6">
<ToolchainCard
status={toolchain}
loading={toolchainLoading}
error={toolchainError}
preparing={preparing}
onPrepare={handlePrepareToolchain}
/>
{isLoading && !scanned ? (
<p className="text-sm text-gray-400 text-center py-8">加载中…</p>
) : 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 <div className="mb-4 rounded-lg border border-gray-100 p-3 text-xs text-gray-400">正在检查项目工具链…</div>;
}
if (error || !status) {
return (
<div className="mb-4 rounded-lg border border-red-100 bg-red-50 p-3 text-xs text-red-600">
工具链状态不可用:{String(error ?? "未返回状态")}
</div>
);
}
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 (
<section className="mb-5 rounded-xl border border-slate-200 bg-slate-50/70 p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-slate-800">项目工具链契约</h3>
<p className="mt-0.5 break-all text-[11px] text-slate-400">{status.project_root}</p>
</div>
{canPrepare && (
<button
type="button"
onClick={onPrepare}
disabled={preparing}
className="shrink-0 rounded-lg bg-slate-800 px-3 py-1.5 text-xs text-white hover:bg-slate-700 disabled:opacity-50"
>
{preparing ? "准备中…" : "准备项目工具链"}
</button>
)}
</div>
<div className="space-y-2">
<ToolchainRow label="Node" item={status.node} />
<ToolchainRow label="pnpm" item={status.pnpm} />
<div className="flex items-center justify-between gap-3 rounded-lg bg-white px-3 py-2 text-xs">
<span className="font-medium text-slate-700">mise resolver</span>
<div className="min-w-0 text-right text-slate-500">
<span>{status.resolver.status}</span>
{status.resolver.actual_version && <span className="ml-2 font-mono">{status.resolver.actual_version}</span>}
{status.resolver.executable && <p className="truncate text-[10px] text-slate-400">{status.resolver.executable}</p>}
</div>
</div>
</div>
{status.conflicts.length > 0 && (
<p className="mt-3 text-xs text-red-600">冲突声明:{status.conflicts.join("、")}。炼境不会替项目选择版本。</p>
)}
{needsPrepare && !canPrepare && status.conflicts.length === 0 && (
<p className="mt-3 text-xs text-amber-700">
{!status.resolver.configured
? "项目尚未配置 mise.toml,请先应用工具链能力包。"
: "本机尚未安装 mise,请先完成一次用户级安装。"}
</p>
)}
</section>
);
}
function ToolchainRow({ label, item }: { label: string; item: RuntimeToolStatus }) {
const meta = TOOLCHAIN_STATUS_META[item.status] ?? TOOLCHAIN_STATUS_META.unavailable;
return (
<div className="rounded-lg bg-white px-3 py-2 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-slate-700">{label}</span>
<span className={`rounded border px-2 py-0.5 ${meta.color}`}>{meta.label}</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-2 text-[11px] text-slate-500">
<span>期望:<span className="font-mono">{item.declared_version ?? "未声明"}</span></span>
<span>实际:<span className="font-mono">{item.actual_version ?? "不可用"}</span></span>
</div>
<p className="mt-1 truncate text-[10px] text-slate-400">
{item.declared_source ?? "无声明来源"}{item.executable ? ` · ${item.executable}` : ""}
</p>
{item.recommendation && <p className="mt-1 text-[11px] text-slate-500">{item.recommendation}</p>}
</div>
);
}
// ── 工具行 ────────────────────────────────────────────────────────────────────
function EnvToolRow({ item, onRemove }: { item: EnvToolItem; onRemove: () => void }) {

View File

@ -321,6 +321,46 @@ export const getProjectEnvTools = (projectId: string) =>
export const saveProjectEnvTools = (projectId: string, items: SaveEnvToolInput[]) =>
invoke<void>('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<ToolchainStatus>('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<PrepareToolchainResult>('prepare_toolchain', { projectId, confirmed });
// ── Shell ─────────────────────────────────────────────────────────────────────
export const openEditor = (projectId: string, env: "wsl" | "win", editorPath: string) =>