feat(onboard/parity): 写入汇 WriteSink 与写入前计划 plan_onboarding(P8c,待独立审查)
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
一键接入改写子项目受版本管理的文件,人在确认前必须看得到改什么;计划与 apply 走同一段 分发代码、只换写入汇,所以「看到的」就是「将写的」。 - lib.rs:WriteSink trait(read/write,内容忽略行尾未变不写)与 DiskSink - 接入包模板分发、蓝图规则同步、项目身份与 MCP 投影(context、settings 兼容条目、 liangjing、Codex 托管块)全部改经写入汇;原入口改为 DiskSink 薄包装,行为不变 - onboard/plan.rs:PlanSink 只记录不落盘、后续步骤读到前面步骤的待写内容; write_project_files 为 plan 与 apply 共用的写入序列;plan_onboarding 给出逐文件 create/update/noop、有上限的 diff、混合文件预警(P10 前移到写入前)、身份冲突、 手写条目与共享项目键阻断项,并对计划算 digest - mcp_inject:plan_onboarding 生产入口与本机侧预计动作(token、稳定入口、三端只读 状态、Codex 信任),只读、不起子进程 - 新增测试:空仓库全 create 且目录快照字节不变、apply 后两次计划全 noop 且 digest 相同、CRLF 判 noop、内容/本机状态变化 digest 变化、混合文件与身份冲突阻断、 手写 Codex 条目与共享键阻断、记录汇覆盖与撤销、diff 截断、Codex 信任解析; cargo test 307 passed Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a85f3d9bd0
commit
ef2c7fd593
@ -1270,33 +1270,55 @@ pub(crate) const BLUEPRINT_MANAGED_END: &str = "<!-- BLUEPRINT_MANAGED_END -->";
|
|||||||
/// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块:
|
/// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块:
|
||||||
/// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。
|
/// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。
|
||||||
/// 返回动作:created | updated | unchanged | appended
|
/// 返回动作:created | updated | unchanged | appended
|
||||||
|
#[cfg(test)]
|
||||||
fn upsert_managed_block(
|
fn upsert_managed_block(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
template: &str,
|
template: &str,
|
||||||
header_when_created: &str,
|
header_when_created: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
upsert_managed_block_with(path, template, header_when_created, &crate::DiskSink)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_managed_block_with(
|
||||||
|
path: &Path,
|
||||||
|
template: &str,
|
||||||
|
header_when_created: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let label = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
let label = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
|
||||||
if !path.exists() {
|
let Some(content) = sink
|
||||||
|
.read(path)
|
||||||
|
.map_err(|e| format!("读取 {label} 失败: {e}"))?
|
||||||
|
else {
|
||||||
let content = format!("{header_when_created}\n\n{template}\n");
|
let content = format!("{header_when_created}\n\n{template}\n");
|
||||||
std::fs::write(path, content).map_err(|e| format!("创建 {label} 失败: {e}"))?;
|
sink.write(path, &content)
|
||||||
|
.map_err(|e| format!("创建 {label} 失败: {e}"))?;
|
||||||
return Ok("created".to_string());
|
return Ok("created".to_string());
|
||||||
}
|
};
|
||||||
let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?;
|
|
||||||
if content.contains(BLUEPRINT_MANAGED_START) {
|
if content.contains(BLUEPRINT_MANAGED_START) {
|
||||||
let new_content = replace_managed_section(&content, template);
|
let new_content = replace_managed_section(&content, template);
|
||||||
let written = crate::write_if_content_changed(path, &new_content)
|
let written = sink
|
||||||
|
.write(path, &new_content)
|
||||||
.map_err(|e| format!("更新 {label} 失败: {e}"))?;
|
.map_err(|e| format!("更新 {label} 失败: {e}"))?;
|
||||||
Ok(if written { "updated" } else { "unchanged" }.to_string())
|
Ok(if written { "updated" } else { "unchanged" }.to_string())
|
||||||
} else {
|
} else {
|
||||||
let new_content = format!("{}\n\n{template}\n", content.trim_end());
|
let new_content = format!("{}\n\n{template}\n", content.trim_end());
|
||||||
std::fs::write(path, new_content).map_err(|e| format!("追加 {label} 失败: {e}"))?;
|
sink.write(path, &new_content)
|
||||||
|
.map_err(|e| format!("追加 {label} 失败: {e}"))?;
|
||||||
Ok("appended".to_string())
|
Ok("appended".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String> {
|
pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String> {
|
||||||
let root = Path::new(&project_path);
|
sync_blueprint_rules_with(Path::new(&project_path), &crate::DiskSink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sync_blueprint_rules 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。
|
||||||
|
pub(crate) fn sync_blueprint_rules_with(
|
||||||
|
root: &Path,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<SyncResult, String> {
|
||||||
let bp_dir = root.join(".blueprint");
|
let bp_dir = root.join(".blueprint");
|
||||||
let project_name = root
|
let project_name = root
|
||||||
.file_name()
|
.file_name()
|
||||||
@ -1304,28 +1326,29 @@ pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String>
|
|||||||
.unwrap_or("Project")
|
.unwrap_or("Project")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// 确保 .blueprint/ 存在
|
// 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写;
|
||||||
std::fs::create_dir_all(&bp_dir).map_err(|e| format!("创建 .blueprint/ 失败: {e}"))?;
|
// .blueprint/ 由写入汇按需创建)
|
||||||
|
let conventions_written = sink
|
||||||
// 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写)
|
.write(
|
||||||
let conventions_written = crate::write_if_content_changed(
|
|
||||||
&bp_dir.join("CONVENTIONS.md"),
|
&bp_dir.join("CONVENTIONS.md"),
|
||||||
&effective_conventions_content(),
|
&effective_conventions_content(),
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?;
|
.map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?;
|
||||||
|
|
||||||
// 2. AGENTS.md:完整蓝图工作流(跨工具单一来源,Qwen/Cursor/Copilot 均读)
|
// 2. AGENTS.md:完整蓝图工作流(跨工具单一来源,Qwen/Cursor/Copilot 均读)
|
||||||
let agents_action = upsert_managed_block(
|
let agents_action = upsert_managed_block_with(
|
||||||
&root.join("AGENTS.md"),
|
&root.join("AGENTS.md"),
|
||||||
&agents_managed_template(),
|
&agents_managed_template(),
|
||||||
&format!("# AGENTS.md — {project_name}\n\n> 跨工具 AI 上下文入口(Claude / Qwen / Cursor / Copilot 等均读此文件)。"),
|
&format!("# AGENTS.md — {project_name}\n\n> 跨工具 AI 上下文入口(Claude / Qwen / Cursor / Copilot 等均读此文件)。"),
|
||||||
|
sink,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 3. CLAUDE.md:薄引用(Claude 经 @AGENTS.md 接入单一来源)
|
// 3. CLAUDE.md:薄引用(Claude 经 @AGENTS.md 接入单一来源)
|
||||||
let claude_action = upsert_managed_block(
|
let claude_action = upsert_managed_block_with(
|
||||||
&root.join("CLAUDE.md"),
|
&root.join("CLAUDE.md"),
|
||||||
&claude_managed_template(),
|
&claude_managed_template(),
|
||||||
&format!("# {project_name}"),
|
&format!("# {project_name}"),
|
||||||
|
sink,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(SyncResult {
|
Ok(SyncResult {
|
||||||
@ -3064,7 +3087,11 @@ mod tests {
|
|||||||
let before = std::fs::read(&f).unwrap();
|
let before = std::fs::read(&f).unwrap();
|
||||||
let a = upsert_managed_block(&f, tpl_v2, "# 头部").unwrap();
|
let a = upsert_managed_block(&f, tpl_v2, "# 头部").unwrap();
|
||||||
assert_eq!(a, "unchanged");
|
assert_eq!(a, "unchanged");
|
||||||
assert_eq!(std::fs::read(&f).unwrap(), before, "CRLF 原样保留,未被 LF 重写");
|
assert_eq!(
|
||||||
|
std::fs::read(&f).unwrap(),
|
||||||
|
before,
|
||||||
|
"CRLF 原样保留,未被 LF 重写"
|
||||||
|
);
|
||||||
|
|
||||||
// 3. 存在但无 MANAGED 区 → appended(既有内容保留)
|
// 3. 存在但无 MANAGED 区 → appended(既有内容保留)
|
||||||
let g = dir.join("CLAUDE.md");
|
let g = dir.join("CLAUDE.md");
|
||||||
|
|||||||
@ -55,17 +55,19 @@ impl TemplateResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
|
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
|
||||||
fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult {
|
fn dispatch_one(
|
||||||
|
root: &Path,
|
||||||
|
relative: &str,
|
||||||
|
content: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> TemplateResult {
|
||||||
let target = root.join(relative);
|
let target = root.join(relative);
|
||||||
if target.exists() {
|
match sink.read(&target) {
|
||||||
return TemplateResult::new(relative, "skipped", "已存在,未覆盖");
|
Ok(Some(_)) => return TemplateResult::new(relative, "skipped", "已存在,未覆盖"),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")),
|
||||||
}
|
}
|
||||||
if let Some(parent) = target.parent() {
|
match sink.write(&target, content) {
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
||||||
return TemplateResult::new(relative, "failed", format!("建目录失败: {e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match std::fs::write(&target, content) {
|
|
||||||
Ok(_) => TemplateResult::new(relative, "written", "已写入"),
|
Ok(_) => TemplateResult::new(relative, "written", "已写入"),
|
||||||
Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")),
|
Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")),
|
||||||
}
|
}
|
||||||
@ -73,20 +75,20 @@ fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult {
|
|||||||
|
|
||||||
/// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。
|
/// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。
|
||||||
/// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。
|
/// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。
|
||||||
/// 比较前归一化行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。
|
/// 比较忽略行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。
|
||||||
fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> TemplateResult {
|
fn dispatch_fully_managed(
|
||||||
|
root: &Path,
|
||||||
|
relative: &str,
|
||||||
|
content: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> TemplateResult {
|
||||||
let target = root.join(relative);
|
let target = root.join(relative);
|
||||||
if !target.exists() {
|
if !matches!(sink.read(&target), Ok(Some(_))) {
|
||||||
return dispatch_one(root, relative, content);
|
return dispatch_one(root, relative, content, sink);
|
||||||
}
|
}
|
||||||
let normalize = |s: &str| s.replace("\r\n", "\n");
|
match sink.write(&target, content) {
|
||||||
if let Ok(existing) = std::fs::read_to_string(&target) {
|
Ok(false) => TemplateResult::new(relative, "skipped", "内容已是最新模板"),
|
||||||
if normalize(&existing) == normalize(content) {
|
Ok(true) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"),
|
||||||
return TemplateResult::new(relative, "skipped", "内容已是最新模板");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match std::fs::write(&target, content) {
|
|
||||||
Ok(_) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"),
|
|
||||||
Err(e) => TemplateResult::new(relative, "failed", format!("覆盖失败: {e}")),
|
Err(e) => TemplateResult::new(relative, "failed", format!("覆盖失败: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,7 +109,7 @@ fn node_pnpm_contract_present(root: &Path) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult {
|
fn dispatch_toolchain_resolver(root: &Path, sink: &dyn crate::WriteSink) -> TemplateResult {
|
||||||
if !node_pnpm_contract_present(root) {
|
if !node_pnpm_contract_present(root) {
|
||||||
return TemplateResult::new(
|
return TemplateResult::new(
|
||||||
"mise.toml",
|
"mise.toml",
|
||||||
@ -115,7 +117,7 @@ fn dispatch_toolchain_resolver(root: &Path) -> TemplateResult {
|
|||||||
"非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包",
|
"非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
dispatch_one(root, "mise.toml", TMPL_MISE)
|
dispatch_one(root, "mise.toml", TMPL_MISE, sink)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。
|
/// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。
|
||||||
@ -135,19 +137,22 @@ fn block_version(text: &str) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 版本化分发(三态):不存在→写全新;有标记→原位升级块;无标记→末尾追加块。
|
/// 版本化分发(三态):不存在→写全新;有标记→原位升级块;无标记→末尾追加块。
|
||||||
fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateResult {
|
fn dispatch_versioned(
|
||||||
|
root: &Path,
|
||||||
|
relative: &str,
|
||||||
|
template: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> TemplateResult {
|
||||||
let target = root.join(relative);
|
let target = root.join(relative);
|
||||||
let tpl_block = match extract_managed_block(template) {
|
let tpl_block = match extract_managed_block(template) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => return dispatch_one(root, relative, template), // 模板无标记,退化为旧行为
|
None => return dispatch_one(root, relative, template, sink), // 模板无标记,退化为旧行为
|
||||||
};
|
};
|
||||||
let tpl_ver = block_version(tpl_block).unwrap_or("?");
|
let tpl_ver = block_version(tpl_block).unwrap_or("?");
|
||||||
|
|
||||||
if !target.exists() {
|
let content = match sink.read(&target) {
|
||||||
return dispatch_one(root, relative, template);
|
Ok(Some(c)) => c,
|
||||||
}
|
Ok(None) => return dispatch_one(root, relative, template, sink),
|
||||||
let content = match std::fs::read_to_string(&target) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")),
|
Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -174,7 +179,7 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
match std::fs::write(&target, new_content) {
|
match sink.write(&target, &new_content) {
|
||||||
Ok(_) => TemplateResult::new(relative, action, detail),
|
Ok(_) => TemplateResult::new(relative, action, detail),
|
||||||
Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")),
|
Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")),
|
||||||
}
|
}
|
||||||
@ -184,33 +189,50 @@ fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateRe
|
|||||||
/// project_path 为项目根(WSL 项目为 \\wsl.localhost\... UNC 路径,可直接写)。
|
/// project_path 为项目根(WSL 项目为 \\wsl.localhost\... UNC 路径,可直接写)。
|
||||||
pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
|
pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
|
||||||
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
|
||||||
|
dispatch_templates_with(root, &crate::DiskSink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// dispatch_templates 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。
|
||||||
|
pub(crate) fn dispatch_templates_with(
|
||||||
|
root: &Path,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Vec<TemplateResult> {
|
||||||
vec![
|
vec![
|
||||||
dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS),
|
dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS, sink),
|
||||||
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK),
|
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK, sink),
|
||||||
dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW),
|
dispatch_versioned(
|
||||||
dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY),
|
root,
|
||||||
|
"docs/ai-context/git-workflow.md",
|
||||||
|
TMPL_GIT_WORKFLOW,
|
||||||
|
sink,
|
||||||
|
),
|
||||||
|
dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY, sink),
|
||||||
dispatch_fully_managed(
|
dispatch_fully_managed(
|
||||||
root,
|
root,
|
||||||
".claude/hooks/blueprint-gate.cjs",
|
".claude/hooks/blueprint-gate.cjs",
|
||||||
TMPL_BLUEPRINT_GATE,
|
TMPL_BLUEPRINT_GATE,
|
||||||
|
sink,
|
||||||
),
|
),
|
||||||
dispatch_fully_managed(
|
dispatch_fully_managed(
|
||||||
root,
|
root,
|
||||||
".agents/skills/lian-jing/SKILL.md",
|
".agents/skills/lian-jing/SKILL.md",
|
||||||
TMPL_LIANJING_SKILL,
|
TMPL_LIANJING_SKILL,
|
||||||
|
sink,
|
||||||
),
|
),
|
||||||
// Claude Code 不读 .agents/skills,同一模板投影一份(单一来源,双投影)。
|
// Claude Code 不读 .agents/skills,同一模板投影一份(单一来源,双投影)。
|
||||||
dispatch_fully_managed(
|
dispatch_fully_managed(
|
||||||
root,
|
root,
|
||||||
".claude/skills/lian-jing/SKILL.md",
|
".claude/skills/lian-jing/SKILL.md",
|
||||||
TMPL_LIANJING_SKILL,
|
TMPL_LIANJING_SKILL,
|
||||||
|
sink,
|
||||||
),
|
),
|
||||||
dispatch_fully_managed(
|
dispatch_fully_managed(
|
||||||
root,
|
root,
|
||||||
".lian-jing/capability-packs.json",
|
".lian-jing/capability-packs.json",
|
||||||
TMPL_CAPABILITY_PACKS,
|
TMPL_CAPABILITY_PACKS,
|
||||||
|
sink,
|
||||||
),
|
),
|
||||||
dispatch_toolchain_resolver(root),
|
dispatch_toolchain_resolver(root, sink),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,7 +244,7 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
|
|||||||
|
|
||||||
/// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED,
|
/// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED,
|
||||||
/// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。
|
/// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。
|
||||||
const MANAGED_BLOCK_FILES: [&str; 3] =
|
pub(crate) const MANAGED_BLOCK_FILES: [&str; 3] =
|
||||||
["AGENTS.md", "docs/ai-context/git-workflow.md", "CLAUDE.md"];
|
["AGENTS.md", "docs/ai-context/git-workflow.md", "CLAUDE.md"];
|
||||||
|
|
||||||
/// 比较块外内容时整体剔除的托管块(开始标记前缀,结束标记)。
|
/// 比较块外内容时整体剔除的托管块(开始标记前缀,结束标记)。
|
||||||
@ -266,7 +288,7 @@ fn outside_managed_differs(head: Option<&str>, worktree: &str) -> bool {
|
|||||||
|
|
||||||
/// 目标文件在 git 中是否有托管块之外的未提交改动(未跟踪文件带块外内容也算)。
|
/// 目标文件在 git 中是否有托管块之外的未提交改动(未跟踪文件带块外内容也算)。
|
||||||
/// 非 git 仓库、git 不可用或命令失败一律返回 false:只做提示,探测失败不打扰调用方。
|
/// 非 git 仓库、git 不可用或命令失败一律返回 false:只做提示,探测失败不打扰调用方。
|
||||||
fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool {
|
pub(crate) fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool {
|
||||||
// --no-optional-locks:后台探测不抢 index.lock,避免与 agent 同时进行的提交冲突
|
// --no-optional-locks:后台探测不抢 index.lock,避免与 agent 同时进行的提交冲突
|
||||||
let status = crate::silent_cmd("git")
|
let status = crate::silent_cmd("git")
|
||||||
.args([
|
.args([
|
||||||
@ -298,7 +320,7 @@ fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str)
|
|||||||
outside_managed_differs(head.as_deref(), worktree)
|
outside_managed_differs(head.as_deref(), worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mixed_file_warning(relative: &str) -> String {
|
pub(crate) fn mixed_file_warning(relative: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{relative}:块外有未提交改动,升级后将成为混合文件——本次只改写了其中的炼境托管块,块外内容未动;\
|
"{relative}:块外有未提交改动,升级后将成为混合文件——本次只改写了其中的炼境托管块,块外内容未动;\
|
||||||
提交前先确认块外改动的归属(不得用字符串手术拆分暂存),无法整体提交时交人定案。"
|
提交前先确认块外改动的归属(不得用字符串手术拆分暂存),无法整体提交时交人定案。"
|
||||||
@ -2242,19 +2264,19 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&base).unwrap();
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
|
||||||
// 不存在 → written
|
// 不存在 → written
|
||||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink);
|
||||||
assert_eq!(r.action, "written");
|
assert_eq!(r.action, "written");
|
||||||
|
|
||||||
// 一致 → skipped(CRLF 检出也视为一致)
|
// 一致 → skipped(CRLF 检出也视为一致)
|
||||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink);
|
||||||
assert_eq!(r.action, "skipped");
|
assert_eq!(r.action, "skipped");
|
||||||
std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap();
|
std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap();
|
||||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink);
|
||||||
assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖");
|
assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖");
|
||||||
|
|
||||||
// 不一致(旧版模板/被改动)→ 覆盖 updated
|
// 不一致(旧版模板/被改动)→ 覆盖 updated
|
||||||
std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap();
|
std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap();
|
||||||
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
|
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink);
|
||||||
assert_eq!(r.action, "updated");
|
assert_eq!(r.action, "updated");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(base.join("envoy.md")).unwrap(),
|
std::fs::read_to_string(base.join("envoy.md")).unwrap(),
|
||||||
@ -2305,7 +2327,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let first = dispatch_toolchain_resolver(&base);
|
let first = dispatch_toolchain_resolver(&base, &crate::DiskSink);
|
||||||
assert_eq!(first.action, "written");
|
assert_eq!(first.action, "written");
|
||||||
assert_eq!(check_toolchain_resolver(&base).state, VerifyState::Ready);
|
assert_eq!(check_toolchain_resolver(&base).state, VerifyState::Ready);
|
||||||
|
|
||||||
@ -2314,7 +2336,7 @@ mod tests {
|
|||||||
"[tasks.custom]\nrun = 'project-owned'\n",
|
"[tasks.custom]\nrun = 'project-owned'\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let second = dispatch_toolchain_resolver(&base);
|
let second = dispatch_toolchain_resolver(&base, &crate::DiskSink);
|
||||||
assert_eq!(second.action, "skipped");
|
assert_eq!(second.action, "skipped");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(base.join("mise.toml")).unwrap(),
|
std::fs::read_to_string(base.join("mise.toml")).unwrap(),
|
||||||
|
|||||||
@ -41,6 +41,41 @@ pub(crate) fn write_if_content_changed(
|
|||||||
std::fs::write(path, content).map(|()| true)
|
std::fs::write(path, content).map(|()| true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 接入写入汇(agent-client-parity P8c):接入包、蓝图规则、项目身份与 MCP 投影这些项目文件写入
|
||||||
|
/// 只经它读写。`DiskSink` 真实写入;onboard::plan 的记录汇只记录不落盘,并让后续步骤读到前面
|
||||||
|
/// 步骤的待写内容(同一文件被两步先后改写时,计划与实际写入算出同样的结果)。
|
||||||
|
pub(crate) trait WriteSink {
|
||||||
|
/// 读取当前内容(含本汇里尚未落盘的待写内容);文件不存在返回 Ok(None)。
|
||||||
|
fn read(&self, path: &std::path::Path) -> std::io::Result<Option<String>>;
|
||||||
|
/// 内容(忽略行尾)未变返回 Ok(false);否则写入(必要时先建父目录)并返回 Ok(true)。
|
||||||
|
fn write(&self, path: &std::path::Path, content: &str) -> std::io::Result<bool>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真实写入:内容不变不重写(write_if_content_changed),父目录按需创建。
|
||||||
|
pub(crate) struct DiskSink;
|
||||||
|
|
||||||
|
impl WriteSink for DiskSink {
|
||||||
|
fn read(&self, path: &std::path::Path) -> std::io::Result<Option<String>> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => Ok(Some(text)),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, path: &std::path::Path, content: &str) -> std::io::Result<bool> {
|
||||||
|
if let Some(existing) = self.read(path)? {
|
||||||
|
if same_text_ignoring_eol(&existing, content) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, content).map(|()| true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。
|
/// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。
|
||||||
/// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。
|
/// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。
|
||||||
pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
|
pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
|
||||||
|
|||||||
@ -168,20 +168,22 @@ fn settings_path(project_id: &str) -> Result<PathBuf, String> {
|
|||||||
.join("settings.json"))
|
.join("settings.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
|
|
||||||
Ok(project_root(project_id)?
|
|
||||||
.join(".claude")
|
|
||||||
.join("liangjing.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
|
fn codex_config_path(project_id: &str) -> Result<PathBuf, String> {
|
||||||
Ok(project_root(project_id)?.join(".codex").join("config.toml"))
|
Ok(project_root(project_id)?.join(".codex").join("config.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
|
fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
|
||||||
match std::fs::read_to_string(path) {
|
read_context_object_with(path, label, &crate::DiskSink)
|
||||||
Ok(content) if content.trim().is_empty() => Ok(json!({})),
|
}
|
||||||
Ok(content) => serde_json::from_str::<Value>(&content)
|
|
||||||
|
fn read_context_object_with(
|
||||||
|
path: &std::path::Path,
|
||||||
|
label: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
match sink.read(path) {
|
||||||
|
Ok(Some(content)) if content.trim().is_empty() => Ok(json!({})),
|
||||||
|
Ok(Some(content)) => serde_json::from_str::<Value>(&content)
|
||||||
.map_err(|error| format!("读取 {label} 失败({}): {error}", path.display()))
|
.map_err(|error| format!("读取 {label} 失败({}): {error}", path.display()))
|
||||||
.and_then(|value| {
|
.and_then(|value| {
|
||||||
if value.is_object() {
|
if value.is_object() {
|
||||||
@ -193,59 +195,135 @@ fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({})),
|
Ok(None) => Ok(json!({})),
|
||||||
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
|
Err(error) => Err(format!("读取 {label} 失败({}): {error}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 现有 JSON 文件与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
|
/// 现有 JSON 文本与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
|
||||||
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
|
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
|
||||||
fn json_file_equals(path: &std::path::Path, value: &Value) -> bool {
|
fn json_text_equals(existing: Option<&str>, value: &Value) -> bool {
|
||||||
std::fs::read_to_string(path)
|
existing
|
||||||
.ok()
|
|
||||||
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
|
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
|
||||||
.is_some_and(|existing| &existing == value)
|
.is_some_and(|existing| &existing == value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
|
/// 语义相同不写;否则以 pretty JSON 经写入汇写入(父目录由写入汇按需创建)。
|
||||||
if json_file_equals(path, value) {
|
fn write_json_with(
|
||||||
|
path: &std::path::Path,
|
||||||
|
value: &Value,
|
||||||
|
label: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let existing = sink
|
||||||
|
.read(path)
|
||||||
|
.map_err(|error| format!("读取 {label} 失败: {error}"))?;
|
||||||
|
if json_text_equals(existing.as_deref(), value) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
|
|
||||||
}
|
|
||||||
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
|
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
|
||||||
crate::write_if_content_changed(path, &content)
|
sink.write(path, &content)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(|error| format!("写入 {label} 失败: {error}"))
|
.map_err(|error| format!("写入 {label} 失败: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
|
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
|
||||||
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
|
/// 合并写入:保留未来字段与项目定制字段,只有炼境拥有的身份字段会被刷新。
|
||||||
|
#[cfg(test)]
|
||||||
fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> {
|
fn write_canonical_context_at(root: &std::path::Path, project_id: &str) -> Result<(), String> {
|
||||||
|
write_canonical_context_with(root, project_id, &crate::DiskSink)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write_canonical_context_with(
|
||||||
|
root: &std::path::Path,
|
||||||
|
project_id: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<(), String> {
|
||||||
let path = root.join(".lian-jing").join("context.json");
|
let path = root.join(".lian-jing").join("context.json");
|
||||||
let mut ctx = read_context_object(&path, "canonical 项目上下文")?;
|
let mut ctx = read_context_object_with(&path, "canonical 项目上下文", sink)?;
|
||||||
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
||||||
obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION));
|
obj.insert("schema_version".into(), json!(CONTEXT_SCHEMA_VERSION));
|
||||||
obj.insert("project_id".into(), json!(project_id));
|
obj.insert("project_id".into(), json!(project_id));
|
||||||
write_context_object(&path, &ctx, "canonical 项目上下文")
|
write_json_with(&path, &ctx, "canonical 项目上下文", sink)
|
||||||
}
|
|
||||||
|
|
||||||
fn write_canonical_context(project_id: &str) -> Result<(), String> {
|
|
||||||
let root = project_root(project_id)?;
|
|
||||||
write_canonical_context_at(&root, project_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。
|
/// 写旧 Claude 上下文投影。它不是项目身份的单一事实来源,保留是为了兼容已接入项目。
|
||||||
fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> {
|
pub(crate) fn write_liangjing_context_with(
|
||||||
let path = liangjing_path(project_id)?;
|
root: &std::path::Path,
|
||||||
let mut ctx = read_context_object(&path, "Claude 兼容上下文")?;
|
project_id: &str,
|
||||||
|
port: u16,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let path = root.join(".claude").join("liangjing.json");
|
||||||
|
let mut ctx = read_context_object_with(&path, "Claude 兼容上下文", sink)?;
|
||||||
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
let obj = ctx.as_object_mut().expect("ctx 必为 JSON 对象");
|
||||||
obj.insert("project_id".into(), json!(project_id));
|
obj.insert("project_id".into(), json!(project_id));
|
||||||
obj.insert("mcp_port".into(), json!(port));
|
obj.insert("mcp_port".into(), json!(port));
|
||||||
obj.remove("group_id");
|
obj.remove("group_id");
|
||||||
write_context_object(&path, &ctx, "Claude 兼容上下文")
|
write_json_with(&path, &ctx, "Claude 兼容上下文", sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容):
|
||||||
|
/// 无人值守默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。
|
||||||
|
pub(crate) fn write_settings_compat_with(
|
||||||
|
root: &std::path::Path,
|
||||||
|
server_url: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let path = root.join(".claude").join("settings.json");
|
||||||
|
let mut settings = match sink.read(&path) {
|
||||||
|
Ok(None) => json!({}),
|
||||||
|
Ok(Some(content)) if content.trim().is_empty() => json!({}),
|
||||||
|
Ok(Some(content)) => serde_json::from_str(&content).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"settings.json 格式错误,请手动修复后重试({}): {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
Err(e) => return Err(format!("读取 settings.json 失败: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let obj = settings
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or("settings.json 顶层必须是 JSON 对象")?;
|
||||||
|
|
||||||
|
// 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断)
|
||||||
|
obj.entry("permissions")
|
||||||
|
.or_insert(json!({"defaultMode": "bypassPermissions"}));
|
||||||
|
|
||||||
|
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
||||||
|
|
||||||
|
if let Some(map) = servers.as_object_mut() {
|
||||||
|
map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||||||
|
}
|
||||||
|
servers[MCP_KEY] = json!({
|
||||||
|
"type": "sse",
|
||||||
|
"url": server_url
|
||||||
|
});
|
||||||
|
write_json_with(&path, &settings, "settings.json", sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 项目侧 MCP 文件(canonical context、Claude 兼容投影、Codex 托管块)一次写齐;
|
||||||
|
/// inject 与 onboard::plan 共用同一序列,计划与实际写入的结果一致。返回 Codex 托管块结果。
|
||||||
|
pub(crate) fn write_project_mcp_files_with(
|
||||||
|
root: &std::path::Path,
|
||||||
|
project_id: &str,
|
||||||
|
port: u16,
|
||||||
|
server_url: &str,
|
||||||
|
header_helper: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
|
) -> Result<ClientOutcome, String> {
|
||||||
|
write_canonical_context_with(root, project_id, sink)?;
|
||||||
|
write_settings_compat_with(root, server_url, sink)?;
|
||||||
|
write_liangjing_context_with(root, project_id, port, sink)?;
|
||||||
|
Ok(upsert_codex_mcp_config_with(
|
||||||
|
&root.join(".codex").join("config.toml"),
|
||||||
|
server_url,
|
||||||
|
header_helper,
|
||||||
|
sink,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|reason| ClientOutcome::Failed { reason }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
|
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
|
||||||
@ -1347,16 +1425,7 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||||
if json_file_equals(path, value) {
|
write_json_with(path, value, "settings.json", &crate::DiskSink)
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
|
|
||||||
}
|
|
||||||
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
|
|
||||||
crate::write_if_content_changed(path, &content)
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| format!("写入 settings.json 失败: {}", e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, String> {
|
fn render_codex_block(server_url: &str, header_helper: &str) -> Result<String, String> {
|
||||||
@ -1417,13 +1486,21 @@ fn upsert_codex_mcp_config(
|
|||||||
path: &PathBuf,
|
path: &PathBuf,
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
header_helper: &str,
|
header_helper: &str,
|
||||||
|
) -> Result<ClientOutcome, String> {
|
||||||
|
upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_codex_mcp_config_with(
|
||||||
|
path: &std::path::Path,
|
||||||
|
server_url: &str,
|
||||||
|
header_helper: &str,
|
||||||
|
sink: &dyn crate::WriteSink,
|
||||||
) -> Result<ClientOutcome, String> {
|
) -> Result<ClientOutcome, String> {
|
||||||
let block = render_codex_block(server_url, header_helper)?;
|
let block = render_codex_block(server_url, header_helper)?;
|
||||||
let current = match std::fs::read_to_string(path) {
|
let current = sink
|
||||||
Ok(value) => value,
|
.read(path)
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
.map_err(|error| format!("读取 Codex config.toml 失败: {error}"))?
|
||||||
Err(error) => return Err(format!("读取 Codex config.toml 失败: {error}")),
|
.unwrap_or_default();
|
||||||
};
|
|
||||||
let (start, end) = (
|
let (start, end) = (
|
||||||
current.find(CODEX_MCP_MANAGED_START),
|
current.find(CODEX_MCP_MANAGED_START),
|
||||||
current.find(CODEX_MCP_MANAGED_END),
|
current.find(CODEX_MCP_MANAGED_END),
|
||||||
@ -1448,11 +1525,7 @@ fn upsert_codex_mcp_config(
|
|||||||
(None, None) => format!("{}\n{}", current.trim_end(), block),
|
(None, None) => format!("{}\n{}", current.trim_end(), block),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
sink.write(path, &next)
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.map_err(|error| format!("创建 .codex 目录失败: {error}"))?;
|
|
||||||
}
|
|
||||||
crate::write_if_content_changed(path, &next)
|
|
||||||
.map(|written| {
|
.map(|written| {
|
||||||
if written {
|
if written {
|
||||||
ClientOutcome::Applied
|
ClientOutcome::Applied
|
||||||
@ -1538,34 +1611,12 @@ fn inject_project_mcp_locked(
|
|||||||
)?;
|
)?;
|
||||||
ensure_header_shim()?;
|
ensure_header_shim()?;
|
||||||
let header_helper = shim_helper_command(&digest);
|
let header_helper = shim_helper_command(&digest);
|
||||||
write_canonical_context(project_id)?;
|
|
||||||
let path = settings_path(project_id)?;
|
|
||||||
let mut settings = read_json(&path)?;
|
|
||||||
|
|
||||||
let obj = settings
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or("settings.json 顶层必须是 JSON 对象")?;
|
|
||||||
|
|
||||||
// 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断)
|
|
||||||
obj.entry("permissions")
|
|
||||||
.or_insert(json!({"defaultMode": "bypassPermissions"}));
|
|
||||||
|
|
||||||
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
|
||||||
|
|
||||||
if let Some(map) = servers.as_object_mut() {
|
|
||||||
map.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
|
||||||
}
|
|
||||||
servers[MCP_KEY] = json!({
|
|
||||||
"type": "sse",
|
|
||||||
"url": server_url
|
|
||||||
});
|
|
||||||
|
|
||||||
write_json(&path, &settings)?;
|
|
||||||
|
|
||||||
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
|
||||||
write_liangjing_context(project_id, port)?;
|
|
||||||
|
|
||||||
let root = project_root(project_id)?;
|
let root = project_root(project_id)?;
|
||||||
|
write_canonical_context_with(&root, project_id, &crate::DiskSink)?;
|
||||||
|
write_settings_compat_with(&root, &server_url, &crate::DiskSink)?;
|
||||||
|
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
||||||
|
write_liangjing_context_with(&root, project_id, port, &crate::DiskSink)?;
|
||||||
|
|
||||||
let facts = project_facts(project_id, &root);
|
let facts = project_facts(project_id, &root);
|
||||||
let desired = DesiredMcp {
|
let desired = DesiredMcp {
|
||||||
port,
|
port,
|
||||||
@ -1636,6 +1687,166 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
|
||||||
|
|
||||||
|
/// 一键接入写入前计划(只读:不写文件、不起子进程)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn plan_onboarding(
|
||||||
|
project_id: &str,
|
||||||
|
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
||||||
|
let root = project_root(project_id)?;
|
||||||
|
let port = crate::mcp_server::read_port();
|
||||||
|
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||||||
|
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
||||||
|
let header_helper = shim_helper_command(&digest);
|
||||||
|
let project = crate::onboard::plan::ProjectWrite {
|
||||||
|
project_id,
|
||||||
|
port,
|
||||||
|
server_url: &server_url,
|
||||||
|
header_helper: &header_helper,
|
||||||
|
};
|
||||||
|
Ok(crate::onboard::plan::plan_onboarding(
|
||||||
|
&root,
|
||||||
|
&project,
|
||||||
|
plan_machine_steps(project_id, &root),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)、Codex 信任。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn plan_machine_steps(
|
||||||
|
project_id: &str,
|
||||||
|
root: &std::path::Path,
|
||||||
|
) -> Vec<crate::onboard::plan::MachineStep> {
|
||||||
|
use crate::onboard::plan::MachineStep;
|
||||||
|
let step = |step: &str, status: &str, detail: String| MachineStep {
|
||||||
|
step: step.into(),
|
||||||
|
status: status.into(),
|
||||||
|
detail,
|
||||||
|
};
|
||||||
|
let mut steps = vec![
|
||||||
|
if is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) {
|
||||||
|
step(
|
||||||
|
"token",
|
||||||
|
"noop",
|
||||||
|
"项目 MCP 认证 token 已在本机受保护存储".into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
step(
|
||||||
|
"token",
|
||||||
|
"apply",
|
||||||
|
"将在本机受保护存储生成项目 MCP 认证 token".into(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shim_step(),
|
||||||
|
];
|
||||||
|
for report in client_static_status(project_id) {
|
||||||
|
let name = match report.client {
|
||||||
|
ClientId::Claude => "claude",
|
||||||
|
ClientId::Codex => "codex",
|
||||||
|
ClientId::Dsh => "dsh",
|
||||||
|
};
|
||||||
|
steps.push(step(
|
||||||
|
name,
|
||||||
|
outcome_status(&report.outcome),
|
||||||
|
report.outcome.describe(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
steps.push(match codex_trusts(root) {
|
||||||
|
Some(true) => step("codex_trust", "noop", "Codex 已信任该目录".into()),
|
||||||
|
Some(false) => step(
|
||||||
|
"codex_trust",
|
||||||
|
"needs_human",
|
||||||
|
"Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)".into(),
|
||||||
|
),
|
||||||
|
None => step("codex_trust", "not_installed", "未发现 Codex 配置".into()),
|
||||||
|
});
|
||||||
|
steps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn shim_step() -> crate::onboard::plan::MachineStep {
|
||||||
|
let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) {
|
||||||
|
(Ok(dir), Ok(exe)) => {
|
||||||
|
let path = dir.join(MCP_HEADER_SHIM_NAME);
|
||||||
|
let expected = render_header_shim(&exe);
|
||||||
|
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
|
||||||
|
("noop", format!("稳定入口已是最新:{}", path.display()))
|
||||||
|
} else {
|
||||||
|
("apply", format!("将写入稳定入口:{}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Err(error), _) => ("unavailable", error),
|
||||||
|
(_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()),
|
||||||
|
};
|
||||||
|
crate::onboard::plan::MachineStep {
|
||||||
|
step: "shim".into(),
|
||||||
|
status: status.into(),
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只读判定结果 → 计划里的本机动作状态。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn outcome_status(outcome: &ClientOutcome) -> &'static str {
|
||||||
|
match outcome {
|
||||||
|
ClientOutcome::Converged => "noop",
|
||||||
|
ClientOutcome::Missing
|
||||||
|
| ClientOutcome::Applied
|
||||||
|
| ClientOutcome::Refreshed
|
||||||
|
| ClientOutcome::Removed
|
||||||
|
| ClientOutcome::Deferred => "apply",
|
||||||
|
ClientOutcome::NotInstalled => "not_installed",
|
||||||
|
ClientOutcome::NotApplicable { .. } => "not_applicable",
|
||||||
|
ClientOutcome::ManualKept => "manual_kept",
|
||||||
|
ClientOutcome::OtherProject { .. } => "other_project",
|
||||||
|
ClientOutcome::NeedsHuman { .. } => "needs_human",
|
||||||
|
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex 用户配置里是否把该目录(或其祖先目录)标为 trusted;未发现 Codex 配置返回 None。
|
||||||
|
/// 只读、只认 `[projects.'<路径>']` 段下的 `trust_level = "trusted"`(P4-0 H-P8-1:未信任目录不加载项目配置)。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn codex_trusts(root: &std::path::Path) -> Option<bool> {
|
||||||
|
let home = std::env::var_os("CODEX_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex"))
|
||||||
|
})?;
|
||||||
|
let text = std::fs::read_to_string(home.join("config.toml")).ok()?;
|
||||||
|
Some(codex_config_trusts(&text, &root.display().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn codex_config_trusts(config: &str, root: &str) -> bool {
|
||||||
|
let normalize = |path: &str| {
|
||||||
|
path.replace('/', "\\")
|
||||||
|
.trim_end_matches('\\')
|
||||||
|
.to_lowercase()
|
||||||
|
};
|
||||||
|
let root = normalize(root);
|
||||||
|
let mut section: Option<String> = None;
|
||||||
|
for line in config.lines().map(str::trim) {
|
||||||
|
if line.starts_with('[') {
|
||||||
|
section = line
|
||||||
|
.strip_prefix("[projects.")
|
||||||
|
.and_then(|rest| rest.strip_suffix(']'))
|
||||||
|
.map(|key| normalize(key.trim_matches(['\'', '"'])));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(key) = §ion else { continue };
|
||||||
|
let covers = &root == key || root.starts_with(&format!("{key}\\"));
|
||||||
|
let trusted = line.split_once('=').is_some_and(|(name, value)| {
|
||||||
|
name.trim() == "trust_level" && value.trim().trim_matches('"') == "trusted"
|
||||||
|
});
|
||||||
|
if covers && trusted {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// ── 三端客户端适配器(注册表见 onboard::clients::registry)──────────────────────
|
// ── 三端客户端适配器(注册表见 onboard::clients::registry)──────────────────────
|
||||||
|
|
||||||
pub(crate) struct ClaudeAdapter;
|
pub(crate) struct ClaudeAdapter;
|
||||||
@ -2092,7 +2303,7 @@ mod tests {
|
|||||||
std::fs::write(&path, client_written).unwrap();
|
std::fs::write(&path, client_written).unwrap();
|
||||||
let value: Value = serde_json::from_str(client_written).unwrap();
|
let value: Value = serde_json::from_str(client_written).unwrap();
|
||||||
write_json(&path, &value).unwrap();
|
write_json(&path, &value).unwrap();
|
||||||
write_context_object(&path, &value, "settings.json").unwrap();
|
write_json_with(&path, &value, "settings.json", &crate::DiskSink).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(&path).unwrap(),
|
std::fs::read_to_string(&path).unwrap(),
|
||||||
client_written,
|
client_written,
|
||||||
@ -2656,6 +2867,29 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_trust_reads_projects_table_including_ancestors() {
|
||||||
|
let config = "model = \"x\"\n\n[projects.'e:\\code-project\\enterprise-system']\ntrust_level = \"trusted\"\n\n[projects.\"D:\\Work\"]\ntrust_level = \"trusted\"\n\n[projects.'e:\\other']\ntrust_level = \"untrusted\"\n";
|
||||||
|
assert!(codex_config_trusts(
|
||||||
|
config,
|
||||||
|
"E:\\code-project\\enterprise-system"
|
||||||
|
));
|
||||||
|
assert!(codex_config_trusts(
|
||||||
|
config,
|
||||||
|
"E:/code-project/enterprise-system/"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
codex_config_trusts(config, "d:\\work\\sub\\proj"),
|
||||||
|
"祖先目录受信任"
|
||||||
|
);
|
||||||
|
assert!(!codex_config_trusts(config, "E:\\other"), "untrusted 不算");
|
||||||
|
assert!(
|
||||||
|
!codex_config_trusts(config, "E:\\code-project\\enterprise-system-wt"),
|
||||||
|
"前缀不是路径边界"
|
||||||
|
);
|
||||||
|
assert!(!codex_config_trusts(config, "C:\\new"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hard_failure_is_only_codex_failed() {
|
fn hard_failure_is_only_codex_failed() {
|
||||||
let report = |client, outcome| ClientReport::new(client, outcome);
|
let report = |client, outcome| ClientReport::new(client, outcome);
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
//! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。
|
//! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。
|
||||||
|
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod plan;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod fitness_tests {
|
mod fitness_tests {
|
||||||
|
|||||||
588
src-tauri/src/onboard/plan.rs
Normal file
588
src-tauri/src/onboard/plan.rs
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
//! 写入前计划(agent-client-parity P8c):用记录汇把一键接入会写的项目文件「演算」一遍,
|
||||||
|
//! 给出逐文件动作、有上限的 diff、混合文件预警与身份冲突,并对计划内容算 digest。
|
||||||
|
//!
|
||||||
|
//! 为什么:一键接入改写的是子项目受版本管理的文件,人在确认前必须看得到改什么;
|
||||||
|
//! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。
|
||||||
|
//! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。
|
||||||
|
|
||||||
|
use crate::WriteSink;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct PlanSink {
|
||||||
|
pending: RefCell<BTreeMap<PathBuf, String>>,
|
||||||
|
touched: RefCell<Vec<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WriteSink for PlanSink {
|
||||||
|
fn read(&self, path: &Path) -> std::io::Result<Option<String>> {
|
||||||
|
if let Some(content) = self.pending.borrow().get(path) {
|
||||||
|
return Ok(Some(content.clone()));
|
||||||
|
}
|
||||||
|
read_disk(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, path: &Path, content: &str) -> std::io::Result<bool> {
|
||||||
|
{
|
||||||
|
let mut touched = self.touched.borrow_mut();
|
||||||
|
if !touched.iter().any(|seen| seen == path) {
|
||||||
|
touched.push(path.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(current) = self.read(path)? {
|
||||||
|
if crate::same_text_ignoring_eol(¤t, content) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 写回与磁盘一致(先改后又改回)时撤销待写,避免把「无变化」报成 update
|
||||||
|
let unchanged_on_disk =
|
||||||
|
read_disk(path)?.is_some_and(|disk| crate::same_text_ignoring_eol(&disk, content));
|
||||||
|
let mut pending = self.pending.borrow_mut();
|
||||||
|
if unchanged_on_disk {
|
||||||
|
pending.remove(path);
|
||||||
|
} else {
|
||||||
|
pending.insert(path.to_path_buf(), content.to_string());
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_disk(path: &Path) -> std::io::Result<Option<String>> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => Ok(Some(text)),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FileAction {
|
||||||
|
Create,
|
||||||
|
Update,
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PlannedFile {
|
||||||
|
/// 相对项目根,统一用 /
|
||||||
|
pub path: String,
|
||||||
|
pub action: FileAction,
|
||||||
|
/// update 时的行级差异(有上限,超出截断并标注);create 时为新文件概要
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub diff: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub warning: Option<String>,
|
||||||
|
/// 计划写入内容的 sha256(noop 为当前内容的 sha256),进入 digest
|
||||||
|
pub content_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BlockReason {
|
||||||
|
/// 托管块文件块外有未提交改动,本次又要改写托管块
|
||||||
|
MixedFile,
|
||||||
|
/// .lian-jing/context.json 已属于别的 project_id
|
||||||
|
IdentityConflict,
|
||||||
|
/// 客户端已有手写同名配置(炼境不覆盖)
|
||||||
|
ManualEntry,
|
||||||
|
/// 生效的客户端条目属于别的项目(共享项目键)
|
||||||
|
SharedKey,
|
||||||
|
/// 需要人工处理或无法确认(读失败、标记错序等)
|
||||||
|
NeedsHuman,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BlockedItem {
|
||||||
|
pub target: String,
|
||||||
|
pub reason: BlockReason,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置、Codex 信任等。
|
||||||
|
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | needs_human | unavailable。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MachineStep {
|
||||||
|
pub step: String,
|
||||||
|
pub status: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OnboardingPlan {
|
||||||
|
pub project_id: String,
|
||||||
|
pub files: Vec<PlannedFile>,
|
||||||
|
pub machine_changes: Vec<MachineStep>,
|
||||||
|
pub blocked: Vec<BlockedItem>,
|
||||||
|
/// 覆盖项目文件新内容哈希、本机动作与阻断项;apply 须带回同一 digest
|
||||||
|
pub digest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接入要写的项目侧连接参数(与 inject 同源)。
|
||||||
|
pub struct ProjectWrite<'a> {
|
||||||
|
pub project_id: &'a str,
|
||||||
|
pub port: u16,
|
||||||
|
pub server_url: &'a str,
|
||||||
|
pub header_helper: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与 MCP 投影),plan 与 apply 共用。
|
||||||
|
/// 返回分发过程中的失败与需人工项(不中断其余写入)。
|
||||||
|
pub(crate) fn write_project_files(
|
||||||
|
root: &Path,
|
||||||
|
project: &ProjectWrite,
|
||||||
|
sink: &dyn WriteSink,
|
||||||
|
) -> Vec<BlockedItem> {
|
||||||
|
let mut issues = Vec::new();
|
||||||
|
for result in crate::commands::onboarding::dispatch_templates_with(root, sink) {
|
||||||
|
if result.action == "failed" {
|
||||||
|
issues.push(BlockedItem {
|
||||||
|
target: result.name,
|
||||||
|
reason: BlockReason::NeedsHuman,
|
||||||
|
detail: result.detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) {
|
||||||
|
issues.push(BlockedItem {
|
||||||
|
target: ".blueprint/CONVENTIONS.md".into(),
|
||||||
|
reason: BlockReason::NeedsHuman,
|
||||||
|
detail: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
use super::clients::ClientOutcome;
|
||||||
|
match crate::mcp_inject::write_project_mcp_files_with(
|
||||||
|
root,
|
||||||
|
project.project_id,
|
||||||
|
project.port,
|
||||||
|
project.server_url,
|
||||||
|
project.header_helper,
|
||||||
|
sink,
|
||||||
|
) {
|
||||||
|
Ok(ClientOutcome::ManualKept) => issues.push(BlockedItem {
|
||||||
|
target: ".codex/config.toml".into(),
|
||||||
|
reason: BlockReason::ManualEntry,
|
||||||
|
detail: "已手工定义 [mcp_servers.lian-jing],炼境不会覆盖".into(),
|
||||||
|
}),
|
||||||
|
Ok(ClientOutcome::Failed { reason }) | Err(reason) => issues.push(BlockedItem {
|
||||||
|
target: ".lian-jing/context.json 等项目 MCP 文件".into(),
|
||||||
|
reason: BlockReason::NeedsHuman,
|
||||||
|
detail: reason,
|
||||||
|
}),
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作,生成计划与 digest。
|
||||||
|
/// 不写任何文件(目录快照前后字节一致)。
|
||||||
|
pub(crate) fn plan_onboarding(
|
||||||
|
root: &Path,
|
||||||
|
project: &ProjectWrite,
|
||||||
|
machine_changes: Vec<MachineStep>,
|
||||||
|
) -> OnboardingPlan {
|
||||||
|
let sink = PlanSink::default();
|
||||||
|
let mut blocked = identity_conflict(root, project.project_id)
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
blocked.extend(write_project_files(root, project, &sink));
|
||||||
|
|
||||||
|
let pending = sink.pending.borrow();
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for path in sink.touched.borrow().iter() {
|
||||||
|
let relative = relative_path(root, path);
|
||||||
|
let disk = read_disk(path).ok().flatten();
|
||||||
|
let (action, content, diff) = match (pending.get(path), &disk) {
|
||||||
|
(Some(new), None) => (
|
||||||
|
FileAction::Create,
|
||||||
|
new.clone(),
|
||||||
|
Some(format!("新建,{} 行", new.lines().count())),
|
||||||
|
),
|
||||||
|
(Some(new), Some(old)) => (
|
||||||
|
FileAction::Update,
|
||||||
|
new.clone(),
|
||||||
|
Some(bounded_diff(old, new, DIFF_LINE_LIMIT)),
|
||||||
|
),
|
||||||
|
(None, current) => (FileAction::Noop, current.clone().unwrap_or_default(), None),
|
||||||
|
};
|
||||||
|
let mut warning = None;
|
||||||
|
if action == FileAction::Update
|
||||||
|
&& crate::commands::onboarding::MANAGED_BLOCK_FILES.contains(&relative.as_str())
|
||||||
|
&& crate::commands::onboarding::has_uncommitted_outside_managed(
|
||||||
|
root,
|
||||||
|
&relative,
|
||||||
|
disk.as_deref().unwrap_or(""),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let text = crate::commands::onboarding::mixed_file_warning(&relative);
|
||||||
|
blocked.push(BlockedItem {
|
||||||
|
target: relative.clone(),
|
||||||
|
reason: BlockReason::MixedFile,
|
||||||
|
detail: text.clone(),
|
||||||
|
});
|
||||||
|
warning = Some(text);
|
||||||
|
}
|
||||||
|
files.push(PlannedFile {
|
||||||
|
path: relative,
|
||||||
|
action,
|
||||||
|
diff,
|
||||||
|
warning,
|
||||||
|
content_sha256: sha256_hex(&content.replace("\r\n", "\n")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for step in &machine_changes {
|
||||||
|
if let Some(reason) = match step.status.as_str() {
|
||||||
|
"manual_kept" => Some(BlockReason::ManualEntry),
|
||||||
|
"other_project" => Some(BlockReason::SharedKey),
|
||||||
|
"needs_human" => Some(BlockReason::NeedsHuman),
|
||||||
|
_ => None,
|
||||||
|
} {
|
||||||
|
blocked.push(BlockedItem {
|
||||||
|
target: step.step.clone(),
|
||||||
|
reason,
|
||||||
|
detail: step.detail.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked);
|
||||||
|
OnboardingPlan {
|
||||||
|
project_id: project.project_id.to_string(),
|
||||||
|
files,
|
||||||
|
machine_changes,
|
||||||
|
blocked,
|
||||||
|
digest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// context.json 已有不同的 project_id:一键接入会改写身份锚点,必须人确认(不自动覆盖)。
|
||||||
|
fn identity_conflict(root: &Path, project_id: &str) -> Option<BlockedItem> {
|
||||||
|
let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??;
|
||||||
|
let existing = serde_json::from_str::<serde_json::Value>(text.trim_start_matches('\u{feff}'))
|
||||||
|
.ok()?
|
||||||
|
.get("project_id")?
|
||||||
|
.as_str()?
|
||||||
|
.to_string();
|
||||||
|
(existing != project_id).then(|| BlockedItem {
|
||||||
|
target: ".lian-jing/context.json".into(),
|
||||||
|
reason: BlockReason::IdentityConflict,
|
||||||
|
detail: format!("目录已属于项目 {existing},与本次接入的 {project_id} 不一致"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_path(root: &Path, path: &Path) -> String {
|
||||||
|
path.strip_prefix(root)
|
||||||
|
.unwrap_or(path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(text: &str) -> String {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(text.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// digest 覆盖项目 ID、逐文件(路径、动作、内容哈希)、本机动作与阻断项。
|
||||||
|
fn plan_digest(
|
||||||
|
project_id: &str,
|
||||||
|
files: &[PlannedFile],
|
||||||
|
machine: &[MachineStep],
|
||||||
|
blocked: &[BlockedItem],
|
||||||
|
) -> String {
|
||||||
|
let canonical = serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"files": files.iter().map(|f| (f.path.as_str(), f.action, f.content_sha256.as_str())).collect::<Vec<_>>(),
|
||||||
|
"machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::<Vec<_>>(),
|
||||||
|
"blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
sha256_hex(&canonical.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_LINE_LIMIT: usize = 60;
|
||||||
|
|
||||||
|
/// 行级差异(去掉公共前后缀后列出删除与新增行),超过 limit 行截断并注明总行数。
|
||||||
|
fn bounded_diff(old: &str, new: &str, limit: usize) -> String {
|
||||||
|
let old = old.replace("\r\n", "\n");
|
||||||
|
let new = new.replace("\r\n", "\n");
|
||||||
|
let a: Vec<&str> = old.lines().collect();
|
||||||
|
let b: Vec<&str> = new.lines().collect();
|
||||||
|
let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
|
||||||
|
let max_suffix = a.len().min(b.len()) - prefix;
|
||||||
|
let suffix = a
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.zip(b.iter().rev())
|
||||||
|
.take(max_suffix)
|
||||||
|
.take_while(|(x, y)| x == y)
|
||||||
|
.count();
|
||||||
|
let mut lines = vec![format!("@@ 第 {} 行起", prefix + 1)];
|
||||||
|
lines.extend(
|
||||||
|
a[prefix..a.len() - suffix]
|
||||||
|
.iter()
|
||||||
|
.map(|line| format!("-{line}")),
|
||||||
|
);
|
||||||
|
lines.extend(
|
||||||
|
b[prefix..b.len() - suffix]
|
||||||
|
.iter()
|
||||||
|
.map(|line| format!("+{line}")),
|
||||||
|
);
|
||||||
|
let total = lines.len();
|
||||||
|
if total > limit {
|
||||||
|
lines.truncate(limit);
|
||||||
|
lines.push(format!("…(diff 共 {total} 行,只显示前 {limit} 行)"));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn git(dir: &Path, args: &[&str]) {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"user.name=t",
|
||||||
|
"-c",
|
||||||
|
"user.email=t@t",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
])
|
||||||
|
.args(args)
|
||||||
|
.current_dir(dir)
|
||||||
|
.output()
|
||||||
|
.expect("git 可执行");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {args:?}: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目录快照:相对路径 → 字节(含 .git,证明 plan 零写入)。
|
||||||
|
fn snapshot(root: &Path) -> BTreeMap<String, Vec<u8>> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
let mut pending = vec![root.to_path_buf()];
|
||||||
|
while let Some(dir) = pending.pop() {
|
||||||
|
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir() {
|
||||||
|
pending.push(path);
|
||||||
|
} else {
|
||||||
|
out.insert(relative_path(root, &path), std::fs::read(&path).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECT: ProjectWrite<'static> = ProjectWrite {
|
||||||
|
project_id: "p8c-test",
|
||||||
|
port: 27190,
|
||||||
|
server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test",
|
||||||
|
header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc",
|
||||||
|
};
|
||||||
|
|
||||||
|
fn machine(status: &str) -> Vec<MachineStep> {
|
||||||
|
vec![MachineStep {
|
||||||
|
step: "claude".into(),
|
||||||
|
status: status.into(),
|
||||||
|
detail: String::new(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_repo(name: &str) -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!("lj_p8c_{name}_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
git(&root, &["init", "-q"]);
|
||||||
|
std::fs::write(root.join("README.md"), "readme\n").unwrap();
|
||||||
|
git(&root, &["add", "-A"]);
|
||||||
|
git(&root, &["commit", "-q", "--no-verify", "-m", "init"]);
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_on_empty_repo_creates_everything_and_writes_nothing() {
|
||||||
|
let root = temp_repo("empty");
|
||||||
|
let before = snapshot(&root);
|
||||||
|
let plan = plan_onboarding(&root, &PROJECT, machine("apply"));
|
||||||
|
assert_eq!(snapshot(&root), before, "plan 不得写任何文件");
|
||||||
|
assert!(plan.files.len() >= 12, "实际 {} 个文件", plan.files.len());
|
||||||
|
assert!(
|
||||||
|
plan.files.iter().all(|f| f.action == FileAction::Create),
|
||||||
|
"{:?}",
|
||||||
|
plan.files
|
||||||
|
);
|
||||||
|
for expected in [
|
||||||
|
"AGENTS.md",
|
||||||
|
"CLAUDE.md",
|
||||||
|
".blueprint/CONVENTIONS.md",
|
||||||
|
".lian-jing/context.json",
|
||||||
|
".claude/settings.json",
|
||||||
|
".claude/liangjing.json",
|
||||||
|
".codex/config.toml",
|
||||||
|
".agents/skills/lian-jing/SKILL.md",
|
||||||
|
".claude/skills/lian-jing/SKILL.md",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
plan.files.iter().any(|f| f.path == expected),
|
||||||
|
"缺 {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(plan.blocked.is_empty(), "{:?}", plan.blocked);
|
||||||
|
assert_eq!(plan.digest.len(), 64);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_after_apply_is_noop_and_digest_tracks_changes() {
|
||||||
|
let root = temp_repo("applied");
|
||||||
|
// apply:同一写入序列换成真实写入汇
|
||||||
|
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
|
||||||
|
git(&root, &["add", "-A"]);
|
||||||
|
git(&root, &["commit", "-q", "--no-verify", "-m", "onboard"]);
|
||||||
|
|
||||||
|
let first = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||||
|
let second = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||||
|
assert!(
|
||||||
|
first.files.iter().all(|f| f.action == FileAction::Noop),
|
||||||
|
"{:?}",
|
||||||
|
first.files
|
||||||
|
);
|
||||||
|
assert_eq!(first.digest, second.digest, "同一状态两次计划 digest 相同");
|
||||||
|
|
||||||
|
// 本机预计动作变化 → digest 变化
|
||||||
|
assert_ne!(
|
||||||
|
plan_onboarding(&root, &PROJECT, machine("apply")).digest,
|
||||||
|
first.digest
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRLF 检出的同内容文件仍是 noop
|
||||||
|
let packs = root.join(".lian-jing/capability-packs.json");
|
||||||
|
// 模板在 Windows 检出时可能已是 CRLF:先归一成 LF 再整体转 CRLF,避免造出 \r\r\n
|
||||||
|
let lf = std::fs::read_to_string(&packs)
|
||||||
|
.unwrap()
|
||||||
|
.replace("\r\n", "\n");
|
||||||
|
std::fs::write(&packs, lf.replace('\n', "\r\n")).unwrap();
|
||||||
|
let crlf = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||||
|
let not_noop: Vec<_> = crlf
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.action != FileAction::Noop)
|
||||||
|
.map(|f| (f.path.clone(), f.action, f.diff.clone()))
|
||||||
|
.collect();
|
||||||
|
assert!(not_noop.is_empty(), "{not_noop:?}");
|
||||||
|
|
||||||
|
// 任一目标文件内容变化 → 该文件 update、digest 变化
|
||||||
|
std::fs::write(&packs, "{}\n").unwrap();
|
||||||
|
let changed = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||||
|
let entry = changed
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path == ".lian-jing/capability-packs.json")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(entry.action, FileAction::Update);
|
||||||
|
assert!(entry.diff.as_deref().unwrap().contains("-{}"));
|
||||||
|
assert_ne!(changed.digest, first.digest);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_file_and_identity_conflict_are_blocked() {
|
||||||
|
let root = temp_repo("blocked");
|
||||||
|
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
|
||||||
|
// 把 AGENTS.md 的接入包托管块版本改旧并提交,让计划要升级它
|
||||||
|
let agents = root.join("AGENTS.md");
|
||||||
|
let text = std::fs::read_to_string(&agents).unwrap();
|
||||||
|
let start = text.find("ONBOARDING_MANAGED_START version=\"").unwrap()
|
||||||
|
+ "ONBOARDING_MANAGED_START version=\"".len();
|
||||||
|
let end = start + text[start..].find('"').unwrap();
|
||||||
|
std::fs::write(&agents, format!("{}0.0.1{}", &text[..start], &text[end..])).unwrap();
|
||||||
|
git(&root, &["add", "-A"]);
|
||||||
|
git(&root, &["commit", "-q", "--no-verify", "-m", "old block"]);
|
||||||
|
// 块外未提交改动
|
||||||
|
let dirty = std::fs::read_to_string(&agents).unwrap() + "\n项目自己的未提交段落\n";
|
||||||
|
std::fs::write(&agents, dirty).unwrap();
|
||||||
|
// 身份锚点属于别的项目
|
||||||
|
std::fs::write(
|
||||||
|
root.join(".lian-jing/context.json"),
|
||||||
|
r#"{"schema_version":1,"project_id":"someone-else"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let plan = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||||
|
let agents_entry = plan.files.iter().find(|f| f.path == "AGENTS.md").unwrap();
|
||||||
|
assert_eq!(agents_entry.action, FileAction::Update);
|
||||||
|
assert!(agents_entry.warning.is_some(), "混合文件须带预警");
|
||||||
|
assert!(plan
|
||||||
|
.blocked
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.reason == BlockReason::MixedFile && b.target == "AGENTS.md"));
|
||||||
|
assert!(plan.blocked.iter().any(
|
||||||
|
|b| b.reason == BlockReason::IdentityConflict && b.detail.contains("someone-else")
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_codex_entry_and_machine_manual_states_block() {
|
||||||
|
let root = temp_repo("manual");
|
||||||
|
std::fs::create_dir_all(root.join(".codex")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.join(".codex/config.toml"),
|
||||||
|
"[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plan = plan_onboarding(&root, &PROJECT, machine("other_project"));
|
||||||
|
assert!(plan
|
||||||
|
.blocked
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.reason == BlockReason::ManualEntry && b.target == ".codex/config.toml"));
|
||||||
|
assert!(plan
|
||||||
|
.blocked
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.reason == BlockReason::SharedKey && b.target == "claude"));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_sink_overlays_pending_writes_and_reverts_to_noop() {
|
||||||
|
let root = std::env::temp_dir().join(format!("lj_p8c_sink_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let file = root.join("a.md");
|
||||||
|
std::fs::write(&file, "v1\n").unwrap();
|
||||||
|
let sink = PlanSink::default();
|
||||||
|
assert!(sink.write(&file, "v2\n").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
sink.read(&file).unwrap().as_deref(),
|
||||||
|
Some("v2\n"),
|
||||||
|
"后续步骤读到待写内容"
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_to_string(&file).unwrap(), "v1\n", "磁盘未动");
|
||||||
|
assert!(sink.write(&file, "v1\n").unwrap(), "改回磁盘原样");
|
||||||
|
assert!(sink.pending.borrow().is_empty(), "改回原样即撤销待写");
|
||||||
|
assert!(!sink.write(&file, "v1\r\n").unwrap(), "仅行尾不同视为未变");
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_diff_trims_common_lines_and_truncates() {
|
||||||
|
let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60);
|
||||||
|
assert_eq!(diff, "@@ 第 2 行起\n-b\n+B");
|
||||||
|
let old: String = (0..200).map(|i| format!("o{i}\n")).collect();
|
||||||
|
let new: String = (0..200).map(|i| format!("n{i}\n")).collect();
|
||||||
|
let long = bounded_diff(&old, &new, 60);
|
||||||
|
assert_eq!(long.lines().count(), 61);
|
||||||
|
assert!(long.ends_with("…(diff 共 401 行,只显示前 60 行)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user