Files
Unlucky-Launcher/crates/cli/src/commands/accounts.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

199 lines
7.0 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.
//! `ull accounts list|add|remove`:账户元数据操作,**凭据永不打印**。
//!
//! 设计约束(D9/Q21 + D4 凭据红线):
//! - 只开四条路径:`list` / `add offline <name>` / `add msa` / `remove <name>`;
//! authlib 的密码登录**故意不开 CLI 通道**(密码进 argv 会直接进 shell 历史与
//! `ps` 输出,破坏红线;留给 GUI 输入框);
//! - 输出只经 `AccountMeta`(可公开字段),`Report`/`--json` 里没有任何 token 字段;
//! - device-code 的 `user_code`/URL 打到 **stderr**(不管 `--quiet` 都要打:不打用户没法完成登录)。
use serde_json::json;
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthJournal};
use crate::cli::{AccountsAddArgs, AccountsAddKind};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{progress_line, Report};
/// `accounts list`:列出账户(名称/轨道/UUID/创建时间)。
///
/// # Errors
///
/// 数据根解析失败(在 `Ctx`)或读盘 IO 失败。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let store = open_store(ctx);
let mut human = Vec::new();
if store.is_empty() {
human.push("还没有账户。".to_string());
human.push(" 离线:%prog% accounts add offline <玩家名>".to_string());
human.push(" 微软:%prog% accounts add msa(设备码交互式登录)".to_string());
} else {
human.push(format!("账户({} 个):", store.accounts().len()));
for meta in store.accounts() {
human.push(format!(
" {} · {} · {}",
meta.display_name(),
meta.kind.label(),
meta.profile_id
));
}
}
for warning in store.warnings() {
human.push(format!("提示: {warning}"));
}
let accounts: Vec<serde_json::Value> = store.accounts().iter().map(account_json).collect();
let data = json!({
"accounts": accounts,
"count": accounts.len(),
"auth_dir": store.paths().root().display().to_string(),
"warnings": store.warnings(),
});
Ok(Report::new(data, human))
}
/// `accounts add offline <name>`。
///
/// # Errors
///
/// 名字不合法、同名账户已存在、落盘失败。
pub fn add(ctx: &Ctx, args: &AccountsAddArgs) -> Result<Report, CliError> {
match args.kind {
AccountsAddKind::Offline => add_offline(ctx, args.name.as_deref().unwrap_or_default()),
AccountsAddKind::Microsoft => add_microsoft(ctx),
}
}
fn add_offline(ctx: &Ctx, name: &str) -> Result<Report, CliError> {
let meta = offline_meta(name, now_ms()).map_err(super::map_auth_error)?;
let mut store = open_store(ctx);
store
.insert(meta.clone(), Credentials::Offline)
.map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![
format!(
"已添加离线账户 {}(HMCL 兼容 UUID {})",
meta.display_name(),
meta.profile_id
),
format!("账户文件:{}", store.paths().accounts_file().display()),
];
Ok(Report::new(added_json(&meta), human))
}
/// `accounts add msa`:device-code 交互式登录。
fn add_microsoft(ctx: &Ctx) -> Result<Report, CliError> {
// 失败日志落**本次命令的实际数据根**(`ctx.layout().logs()`):`MicrosoftAuth::from_env()`
// 默认挂的是便携根(exe 同级 `.ULL/logs`),`--data-dir` / launcher.json 的 standard 模式下
// 那是别人的目录,排障时找不到 auth.jsonl(M38 遗留 finding)。
let auth = MicrosoftAuth::from_env()
.map_err(super::map_auth_error)?
.with_journal(Some(AuthJournal::in_logs_dir(ctx.layout().logs())));
// 设备码要先给用户看,再开始等:core 的 `present` 回调正好卡在这个时间点。
let session = unlucky_core::download::blocking::block_on(auth.device_code_login(|prompt| {
let (headline, code) = prompt.lines();
progress_line(&headline);
progress_line(&code);
}))
.map_err(super::map_auth_error)?;
let meta = AccountMeta::new(
AccountKind::Microsoft,
session.profile_id.clone(),
session.profile_name.clone(),
now_ms(),
);
let mut store = open_store(ctx);
store
.insert(meta.clone(), Credentials::Microsoft(session))
.map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![
format!(
"已添加微软账户 {}(档案 UUID {})",
meta.display_name(),
meta.profile_id
),
format!("账户文件:{}", store.paths().accounts_file().display()),
];
Ok(Report::new(added_json(&meta), human))
}
/// `accounts remove <name>`。
///
/// # Errors
///
/// 找不到该账户、落盘失败。
pub fn remove(ctx: &Ctx, name: &str) -> Result<Report, CliError> {
let mut store = open_store(ctx);
let meta = store.remove(name).map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![format!(
"已删除账户 {}({})",
meta.display_name(),
meta.kind.label()
)];
let data = json!({
"removed": account_json(&meta),
"count": store.accounts().len(),
});
Ok(Report::new(data, human))
}
fn open_store(ctx: &Ctx) -> AccountStore {
AccountStore::open(ctx.auth_data())
}
fn added_json(meta: &AccountMeta) -> serde_json::Value {
json!({
"account": account_json(meta),
"account_id": meta.id.as_str(),
})
}
/// 账户元数据 → JSON(**只含可公开字段**)。
fn account_json(meta: &AccountMeta) -> serde_json::Value {
json!({
"name": meta.display_name(),
"profile_name": meta.profile_name,
"id": meta.id.as_str(),
"kind": meta.kind.as_str(),
"profile_id": meta.profile_id,
"login_name": meta.login_name,
"server_url": meta.server_url,
"created_at_ms": meta.created_at_ms,
})
}
#[cfg(test)]
mod tests {
use super::account_json;
use unlucky_core::auth::account::{AccountKind, AccountMeta};
#[test]
fn account_json_has_no_credential_fields() {
let mut meta = AccountMeta::new(
AccountKind::Microsoft,
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
7,
);
meta.login_name = Some("me@example.com".to_string());
let value = account_json(&meta);
let keys: Vec<&str> = value
.as_object()
.expect("对象")
.keys()
.map(String::as_str)
.collect();
for forbidden in ["access_token", "refresh_token", "token", "password"] {
assert!(!keys.contains(&forbidden), "不该出现 {forbidden}");
}
assert_eq!(value["name"], "Steve");
assert_eq!(value["kind"], "microsoft");
}
}