Files
Unlucky-Launcher/crates/cli/src/commands/mod.rs
T
AskaEth 4760714743 feat(addon): M111 整合包导入管线(CF / mrpack / MultiMC)+ ull pack import
接 M110 的 pack 模型与安全闸,做导入:三格式 → 实例目录,落 D10 §2 三条硬要求。

core(`addon::import`):
- 三方合并:`merge::decide` 判据表(磁盘 × 基线 × 包内),base 侧落在
  `<实例根>/.ULL/pack-baseline.json`;无基线时磁盘同名文件算用户已有内容 ⇒ 不覆盖。
  冲突逐条进报告(user_modified / user_deleted / pre_existing + 可选处置)。
- 两阶段:先 overrides 再补远端;失败分级只有一条判据(是不是"某个文件搞不到"):
  缺文件 ⇒ Partial;穿越/符号链接/限额/清单坏/摘要不符/IO ⇒ Failed 且整包回滚。
- 事务:`Staging` 临时目录 + `CommitGuard`(全新导入 rename 整目录、合并态 journal 逆序
  回滚、Drop 即回滚),基线作为最后一个暂存文件同生共死。
- 三格式语义:mrpack `client-overrides` 胜 `overrides`、`server-overrides` 不解压;
  `.disabled`/`.old` 按路径后缀识别并原样保留;MultiMC `formatVersion != 1` 整份拒绝、
  非加载器 component 逐项进报告;CF `classId` 认不出就不猜。
- 加载器只做翻译(`LoaderTarget`:kind + 原版 + 加载器版本 + 版本 id,含 CF NeoForge
  `1.20.1-47.1.106` 的 MC 前缀剥离),网络全部经注入的 `PackFetcher`
  (生产实现 `DownloadEngineFetcher` 只走 core::download,无第二个 reqwest client)。
- 报告:三态 + 逐项原因 + 稳定文案 key 表(`report::keys`,含 ALL 守门表)。

CLI(`ull pack import <file> [--target] [--name] [--overwrite] [--no-register]`):
- 退出码三态 0 / 3 / 1(`Outcome::exit_code` + runner 透传,其它命令仍 0);
- `--json` 恒有信封(`data.status` / `data.error.code` / `data.stages`),Failed 不抛栈。

测试:29 条 core(三格式端到端、合并 6 条、必须先失败 8 条含双检互补样本与确定性
提交失败回滚、报告契约 5 条)+ 5 条 CLI(三态退出码、JSON 契约、既有命令回归、help)。
fmt / clippy(core+cli, -D warnings) 绿;文件全部 ≤400 行。
2026-11-04 20:54:04 +08:00

178 lines
7.7 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.
//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。
pub mod accounts;
pub mod agent;
pub mod config;
pub mod identity;
pub mod install;
pub mod instances;
pub mod java;
pub mod java_launch;
pub mod java_vendors;
pub mod launch;
pub mod meta;
pub mod pack;
pub mod sessions;
pub mod snapshot;
use crate::cli::{Command, Globals, Invocation};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// 执行一条已解析的命令。
///
/// # Errors
///
/// 用法/数据根/目标不存在等用户错误,或序列化失配等内部错误。
pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
match &invocation.command {
// 这三条不需要数据根(`--version` 在不达基线的机器上也要能跑,用于诊断)。
Command::Welcome => Ok(Outcome::new(meta::welcome())),
Command::Version => Ok(Outcome::new(meta::version())),
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
Command::PackImport(args) => {
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
}
Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)),
Command::SessionsList { limit } => {
with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit))
}
Command::Logs(args) => with_ctx(&invocation.globals, |ctx| sessions::logs(ctx, args)),
Command::Diag(args) => with_ctx(&invocation.globals, |ctx| sessions::diag(ctx, args)),
Command::ConfigGet(args) => with_ctx(&invocation.globals, |ctx| {
config::get(ctx, args.key.as_deref(), args.show_secret)
}),
Command::ConfigSet(args) => with_ctx(&invocation.globals, |ctx| {
config::set(ctx, &args.key, &args.value)
}),
Command::JavaList => with_ctx(&invocation.globals, java::list),
Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| {
java::install(ctx, *major, *vendor)
}),
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
Command::AccountsRemove { name } => {
with_ctx(&invocation.globals, |ctx| accounts::remove(ctx, name))
}
// `AgentServe` 由 `runner::run` 提前分流(stdio 形态下 stdout 就是协议流,
// 不能走「先返回 Report 再打印」的路径);这里只兜住「有人直接调 commands::run」。
Command::AgentServe(_) => Err(CliError::usage(
"agent serve 必须在进程入口直接运行(runner 会提前分流)",
)),
Command::AgentConfig { port } => {
with_ctx(&invocation.globals, |ctx| agent::config_report(ctx, *port))
}
}
}
/// `core::auth` 的错误 → CLI 错误(保留稳定机读码;文案已是人话,这里只补「下一步」)。
///
/// 集中一处的原因:账户相关命令(`accounts *`)与 `launch` 的身份解析要为**同一批**
/// 错误给出一致的码与提示——两处各写一份必然会漂。
pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
use unlucky_core::auth::body;
use unlucky_core::auth::AuthError;
let hint = match &error {
AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()),
AuthError::Timeout { .. } => {
Some("重新执行 `%prog% accounts add msa` 拿新的用户码".to_string())
}
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
AuthError::Unsupported(_) => {
Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string())
}
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
AuthError::NotFound { .. } => Some("用 `%prog% accounts list` 查看账户名".to_string()),
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => {
Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string())
}
AuthError::Xbox { .. } => {
Some("去 xbox.com / 家庭组设置里解决该账号的 Xbox 限制".to_string())
}
// M38 的两类分流落到 hint 行:拦截页(HTML/纯文本/空体)先查网络,
// JSON 拒绝按步骤查许可与区域。`code()` 仍是 "denied"(M31 兼容),
// 细分结论由 `detail_code()` 带在 hint 里,排障脚本照样能机读分流。
AuthError::Response(detail) if detail.is_interception() => Some(format!(
"检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案);诊断码 {}",
detail.detail_code()
)),
AuthError::Response(detail) => Some(format!(
"{};诊断码 {}",
body::advice(&detail.step, detail.kind),
detail.detail_code()
)),
_ => None,
};
let cli = CliError::user(error.code(), error.message());
match hint {
Some(hint) => cli.with_hint(hint),
None => cli,
}
}
/// 需要数据根的命令:解析上下文 → D19 支持级别闸门 → 执行 → 挂环境提示。
fn with_ctx<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
where
F: FnOnce(&Ctx) -> Result<Report, CliError>,
{
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
let report = action(&ctx)?;
Ok(Outcome::with_warnings(report, ctx.notes()))
}
/// 同 [`with_ctx`],但命令自己组装 [`Outcome`](需要指定退出码或多态结果时用)。
///
/// 只有 `pack import` 走这条路:它的三态要 0 / 3 / 1 三个退出码,`Report` 表达不了。
fn with_ctx_outcome<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
where
F: FnOnce(&Ctx) -> Result<Outcome, CliError>,
{
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
let mut outcome = action(&ctx)?;
outcome.warnings.extend(
ctx.notes()
.into_iter()
.map(|line| crate::cli::program::fill(&line)),
);
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::map_auth_error;
use unlucky_core::auth::{AuthError, BodyKind, ResponseDetail};
fn response(step: &str, kind: BodyKind) -> AuthError {
AuthError::Response(Box::new(ResponseDetail {
step: step.to_string(),
what: "Minecraft 登录被拒".to_string(),
status: 403,
content_type: None,
kind,
server_message: None,
digest: String::new(),
}))
}
#[test]
fn response_hints_split_network_from_license_and_carry_detail_code() {
let blocked = map_auth_error(response("login_with_xbox", BodyKind::Html));
assert_eq!(blocked.code(), "denied", "code() 仍是 M31 的 denied");
let hint = blocked.hint().expect("拦截页要有 hint").to_string();
assert!(hint.contains("代理"), "{hint}");
assert!(hint.contains("login_with_xbox_non_json"), "{hint}");
let denied = map_auth_error(response("login_with_xbox", BodyKind::Json));
let hint = denied.hint().expect("JSON 拒绝要有 hint").to_string();
assert!(hint.contains("许可"), "{hint}");
assert!(!hint.contains("代理"), "{hint}");
assert!(hint.contains("login_with_xbox_403"), "{hint}");
}
}