Files
Unlucky-Launcher/crates/cli/src/commands/config.rs
T
AskaEth 2f8bbd1746 fix(cli): 程序名只替换模板占位符,不再改写用户数据(M82 r2 复审 P2)
r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。

改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。

同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。

回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。

占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
2026-11-02 08:08:13 +08:00

342 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `config get|set`:走 `core::setting` 的 schema API(单一真相源)。
//!
//! # 落点(M40:与数据根解析同源)
//!
//! - **数据根键**([`DATA_ROOT_KEYS`]:`core.data_mode` / `core.data_custom_root` /
//! `core.launcher_home` / `core.game_home`)→ 便携目录那份 `launcher.json`
//! (`<exe 目录>/.ULL/config/launcher.json`)。这正是 `Ctx::resolve` 决定数据根时读的
//! 同一份文件(落点由 `Ctx::setting_path_for` 给出),所以 `config get core.data_mode`
//! 展示的就是真正生效的值,`config set` 也真的影响下一次解析;
//! - 其余键 → 解析后的数据根(`<data>/.ULL/config/launcher.json`)。
//!
//! 之前两条路各看一份文件:`--data-dir` / standard 模式下「改了 `core.data_mode` 没反应、
//! `config get` 还报另一份文件里的值」(finding `…-improve-ull-config-get-set-launcher-json`)。
//!
//! 实例级配置(`config/instances/<id>.json`)留给实例管理 mission,这里不半途实现。
//!
//! **凭据纪律**(D9 / `02-ai-agent-design.md` §3.1):schema 标 `High` 的敏感键
//! (`ai.api_key` / `download.proxy_url` / `java.extra_args` …)在 read 路径上一律掩码——
//! 单键 get、无键全量 dump、set 的旧值回显**三条**都要掩,`--show-secret` 才放行明文。
//! 判定叠两道:schema 的 `Sensitivity::High` + agent 的 `redact::is_sensitive_key` 词表。
use serde_json::{json, Map, Value};
use unlucky_core::setting::catalog::SettingSchema;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::Sensitivity;
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
use crate::context::{is_data_root_key, Ctx, DATA_ROOT_KEYS};
use crate::error::CliError;
use crate::output::Report;
/// 敏感值在输出里的占位。
const MASK: &str = "<redacted>";
/// `ull config get [<key>] [--show-secret]`。
///
/// # Errors
///
/// 未知键、配置不可读时返回用户错误。
pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, CliError> {
match key {
Some(key) => get_one(ctx, key, show_secret),
None => dump(ctx, show_secret),
}
}
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
let path = ctx.setting_path_for(key);
let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone();
let Some(value) = store.effective(key) else {
return Err(
CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里"))
.with_hint("`%prog% config get`(不带键)可以看到全部生效键"),
);
};
let sensitive = is_sensitive(&schema, key);
let hidden = sensitive && !show_secret;
let shown = if hidden {
Value::String(MASK.to_string())
} else {
value.clone()
};
let problems = render_problems(&store);
let data = json!({
"key": key,
"value": shown,
"redacted": hidden,
"sensitive": sensitive,
"path": path.display().to_string(),
"problems": problems,
});
let mut human = vec![format!(
"{key} = {}{}",
render_value(&shown),
if hidden {
"(敏感键;加 --show-secret 显示明文)"
} else {
""
}
)];
human.push(format!("来自 {}", path.display()));
for problem in &problems {
human.push(format!("问题:{problem}"));
}
human.extend(source_note(ctx, key));
Ok(Report::new(data, human))
}
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
let path = ctx.launcher_config();
let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone();
let core_path = ctx
.portable_launcher_config()
.filter(|core| core.as_path() != path.as_path());
let core_store = core_path
.as_ref()
.map(|core| SettingStore::load(core, launcher_schema()));
let mut document = store.doc().to_value();
if let Some(core) = &core_store {
// 只叠加**便携目录那份里真有的键**(默认值不落盘,dump 也只展示文件里有的键);
// 生效数据根在下面的 `data_root` 里单独给,不靠这里的默认值推。
for key in DATA_ROOT_KEYS {
if let Some(value) = core.doc().get(key) {
overlay(&mut document, key, value.clone());
}
}
}
let mut problems = render_problems(&store);
if let Some(core) = &core_store {
problems.extend(render_problems(core));
}
let (document, masked) = if show_secret {
(document, false)
} else {
mask_document(&schema, &document)
};
let text = match serde_json::to_string_pretty(&document) {
Ok(text) => text,
Err(error) => return Err(CliError::internal("config_serialize", error.to_string())),
};
let mut human: Vec<String> = Vec::new();
human.push(format!("配置 {}({:?})", path.display(), store.compat()));
if let Some(core) = &core_path {
human.push(format!("数据根键(core.*)读的是 {}", core.display()));
}
human.push(format!(
"数据根:{}(launcher home {},游戏目录 {})",
ctx.data.mode.as_str(),
ctx.data.launcher_home.display(),
ctx.data.game_home.display()
));
human.extend(text.lines().map(str::to_string));
for problem in &problems {
human.push(format!("问题:{problem}"));
}
if masked {
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
}
Ok(Report::new(
json!({
"path": path.display().to_string(),
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
"config": document,
"redacted": masked,
"problems": problems,
"data_root": {
"mode": ctx.data.mode.as_str(),
"launcher_home": ctx.data.launcher_home.display().to_string(),
"game_home": ctx.data.game_home.display().to_string(),
},
}),
human,
))
}
/// `ull config set <key> <value>`。
///
/// 敏感键的**旧值回显要掩码**(新值是用户刚敲进来的,照常回显)。
///
/// # Errors
///
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
let path = ctx.setting_path_for(key);
let mut store = SettingStore::load(&path, launcher_schema());
let sensitive = is_sensitive(store.doc().schema(), key);
let value = parse_value(raw);
let old = store.effective(key).cloned().map(|old| {
if sensitive {
Value::String(MASK.to_string())
} else {
old
}
});
store.set(key, value.clone()).map_err(|error| {
CliError::user("config_set", error.to_string())
.with_hint(format!("键与取值范围见 {}", path.display()))
})?;
let outcome = store.save().map_err(|error| {
CliError::user(
"config_save",
format!("写 {} 失败:{error}", path.display()),
)
})?;
match &outcome {
SaveOutcome::Saved(saved) => {
let data = json!({
"key": key,
"old": old,
"new": value,
"saved": saved.display().to_string(),
});
let mut human = vec![
format!(
"{key} = {}(原值 {})",
render_value(&value),
render_value(&old.unwrap_or(Value::Null))
),
format!("已保存:{}", saved.display()),
];
human.extend(source_note(ctx, key));
Ok(Report::new(data, human))
}
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
json!({ "key": key, "new": value, "saved": null }),
vec![format!("{key} 已经是该值,未写盘")],
)),
SaveOutcome::Skipped(reason) => {
let error = CliError::user(
"config_read_only",
format!("{} 当前不可写({})", path.display(), reason.as_str()),
);
// 不可写常常不是权限问题,而是文件内容让加载降级(`$schema` 认不出 / 版本更高)。
let problems = render_problems(&store);
Err(if problems.is_empty() {
error.with_hint("修好文件权限/内容后重试")
} else {
error.with_hint(problems.join(";"))
})
}
}
}
/// 数据根键的落点说明(其余键不需要:它们的落点就是数据根本身)。
fn source_note(ctx: &Ctx, key: &str) -> Option<String> {
if !is_data_root_key(key) {
return None;
}
if ctx.data_dir_forced() {
return Some(
"本次由 --data-dir 指定数据根:该键在下次不带 --data-dir 运行时生效".to_string(),
);
}
if ctx.setting_path_for(key) != ctx.launcher_config() {
return Some("该键决定数据根,落点是便携目录那份(与数据根解析同源)".to_string());
}
None
}
/// 加载问题 → 人读一行(`problems` 通道与人类可读输出共用)。
fn render_problems(store: &SettingStore) -> Vec<String> {
store.problems().iter().map(ToString::to_string).collect()
}
/// 把 `key` 的生效值写进 dump 文档(点分键 → 嵌套对象)。
fn overlay(root: &mut Value, key: &str, value: Value) {
let mut segments = key.split('.').filter(|part| !part.is_empty());
let Some(head) = segments.next() else {
return;
};
let rest: Vec<&str> = segments.collect();
let Some(map) = root.as_object_mut() else {
return;
};
if rest.is_empty() {
map.insert(head.to_string(), value);
return;
}
overlay(
map.entry(head.to_string())
.or_insert_with(|| Value::Object(Map::new())),
&rest.join("."),
value,
);
}
/// 值先按 JSON 解析(数字/布尔/对象),失败按字符串。
fn parse_value(raw: &str) -> Value {
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
/// 敏感键:schema 标 `High`,或键名本身命中 agent 的敏感词表(双保险)。
fn is_sensitive(schema: &SettingSchema, key: &str) -> bool {
matches!(schema.sensitivity(key), Some(Sensitivity::High))
|| unlucky_agent::redact::is_sensitive_key(key)
}
/// 递归掩码渲染后的配置文档,返回(掩码后的文档,是否掩过)。
///
/// `to_value()` 是**嵌套对象**(点分键展开成层级),所以按路径下钻;
/// 命中敏感路径时整棵子树一起掩码,数组元素沿用父路径继续下钻。
fn mask_document(schema: &SettingSchema, document: &Value) -> (Value, bool) {
mask_at(schema, "", document)
}
fn mask_at(schema: &SettingSchema, path: &str, value: &Value) -> (Value, bool) {
if !path.is_empty() && is_sensitive(schema, path) {
return (Value::String(MASK.to_string()), true);
}
match value {
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
let mut masked = false;
for (key, child) in map {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
let (child, child_masked) = mask_at(schema, &child_path, child);
masked |= child_masked;
out.insert(key.clone(), child);
}
(Value::Object(out), masked)
}
Value::Array(items) => {
let mut masked = false;
let redacted: Vec<Value> = items
.iter()
.map(|item| {
let (item, item_masked) = mask_at(schema, path, item);
masked |= item_masked;
item
})
.collect();
(Value::Array(redacted), masked)
}
other => (other.clone(), false),
}
}
fn render_value(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests;