Compare commits
4 Commits
46f75292ab
...
e57d612212
| Author | SHA1 | Date | |
|---|---|---|---|
| e57d612212 | |||
|
|
ccbd2b0e23 | ||
|
|
8376bcbfd2 | ||
|
|
ead35ad12a |
@ -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
|
||||
|
||||
139
.blueprint/modules/toolchain-capability-pack.md
Normal file
139
.blueprint/modules/toolchain-capability-pack.md
Normal 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 以消费者角色完成最终闭环。
|
||||
@ -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 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 映射;这些都属于项目接入与炼境控制面。
|
||||
|
||||
@ -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 的本地工作;不得伪造诊断结果。
|
||||
|
||||
@ -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 永不覆盖。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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 的投影
|
||||
|
||||
@ -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; }
|
||||
|
||||
4
src-tauri/resources/onboarding/mise.toml.tmpl
Normal file
4
src-tauri/resources/onboarding/mise.toml.tmpl
Normal file
@ -0,0 +1,4 @@
|
||||
# 炼境工具链能力包:版本事实仍由 .nvmrc 与 package.json#packageManager 持有。
|
||||
# 本文件只让 mise 读取这些标准声明,不重复保存 Node/pnpm 版本。
|
||||
[settings]
|
||||
idiomatic_version_file_enable_tools = ["node", "pnpm"]
|
||||
@ -36,4 +36,5 @@ pub mod spaces;
|
||||
pub mod ssh_config;
|
||||
pub mod tags;
|
||||
pub mod templates;
|
||||
pub mod toolchain;
|
||||
pub mod user_todos;
|
||||
|
||||
@ -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 侧检查段
|
||||
|
||||
960
src-tauri/src/commands/toolchain.rs
Normal file
960
src-tauri/src/commands/toolchain.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 批量模式)"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user