Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
//! 帮助文案(`ull --help` / `ull help <命令>`)。
//!
//! 命令集与 `10-cli-design.md` §3 对齐;`agent serve|config`(M39 的 MCP server)已列,
//! `agent ask`(内置对话的 headless 形态)仍未列。
/// 总帮助。
#[must_use]
pub const fn general() -> &'static str {
"\
ull · Unlucky-Launcher 命令行(D18)
用法:
ull [全局选项] <命令> [参数]
命令:
instances list 列出实例与已安装版本
install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
下载并安装版本本体(--loader fabric|quilt|neoforge|forge
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
[--max-memory <MB>] 启动实例;--dry-run 只打印组装后的命令行
accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**)
accounts add offline <name> 添加离线账户(HMCL 兼容 UUID)
accounts add msa 添加微软账户(设备码交互式登录)
accounts remove <name> 删除账户(连同凭据)
sessions list [--limit N] 列出会话记录(倒序)
logs <sid> [--tail N] 打印会话事件流末尾
diag <sid> [--tail N] 对会话跑规则引擎诊断
config get [<key>] [--show-secret] 读配置(生效值;无 key 时输出整份)
config set <key> <value> 写配置(走 setting schema 校验)
java list 列出可用 Java(含每个 JDK 的实际落点)
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版;
--vendor temurin|oracle|microsoft|zulu 钉死一家)
java vendors 列出四家发行版的镜像/支持版本/当前优先级
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
version 版本 + 系统摘要(同 --version)
help [命令] 本帮助
全局选项:
--json 机器可读输出:成功 {\"ok\":true,\"data\":…},失败 {\"ok\":false,\"error\":…}
--data-dir P 覆盖数据根(等价 custom 模式的根目录,即含 .ULL/ 与 .minecraft/ 的父目录)
--no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本)
--quiet 静默人类可读输出(错误与提示仍进 stderr)
--help, -h 帮助(有位置参数时按该命令给帮助)
--version, -V 版本 + 系统摘要
退出码: 0 成功 / 1 用户错误 / 2 内部错误"
}
/// 单个命令的帮助;未知主题返回 `None`(调用方退回总帮助)。
#[must_use]
pub fn topic(name: &str) -> Option<&'static str> {
match name {
"instances" => Some(
"\
ull instances list
列出 profiles.json 里的实例与 versions/ 下已安装的版本。
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}",
),
"install" => Some(
"\
ull install <version> [--loader X] [--loader-version V] [--name N] [--dry-run]
解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例
(profiles.json + config/instances/<id>.json)。已存在且校验通过的文件会跳过,
重复装同一版本不重复下载、不重复建实例。
--dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。
--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge。装加载器时先生成
versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类 + loader 库),
原版没装会先装原版,实例的 versionId 指向 loader 版本。
--loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新)。
Forge 可写 47.2.0 或 1.20.1-47.2.0 两种形态。
NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装):
缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。
--json:stdout 是单个结果信封 {\"data\":{\"plan\":…,\"result\":…,\"instance\":…}},
实时进度改走 stderr 的 JSONL(一行一条 {\"event\":\"progress\"|\"file_failed\"});
--quiet 时连进度也不打。",
),
"launch" => Some(
"\
ull launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
[--max-memory <MB>]
<instance> 可以是实例 id、实例名,或直接是版本 id。
--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌。
账户:--account <name> 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落
离线占位(--username 或 Player),有多个账户会报错并列出候选。
accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
),
"accounts" => Some(
"\
ull accounts list
ull accounts add offline <name>
ull accounts add msa
ull accounts remove <name>
账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据)
**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。
凭据永不打印:list 只输出名称/轨道/UUID。
add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码),
需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。
authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。",
),
"sessions" => Some("ull sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"),
"logs" => Some("ull logs <sid> [--tail N] <sid> 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"),
"diag" => Some(
"ull diag <sid> [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断",
),
"config" => Some(
"\
ull config get [<key>] [--show-secret]
ull config set <key> <value>
读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、
原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。",
),
"java" => Some(
"\
ull java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录)
ull java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
ull java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/…
Linux $XDG_DATA_HOME/java/…
custom 设置 java.installRoot 后按 <root>/<发行方>/<版本>/(覆盖前两者)
发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms)
→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序
(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。
每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。",
),
"agent" => Some(
"\
ull agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly]
ull agent config [--port N]
把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、
同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823);
--stdio 是给 llama.cpp server 的内置 MCP client 用的形态。
写权限默认**关**(只读子集):要放开需设置键 agent.mcp.allow_write=true 或 --allow-write。
敏感键(High 分级)在 MCP 场景**默认拒绝**(外部客户端没有确认弹窗):让用户去设置页改,
或在设置 → AI 里打开 YOLO(审计照记 yolo=true)。
令牌:设置键 agent.mcp.token;非空时要求 Authorization: Bearer <token>。
`agent config` 直接给出 Claude Code {\"type\":\"http\",\"url\":…} 与 Cursor {\"url\":…} 的样例。",
),
_ => None,
}
}
+17
View File
@@ -0,0 +1,17 @@
//! 命令行解析(手写,不引 clap —— 子命令集小,省一棵依赖树)。
//!
//! 语法:`ull [全局选项] <命令> [子命令/参数]`。全局选项可以出现在任意位置
//! (脚本里 `ull instances list --json` 与 `ull --json instances list` 等价)。
//!
//! 帮助与版本是「提前返回」的伪命令:`--help` / `-h` 出现在任何位置都立即给帮助,
//! 有位置参数时按该主题给帮助。
pub mod help;
pub mod model;
pub mod parse;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs,
};
pub use parse::parse;
+196
View File
@@ -0,0 +1,196 @@
//! 命令模型(纯数据):解析结果就是这里的一个 [`Invocation`]。
use std::path::PathBuf;
/// 全局选项(所有命令共享)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Globals {
/// `--json`:机器可读输出。
pub json: bool,
/// `--no-color`:关闭颜色。v1 输出本身不含 ANSI,参数保留以兼容脚本。
pub no_color: bool,
/// `--quiet`:静默人类可读输出(错误仍进 stderr)。
pub quiet: bool,
/// `--data-dir <dir>`:覆盖数据根(等价 custom 模式的 `custom_root`)。
pub data_dir: Option<PathBuf>,
}
/// 一条命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。
Welcome,
/// 只打印版本 + 系统摘要。
Version,
/// 帮助(`Some(topic)` 给该子命令的帮助)。
Help(Option<String>),
/// `instances list`。
InstancesList,
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `launch <instance> [--dry-run]`。
Launch(LaunchArgs),
/// `sessions list [--limit N]`。
SessionsList {
/// 最多返回几条。
limit: usize,
},
/// `logs <sid> [--tail N]`。
Logs(LogsArgs),
/// `diag <sid>`。
Diag(DiagArgs),
/// `config get [<key>]`。
ConfigGet(ConfigGetArgs),
/// `config set <key> <value>`。
ConfigSet(ConfigSetArgs),
/// `java list`。
JavaList,
/// `java install <major> [--vendor X]`。
JavaInstall {
/// 目标 JDK 主版本(8 / 17 / 21)。
major: u32,
/// 钉死的发行版(缺省走 D23 的回退链)。
vendor: Option<unlucky_core::game::java::JdkVendor>,
},
/// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。
JavaVendors,
/// `accounts list`。
AccountsList,
/// `accounts add <kind> [<name>]`。
AccountsAdd(AccountsAddArgs),
/// `accounts remove <name>`。
AccountsRemove {
/// 账户名(或条目 id)。
name: String,
},
/// `agent serve`:对外 MCP server(长驻)。
AgentServe(AgentServeArgs),
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
AgentConfig {
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
port: Option<u16>,
},
}
/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentServeArgs {
/// 走 stdio 形态(llama.cpp 内置 MCP client 用);缺省是 Streamable HTTP。
pub stdio: bool,
/// 覆盖端口。
pub port: Option<u16>,
/// 覆盖监听地址(只接受回环地址,非回环会被 server 拒绝)。
pub host: Option<String>,
/// 本次运行放开写工具(仍需设置键 `agent.mcp.allow_write` 或本 flag 之一)。
pub allow_write: bool,
}
/// `accounts add` 的轨道。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountsAddKind {
/// 离线(`add offline <name>`)。
Offline,
/// 微软 device-code(`add msa`,交互式)。
Microsoft,
}
impl AccountsAddKind {
/// 稳定名(帮助文案与 JSON 输出用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "msa",
}
}
}
/// `accounts add` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsAddArgs {
/// 轨道。
pub kind: AccountsAddKind,
/// 离线轨的玩家名(微软轨不需要)。
pub name: Option<String>,
}
/// `install` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallArgs {
/// 目标版本 id(`1.20.1` / `1.20.1-forge-47.2.0`)。
pub version: String,
/// 加载器(`vanilla` 缺省 / `fabric` / `quilt` / `neoforge` / `forge`)。
pub loader: Option<String>,
/// 加载器版本(缺省取官方默认:Fabric 系首个稳定版,maven 系该原版下最新)。
pub loader_version: Option<String>,
/// 实例显示名(缺省用版本 id)。
pub name: Option<String>,
/// 只出安装计划(列清单 + 总大小 + 源选择),不下载、不登记。
pub dry_run: bool,
/// 是否往 stderr 打实时进度(`--quiet` 下关掉)。
pub progress: bool,
/// 实时进度用 JSONL 形态(`--json` 时开,stderr 一行一条,stdout 仍是单个信封)。
pub json_progress: bool,
}
/// `launch` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchArgs {
/// 实例 id 或实例名;也可以直接给版本 id。
pub instance: String,
/// 只组装命令行,不 spawn(D18 的 `--dry-run`)。
pub dry_run: bool,
/// Java 可执行文件覆盖。
pub java: Option<PathBuf>,
/// 离线账户名(缺省 `Player`)。
pub username: Option<String>,
/// 账户名(`accounts` 里的玩家名或条目 id);缺省用唯一账户。
pub account: Option<String>,
/// 最大堆(MB)。
pub max_memory: Option<u32>,
}
/// `logs` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogsArgs {
/// 会话 id(允许唯一前缀)。
pub session_id: String,
/// 只打印末尾 N 条事件(缺省 200)。
pub tail: usize,
}
/// `diag` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagArgs {
/// 会话 id(允许唯一前缀)。
pub session_id: String,
/// 送入规则引擎的日志行上限(缺省 2000)。
pub tail: usize,
}
/// `config get` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigGetArgs {
/// 点分键路径;`None` 表示整份生效配置。
pub key: Option<String>,
/// 敏感键也输出明文(默认脱敏)。
pub show_secret: bool,
}
/// `config set` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigSetArgs {
/// 点分键路径。
pub key: String,
/// 值的原始文本(先按 JSON 解析,失败按字符串)。
pub value: String,
}
/// 一次调用的完整输入。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
/// 全局选项。
pub globals: Globals,
/// 要执行的命令。
pub command: Command,
}
+365
View File
@@ -0,0 +1,365 @@
//! 手写解析器:全局选项 → 子命令 → 子命令内的选项。
use std::collections::BTreeMap;
use std::path::PathBuf;
use unlucky_core::game::java::JdkVendor;
use crate::error::CliError;
mod accounts;
mod agent;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
LogsArgs,
};
/// `logs` 缺省尾巴行数。
const DEFAULT_LOG_TAIL: usize = 200;
/// `diag` 缺省送入规则引擎的行数。
const DEFAULT_DIAG_TAIL: usize = 2000;
/// `sessions list` 缺省条数。
const DEFAULT_SESSION_LIMIT: usize = 20;
/// 解析整条命令行(不含 argv[0])。
///
/// # Errors
///
/// 用法错误(未知命令/选项、缺参数、数字解析失败)返回 [`CliError`](退出码 1)。
pub fn parse(args: &[String]) -> Result<Invocation, CliError> {
let mut globals = Globals::default();
let mut rest: Vec<String> = Vec::new();
let mut help_requested = false;
let mut version_requested = false;
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
match arg {
"--json" => globals.json = true,
"--no-color" => globals.no_color = true,
"--quiet" | "-q" => globals.quiet = true,
// 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时,
// 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。
"--help" | "-h" => help_requested = true,
"--version" | "-V" => version_requested = true,
"--data-dir" => {
index += 1;
globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?));
}
_ => match arg.strip_prefix("--data-dir=") {
Some(value) => globals.data_dir = Some(PathBuf::from(value)),
None => rest.push(arg.to_string()),
},
}
index += 1;
}
if help_requested {
return Ok(Invocation {
globals,
command: Command::Help(rest.first().cloned()),
});
}
if version_requested {
return Ok(Invocation {
globals,
command: Command::Version,
});
}
let command = parse_command(&rest, &globals)?;
Ok(Invocation { globals, command })
}
fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError> {
let Some(name) = rest.first() else {
return Ok(Command::Welcome);
};
let tail = &rest[1..];
match name.as_str() {
"version" => Ok(Command::Version),
"help" => Ok(Command::Help(tail.first().cloned())),
"instances" => parse_instances(tail),
"install" => parse_install(tail, globals),
"launch" => parse_launch(tail),
"sessions" => parse_sessions(tail),
"logs" => parse_logs(tail),
"diag" => parse_diag(tail),
"config" => parse_config(tail),
"java" => parse_java(tail),
"accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail),
other => {
Err(CliError::usage(format!("未知命令 `{other}`")).with_hint("ull --help 查看命令列表"))
}
}
}
fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"instances 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &[], &[])?;
ensure_no_positionals(&parsed, "instances list")?;
Ok(Command::InstancesList)
}
fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?;
let version = require_single_positional(&parsed, "install <version>")?;
Ok(Command::Install(InstallArgs {
version,
loader: parsed.value("loader"),
loader_version: parsed.value("loader-version"),
name: parsed.value("name"),
dry_run: parsed.has("dry-run"),
// 实时进度进 stderr:人类模式是文本行,`--json` 模式是 JSONL(stdout 保持干净),
// `--quiet` 一律不打。
progress: !globals.quiet,
json_progress: globals.json,
}))
}
fn parse_launch(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
&["java", "username", "account", "max-memory"],
&["dry-run"],
)?;
let instance = require_single_positional(&parsed, "launch <instance>")?;
let max_memory = match parsed.value("max-memory") {
Some(raw) => Some(
raw.parse::<u32>()
.map_err(|_| CliError::usage(format!("--max-memory 需要整数 MB,收到 `{raw}`")))?,
),
None => None,
};
Ok(Command::Launch(LaunchArgs {
instance,
dry_run: parsed.has("dry-run"),
java: parsed.value("java").map(PathBuf::from),
username: parsed.value("username"),
account: parsed.value("account"),
max_memory,
}))
}
fn parse_sessions(tail: &[String]) -> Result<Command, CliError> {
let args = match tail.first().map(String::as_str) {
None | Some("list") => tail.get(1..).unwrap_or_default(),
Some(other) => {
return Err(CliError::usage(format!(
"sessions 的子命令只有 list,收到 `{other}`"
)));
}
};
let parsed = parse_flags(args, &["limit"], &[])?;
ensure_no_positionals(&parsed, "sessions list")?;
let limit = match parsed.value("limit") {
Some(raw) => raw
.parse::<usize>()
.map_err(|_| CliError::usage(format!("--limit 需要整数,收到 `{raw}`")))?,
None => DEFAULT_SESSION_LIMIT,
};
Ok(Command::SessionsList { limit })
}
fn parse_logs(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["tail"], &[])?;
let session_id = require_single_positional(&parsed, "logs <sid>")?;
let tail_lines = optional_count(&parsed, "tail", DEFAULT_LOG_TAIL)?;
Ok(Command::Logs(LogsArgs {
session_id,
tail: tail_lines,
}))
}
fn parse_diag(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(tail, &["tail"], &[])?;
let session_id = require_single_positional(&parsed, "diag <sid>")?;
let tail_lines = optional_count(&parsed, "tail", DEFAULT_DIAG_TAIL)?;
Ok(Command::Diag(DiagArgs {
session_id,
tail: tail_lines,
}))
}
fn parse_config(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("config 需要子命令 get|set")
.with_hint("ull config get <key> / ull config set <key> <value>"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"get" => {
let parsed = parse_flags(rest, &[], &["show-secret"])?;
if parsed.positionals.len() > 1 {
return Err(CliError::usage("config get 最多接受一个键路径"));
}
Ok(Command::ConfigGet(ConfigGetArgs {
key: parsed.positionals.first().cloned(),
show_secret: parsed.has("show-secret"),
}))
}
"set" => {
let parsed = parse_flags(rest, &[], &[])?;
if parsed.positionals.len() != 2 {
return Err(CliError::usage(
"config set 需要键路径与值:ull config set <key> <value>",
));
}
Ok(Command::ConfigSet(ConfigSetArgs {
key: parsed.positionals[0].clone(),
value: parsed.positionals[1].clone(),
}))
}
other => Err(CliError::usage(format!(
"config 的子命令只有 get|set,收到 `{other}`"
))),
}
}
fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) {
None | Some("list") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?;
Ok(Command::JavaList)
}
Some("vendors") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?;
Ok(Command::JavaVendors)
}
Some("install") => {
let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &["vendor"], &[])?;
let raw = require_single_positional(&parsed, "java install <major>")?;
let major = raw
.parse::<u32>()
.map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?;
let vendor = match parsed.value("vendor") {
Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| {
CliError::usage(format!(
"--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`"
))
})?),
None => None,
};
Ok(Command::JavaInstall { major, vendor })
}
Some(other) => Err(CliError::usage(format!(
"java 的子命令只有 list|install|vendors,收到 `{other}`"
))),
}
}
/// 单个位置参数(多一个少一个都算用法错误)。
fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result<String, CliError> {
match parsed.positionals.as_slice() {
[only] => Ok(only.clone()),
[] => Err(CliError::usage(format!("缺少参数:ull {usage}"))),
_ => Err(CliError::usage(format!("参数过多:ull {usage}"))),
}
}
fn optional_count(parsed: &ParsedArgs, name: &str, default: usize) -> Result<usize, CliError> {
match parsed.value(name) {
Some(raw) => raw
.parse::<usize>()
.map_err(|_| CliError::usage(format!("--{name} 需要整数,收到 `{raw}`"))),
None => Ok(default),
}
}
fn ensure_no_extra(args: &[String], usage: &str) -> Result<(), CliError> {
if args.is_empty() {
Ok(())
} else {
Err(CliError::usage(format!(
"{usage} 不接受参数:{}",
args.join(" ")
)))
}
}
fn ensure_no_positionals(parsed: &ParsedArgs, usage: &str) -> Result<(), CliError> {
ensure_no_extra(&parsed.positionals, usage)
}
/// 解析后的一条子命令参数。
struct ParsedArgs {
positionals: Vec<String>,
flags: BTreeMap<String, Option<String>>,
}
impl ParsedArgs {
fn value(&self, name: &str) -> Option<String> {
self.flags.get(name).cloned().flatten()
}
fn has(&self, name: &str) -> bool {
self.flags.contains_key(name)
}
}
/// 取值形 flag 的下一 token。
///
/// 下一个 token 若以 `--` 开头,说明用户漏了值(`ull launch x --java --dry-run`),
/// 此时报用法错误,而不是把 flag 当值吞掉后在下游报出费解的「找不到 Java」。
/// 负数(`-1`)不以 `--` 开头,不会被误伤。
fn take_value(args: &[String], index: usize, name: &str) -> Result<String, CliError> {
let value = args
.get(index)
.ok_or_else(|| CliError::usage(format!("--{name} 需要一个值")))?;
if value.starts_with("--") {
return Err(CliError::usage(format!(
"--{name} 需要一个值,但收到选项 `{value}`"
)));
}
Ok(value.clone())
}
/// `--flag` / `--flag=value` / `--flag value` + 位置参数。
fn parse_flags(args: &[String], valued: &[&str], flags: &[&str]) -> Result<ParsedArgs, CliError> {
let mut parsed = ParsedArgs {
positionals: Vec::new(),
flags: BTreeMap::new(),
};
let mut index = 0;
while index < args.len() {
let arg = &args[index];
let Some(body) = arg.strip_prefix("--") else {
parsed.positionals.push(arg.clone());
index += 1;
continue;
};
let (name, inline) = match body.split_once('=') {
Some((name, value)) => (name, Some(value.to_string())),
None => (body, None),
};
if valued.contains(&name) {
let value = match inline {
Some(value) => value,
None => {
index += 1;
take_value(args, index, name)?
}
};
parsed.flags.insert(name.to_string(), Some(value));
} else if flags.contains(&name) {
if inline.is_some() {
return Err(CliError::usage(format!("--{name} 不接受值")));
}
parsed.flags.insert(name.to_string(), None);
} else {
return Err(CliError::usage(format!("未知选项 --{name}")));
}
index += 1;
}
Ok(parsed)
}
+68
View File
@@ -0,0 +1,68 @@
//! `ull accounts` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。
//!
//! 语法:
//! - `ull accounts [list]`
//! - `ull accounts add offline <name>`
//! - `ull accounts add msa`(别名 `microsoft`)
//! - `ull accounts remove <name>`(别名 `rm`)
//!
//! 只开这三条轨道入口:authlib 的密码登录不走 CLI(密码进 argv 会泄露,见
//! `DevDocs/SubAgentsTalk/auth-接口约定.md` §4)。
use super::{ensure_no_positionals, parse_flags, require_single_positional};
use crate::cli::model::{AccountsAddArgs, AccountsAddKind, Command};
use crate::error::CliError;
/// 解析 `accounts ...`。
pub(super) fn parse_accounts(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Ok(Command::AccountsList);
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"list" => {
let parsed = parse_flags(rest, &[], &[])?;
ensure_no_positionals(&parsed, "accounts list")?;
Ok(Command::AccountsList)
}
"add" => parse_add(rest),
"remove" | "rm" => {
let parsed = parse_flags(rest, &[], &[])?;
let name = require_single_positional(&parsed, "accounts remove <name>")?;
Ok(Command::AccountsRemove { name })
}
other => Err(CliError::usage(format!(
"accounts 的子命令只有 list|add|remove,收到 `{other}`"
))),
}
}
fn parse_add(rest: &[String]) -> Result<Command, CliError> {
let Some(kind) = rest.first().map(String::as_str) else {
return Err(
CliError::usage("accounts add 需要轨道:offline|msa").with_hint(
"ull accounts add offline <玩家名> / ull accounts add msa(设备码交互式登录)",
),
);
};
let parsed = parse_flags(rest.get(1..).unwrap_or_default(), &[], &[])?;
match kind {
"offline" => {
let name = require_single_positional(&parsed, "accounts add offline <name>")?;
Ok(Command::AccountsAdd(AccountsAddArgs {
kind: AccountsAddKind::Offline,
name: Some(name),
}))
}
"msa" | "microsoft" => {
ensure_no_positionals(&parsed, "accounts add msa")?;
Ok(Command::AccountsAdd(AccountsAddArgs {
kind: AccountsAddKind::Microsoft,
name: None,
}))
}
other => Err(CliError::usage(format!(
"accounts add 的轨道只有 offline|msa,收到 `{other}`"
))),
}
}
+66
View File
@@ -0,0 +1,66 @@
//! `agent` 子命令解析(`serve` / `config`)。
//!
//! 与 `parse::accounts` 同样的拆分理由:`parse.rs` 挨着 400 行硬上限,
//! 而且「MCP server 的参数」本来就是一撮独立的东西,混在主解析器里迟早长成一片。
use crate::cli::model::{AgentServeArgs, Command};
use crate::cli::parse::ParsedArgs;
use crate::error::CliError;
use super::{ensure_no_positionals, parse_flags};
/// 解析 `ull agent <sub>`。
///
/// # Errors
/// 缺子命令、未知子命令、未知选项、端口不是 1~65535 时返回用法错误。
pub fn parse_agent(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("agent 需要子命令 serve|config")
.with_hint("ull agent serve / ull agent config"));
};
let rest = tail.get(1..).unwrap_or_default();
match sub {
"serve" => parse_serve(rest),
"config" => parse_config(rest),
other => Err(CliError::usage(format!(
"agent 的子命令只有 serve|config,收到 `{other}`"
))),
}
}
fn parse_serve(rest: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
rest,
&["port", "host"],
&["stdio", "allow-write", "readonly"],
)?;
ensure_no_positionals(&parsed, "agent serve")?;
if parsed.has("allow-write") && parsed.has("readonly") {
return Err(CliError::usage(
"--allow-write 与 --readonly 不能同时给(二选一)",
));
}
Ok(Command::AgentServe(AgentServeArgs {
stdio: parsed.has("stdio"),
port: optional_port(&parsed)?,
host: parsed.value("host"),
allow_write: parsed.has("allow-write"),
}))
}
fn parse_config(rest: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(rest, &["port"], &[])?;
ensure_no_positionals(&parsed, "agent config")?;
Ok(Command::AgentConfig {
port: optional_port(&parsed)?,
})
}
fn optional_port(parsed: &ParsedArgs) -> Result<Option<u16>, CliError> {
match parsed.value("port") {
Some(raw) => Ok(Some(raw.parse::<u16>().map_err(|_| {
CliError::usage(format!("--port 需要 1~65535,收到 `{raw}`"))
})?)),
None => Ok(None),
}
}
+198
View File
@@ -0,0 +1,198 @@
//! `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(" 离线:ull accounts add offline <玩家名>".to_string());
human.push(" 微软:ull 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");
}
}
+135
View File
@@ -0,0 +1,135 @@
//! `ull agent`:对外 MCP server 的 headless 入口(02 文档 §1 的第二形态)。
//!
//! 两个子命令:
//! - `agent serve`:长驻服务(`--stdio` 或 Streamable HTTP on 127.0.0.1);
//! - `agent config`:打印 Claude Code / Cursor 的配置样例(含当前端口与令牌状态)。
//!
//! 为什么 `serve` 不能走 `commands::run` 的「返回 Report 再打印」套路:stdio 形态下
//! **stdout 就是 JSON-RPC 流**,任何一行多余输出都会让客户端解析失败。所以 runner
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
pub mod ports;
pub mod serve;
use serde_json::{json, Value};
use unlucky_agent::mcp::config_samples;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::AgentServeArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull agent serve`:起服务(阻塞)。
///
/// # Errors
/// 见 [`serve::run`]。
pub fn run_serve(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
serve::run(ctx, args)
}
/// runner 的分流入口:解析数据根 → 起服务。错误由 runner 统一打印。
///
/// # Errors
/// 数据根缺失、平台不达基线、端口占用、非回环监听等返回用户错误。
pub fn run_serve_with(
globals: &crate::cli::Globals,
args: &AgentServeArgs,
err: &mut dyn std::io::Write,
) -> Result<i32, CliError> {
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
for note in ctx.notes() {
let _ = writeln!(err, "提示: {note}");
}
run_serve(&ctx, args)
}
/// `ull agent config`:输出两份可粘贴的客户端配置 + 当前设置状态。
///
/// # Errors
/// 配置不可读时返回用户错误(schema 不认识的键不影响)。
pub fn config_report(ctx: &Ctx, port: Option<u16>) -> Result<Report, CliError> {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let doc = store.doc();
let enabled = doc
.effective("agent.mcp.enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let allow_write = doc
.effective("agent.mcp.allow_write")
.and_then(Value::as_bool)
.unwrap_or(false);
let yolo = doc
.effective("ai.yolo")
.and_then(Value::as_bool)
.unwrap_or(false);
let token = doc
.effective_str("agent.mcp.token")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
let configured_port = doc
.effective("agent.mcp.port")
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(47823);
let port = port.unwrap_or(configured_port);
let url = format!("http://127.0.0.1:{port}/mcp");
let samples = config_samples(&url, token.as_deref());
let registry = unlucky_agent::tools::build_v1_registry(&ports::build(ctx))
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?;
let read_only_tools = registry.read_only_definitions().len();
let all_tools = registry.len();
let data = json!({
"url": url,
"enabled": enabled,
"allow_write": allow_write,
"yolo": yolo,
"token_configured": token.is_some(),
"read_only_tools": read_only_tools,
"all_tools": all_tools,
"claude_code": samples.claude_code,
"cursor": samples.cursor,
});
let mut human = vec![
format!("MCP 端点:{url}"),
format!(
"开关:{} · 写权限:{} · YOLO:{} · 令牌:{}",
if enabled {
"已启用"
} else {
"未启用(serve 仍可手动起)"
},
if allow_write {
"开"
} else {
"关(只读)"
},
if yolo { "开" } else { "关" },
if token.is_some() {
"已设置"
} else {
"未设置"
}
),
"Claude Code(~/.claude.json 或 `claude mcp add --json`):".to_string(),
];
human.extend(pretty_lines(&samples.claude_code));
human.push("Cursor(.cursor/mcp.json):".to_string());
human.extend(pretty_lines(&samples.cursor));
human.push(
"启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(),
);
Ok(Report::new(data, human))
}
fn pretty_lines(value: &Value) -> Vec<String> {
match serde_json::to_string_pretty(value) {
Ok(text) => text.lines().map(|line| format!(" {line}")).collect(),
Err(_) => vec![" (序列化失败)".to_string()],
}
}
@@ -0,0 +1,49 @@
//! 账户端口:只读 `AccountStore::accounts()`(公开字段)。**凭据红线在这里落闸**。
//!
//! `AccountStore::credentials()` 存在,但本文件**不调用**它——agent 侧也没有能承接
//! `Credentials` 的类型([`unlucky_agent::port::AccountSummary`])。这条界线不是靠自觉:
//! 只要有人想从工具结果里带出 token,就必须先改 `AccountSummary`,而 `port::game` 的
//! 单测会先炸(序列化结果里不许出现 token/password/secret)。
use unlucky_agent::port::game::{AccountPort, AccountSummary};
use unlucky_agent::port::PortResult;
use unlucky_core::auth::AccountStore;
/// M31 账户库的只读端口。
#[derive(Debug, Clone)]
pub struct CoreAccounts {
root: std::path::PathBuf,
}
impl CoreAccounts {
/// `root` = `<数据根>/.ULL/data/auth`。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self { root: root.into() }
}
}
impl AccountPort for CoreAccounts {
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
let store = AccountStore::open(self.root.clone());
if !store.warnings().is_empty() {
// 账户文件有坏条目:不阻断(用户还能看到其余账户),但要留痕到 stderr。
eprintln!(
"[unlucky-agent] 账户库有 {} 条提示(只读本工具不受影响)",
store.warnings().len()
);
}
Ok(store
.accounts()
.iter()
.map(|meta| AccountSummary {
id: meta.id.as_str().to_string(),
kind: meta.kind.as_str().to_string(),
profile_id: meta.profile_id.clone(),
profile_name: meta.profile_name.clone(),
login_name: meta.login_name.clone(),
created_at_ms: meta.created_at_ms,
})
.collect())
}
}
@@ -0,0 +1,187 @@
//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。
//!
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
use std::collections::BTreeMap;
use unlucky_agent::port::{
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
PortResult,
};
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
use super::events::{tail_join, SessionSource};
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// M15 规则引擎的端口实现。
#[derive(Debug, Clone)]
pub struct CoreDiagnose {
source: SessionSource,
tail: usize,
}
impl CoreDiagnose {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
tail: DEFAULT_TAIL,
}
}
}
fn map_kind(kind: CoreFixKind) -> FixActionKind {
match kind {
CoreFixKind::ChangeJava => FixActionKind::ChangeJava,
CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory,
CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics,
CoreFixKind::RemoveMod => FixActionKind::RemoveMod,
CoreFixKind::Reinstall => FixActionKind::Reinstall,
CoreFixKind::SeekHelp => FixActionKind::SeekHelp,
}
}
fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
let core_candidate = finding.to_candidate();
DiagnosisCandidate {
id: core_candidate.id,
severity: core_candidate.severity.to_string(),
category: core_candidate.category.to_string(),
confidence: core_candidate.confidence,
message_key: core_candidate.message_key,
message: core_candidate.message,
detail: core_candidate.detail.unwrap_or_default(),
evidence: core_candidate.evidence,
fix_actions: core_candidate
.fix_actions
.into_iter()
.map(|action| FixAction {
kind: map_kind(action.kind),
tool: action.tool.to_string(),
label: String::new(),
args: action
.args
.into_iter()
.collect::<BTreeMap<String, String>>(),
})
.collect(),
}
}
impl DiagnosePort for CoreDiagnose {
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
let entry = self.source.find(session_id)?;
let events = self.source.events(&entry)?;
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr.join("\n"),
);
let mut input = DiagnosisInput::new().with_exit(exit);
let stdout_text = tail_join(&stdout, self.tail);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
let stderr_text = tail_join(&stderr, self.tail);
if !stderr_text.is_empty() {
input.push_text(SignalOrigin::Stderr, stderr_text);
}
let loader = RuleLoader::embedded();
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
let report = diagnoser.analyze(&input);
Ok(CrashDiagnosis {
session_id: entry.session_id.clone(),
source: "rules".to_string(),
needs_more_info: report.needs_more_info(),
candidates: report.findings.iter().map(candidate_of).collect(),
secondary: report.secondary.iter().map(candidate_of).collect(),
evidence: stderr.iter().rev().take(3).cloned().collect(),
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn seed(root: &std::path::Path, stderr_line: &str) {
let game = root.join("game");
std::fs::create_dir_all(&game).expect("建目录");
let entry = json!({
"v": 1,
"session_id": "20261029T124501-9f3c2a",
"instance_id": "inst-1",
"instance_name": "1.20.1 Forge",
"game_version": "1.20.1",
"started_at": "2026-10-29T12:45:01.123Z",
"ended_at": "2026-10-29T12:45:05.123Z",
"duration_ms": 4000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 1,
"log_path": "20261029T124501-9f3c2a.jsonl",
"crash_found": true,
"diagnosis_top": null
});
std::fs::write(game.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
let event = json!({
"seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0,
"kind": "stderr",
"payload": { "line": stderr_line, "level": "error" }
});
std::fs::write(
game.join("20261029T124501-9f3c2a.jsonl"),
format!("{event}\n"),
)
.expect("写事件");
}
#[test]
fn runs_the_rule_engine_over_a_session() {
let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
seed(
&root,
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher",
);
let port = CoreDiagnose::new(root.clone());
let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断");
assert_eq!(report.session_id, "20261029T124501-9f3c2a");
assert_eq!(report.source, "rules");
assert_eq!(report.exit.code, Some(1));
// 规则集里应有 JVM/完整性类结论命中这条 stderr
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unknown_session_is_a_readable_error() {
let root =
std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id()));
let port = CoreDiagnose::new(root);
let err = port.diagnose("nope").unwrap_err();
assert!(err.contains("找不到会话"), "{err}");
}
}
@@ -0,0 +1,136 @@
//! 会话数据的读取底座:索引(`index.jsonl`)+ 事件流(`<id>.jsonl`)。
//!
//! 三个端口([`super::session::CoreSessions`] 的列表/切片、[`super::diagnose::CoreDiagnose`]
//! 的规则跑批)共用这一份读取逻辑——「同一份 jsonl 两种读法」若各写一遍,
//! 迟早出现「列表说有 5 条事件、诊断只看到 3 条」这种对不上的账。
//!
//! 形状依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5。
use std::path::{Path, PathBuf};
use serde_json::Value;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use unlucky_agent::port::PortResult;
/// 一个数据根下的会话索引 + 事件流。
#[derive(Debug, Clone)]
pub struct SessionSource {
root: PathBuf,
}
impl SessionSource {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
/// 索引文件路径。
#[must_use]
pub fn index(&self) -> PathBuf {
session_index::index_path(&self.root)
}
/// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。
///
/// # Errors
/// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。
pub fn entries(&self) -> PortResult<Vec<SessionIndexEntry>> {
let index = self.index();
let mut entries = session_index::read_all(&index)
.map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?;
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(entries)
}
/// 按完整 id 或唯一前缀找会话。
///
/// # Errors
/// 找不到 / 前缀歧义时返回用户可读错误。
pub fn find(&self, key: &str) -> PortResult<SessionIndexEntry> {
let entries = self.entries()?;
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
return Ok(entry.clone());
}
let matches: Vec<&SessionIndexEntry> = entries
.iter()
.filter(|entry| entry.session_id.starts_with(key))
.collect();
match matches.as_slice() {
[only] => Ok((*only).clone()),
[] => Err(format!(
"找不到会话 `{key}`(索引:{})",
self.index().display()
)),
_ => Err(format!(
"`{key}` 匹配到 {} 个会话,请给更长的前缀",
matches.len()
)),
}
}
/// 一个会话的原始事件行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
///
/// # Errors
/// 事件流文件读不了时返回用户可读错误。
pub fn events(&self, entry: &SessionIndexEntry) -> PortResult<Vec<Value>> {
let path = self.event_path(entry);
let text = std::fs::read_to_string(&path)
.map_err(|error| format!("读会话事件流 {} 失败:{error}", path.display()))?;
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect())
}
/// 事件流文件路径。
#[must_use]
pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf {
self.root.join("game").join(&entry.log_path)
}
/// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。
///
/// 返回 `(stdout 行, stderr 行, stderr 里 level=error 的条数)`。
#[must_use]
pub fn split_text(events: &[Value]) -> (Vec<String>, Vec<String>, u32) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut error_lines = 0u32;
for event in events {
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
continue;
};
match event.get("kind").and_then(Value::as_str) {
Some("stdout") => stdout.push(line.to_string()),
Some("stderr") => {
stderr.push(line.to_string());
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
error_lines += 1;
}
}
_ => {}
}
}
(stdout, stderr, error_lines)
}
}
/// 取末尾 `tail` 行并拼成文本(诊断输入用)。
#[must_use]
pub fn tail_join(lines: &[String], tail: usize) -> String {
let start = lines.len().saturating_sub(tail.max(1));
let mut text = lines[start..].join("\n");
if !text.is_empty() {
text.push('\n');
}
text
}
/// 索引里有没有这个会话(存在性判断,不读事件流)。
#[must_use]
pub fn index_exists(root: &Path) -> bool {
session_index::index_path(root).is_file()
}
+204
View File
@@ -0,0 +1,204 @@
//! 实例 / 版本 / Java 端口(M14 + M33)。
use std::path::PathBuf;
use unlucky_agent::port::game::{
GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, VersionSummary,
};
use unlucky_agent::port::PortResult;
use unlucky_core::game::java::{self, JavaOrigin};
use unlucky_core::game::{install::instance::read_profiles, GameLayout};
use unlucky_core::platform::data_dir::DataMode;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 实例 / 版本 / Java 的只读端口。
#[derive(Debug, Clone)]
pub struct CoreGame {
layout: LauncherLayout,
game: GameLayout,
launcher_config: PathBuf,
mode: DataMode,
platform: Platform,
}
impl CoreGame {
/// 装配:`layout` 是 `.ULL/`,`game` 是 `.minecraft/`。
#[must_use]
pub fn new(
layout: LauncherLayout,
game: GameLayout,
mode: DataMode,
platform: Platform,
) -> Self {
let launcher_config = layout.launcher_config();
Self {
layout,
game,
launcher_config,
mode,
platform,
}
}
fn store(&self) -> SettingStore {
SettingStore::load(&self.launcher_config, launcher_schema())
}
fn managed_roots(&self) -> Vec<PathBuf> {
let store = self.store();
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
java::location::scan_roots(
self.mode,
&self.layout,
install_root.as_deref(),
self.platform,
&ProcessEnv,
)
}
}
impl GamePort for CoreGame {
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
let profiles =
read_profiles(&self.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
let mut out: Vec<InstanceSummary> = profiles
.profiles
.iter()
.map(|profile| InstanceSummary {
id: profile.id.as_str().to_string(),
name: profile.name.clone(),
version_id: profile.version_id.clone(),
version_present: self.game.is_version_installed(&profile.version_id),
game_dir: profile.game_dir.as_ref().map(|p| p.display().to_string()),
java_path: profile.java_path.as_ref().map(|p| p.display().to_string()),
last_played_at: profile.last_played_at.as_deref().and_then(parse_rfc3339_ms),
})
.collect();
// `profiles.json` 的顺序是 UI 顺序;这里按最近启动时间倒序(最近的先看)。
out.sort_by_key(|instance| std::cmp::Reverse(instance.last_played_at));
Ok(out)
}
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
let versions = self
.game
.installed_versions()
.map_err(|error| format!("读已安装版本失败:{error}"))?;
let profiles = read_profiles(&self.layout).map_err(|error| error.to_string())?;
Ok(versions
.into_iter()
.map(|id| {
let instance_count = profiles
.profiles
.iter()
.filter(|profile| profile.version_id == id)
.count();
VersionSummary {
id,
kind: None,
instance_count,
}
})
.collect())
}
fn java_status(&self) -> PortResult<JavaStatus> {
let roots = self.managed_roots();
let mut candidates = java::candidates_in_roots(&roots);
if let Ok(profiles) = read_profiles(&self.layout) {
for profile in &profiles.profiles {
if let Some(path) = &profile.java_path {
candidates.push(java::probe(path, JavaOrigin::Instance));
}
}
}
let candidates = java::discover::dedupe(candidates);
let summary: Vec<JavaRuntimeSummary> = candidates
.iter()
.map(|runtime| JavaRuntimeSummary {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone(),
major: runtime.major,
vendor: runtime.vendor.clone(),
origin: runtime.origin.category().to_string(),
})
.collect();
let default_memory = self
.store()
.effective("java.default_memory_mb")
.and_then(serde_json::Value::as_u64);
Ok(JavaStatus {
default_memory_mb: default_memory,
managed_roots: roots
.iter()
.map(|root| root.display().to_string())
.collect(),
candidates: summary,
})
}
}
/// RFC3339 → Unix 毫秒(解析失败返回 `None`,不让一个坏时间戳毁掉整个列表)。
///
/// 只处理 launch 写出来的那一种形态(总是 UTC、总是 `Z` 结尾)。不引第三方日期库:
/// agent 侧只需要「排序用的毫秒」,多一棵依赖树不值当。
fn parse_rfc3339_ms(text: &str) -> Option<u64> {
let body = text.trim().trim_end_matches('Z');
let (date, time) = body.split_once('T')?;
let mut date_parts = date.split('-');
let year: i64 = date_parts.next()?.parse().ok()?;
let month: i64 = date_parts.next()?.parse().ok()?;
let day: i64 = date_parts.next()?.parse().ok()?;
let mut time_parts = time.split(':');
let hour: i64 = time_parts.next()?.parse().ok()?;
let minute: i64 = time_parts.next()?.parse().ok()?;
let seconds_text = time_parts.next()?;
let (secs, millis) = match seconds_text.split_once('.') {
Some((secs, frac)) => (secs.parse::<i64>().ok()?, {
let padded = format!("{frac:0<3}");
padded.get(..3)?.parse::<i64>().ok()?
}),
None => (seconds_text.parse::<i64>().ok()?, 0),
};
let days = days_from_civil(year, month, day);
let total_ms = (((days * 24 + hour) * 60 + minute) * 60 + secs) * 1000 + millis;
u64::try_from(total_ms).ok()
}
/// Howard Hinnant 的 `days_from_civil`(1970-01-01 为 0)。
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_launch_timestamps() {
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0));
// 与既有审计测试同源:1_793_304_753_123 = 2026-10-29T20:12:33.123Z
assert_eq!(
parse_rfc3339_ms("2026-10-29T20:12:33.123Z"),
Some(1_793_304_753_123)
);
assert_eq!(parse_rfc3339_ms("not-a-time"), None);
}
}
@@ -0,0 +1,58 @@
//! 端口装配:把 `unlucky-core` 的实现塞进 agent 的五个端口。
//!
//! 为什么这一层在 CLI 而不在 agent:agent 只知道 trait(`#!` 依赖倒置),
//! `crates/app` 是 GUI 的装配处,而 `ull agent serve` 是**headless 的另一个装配入口**。
//! 放这里让 CLI 自洽,也让 GUI 后续复用同一批适配器(把本目录上移到 app 是一次纯搬家)。
//!
//! 每个文件一个端口,与 `crates/agent/src/port*.rs` 一一对应,读起来不用跳来跳去。
pub mod accounts;
pub mod diagnose;
pub mod events;
pub mod game;
pub mod session;
pub mod setting;
use std::sync::Arc;
use unlucky_agent::port::Ports;
use unlucky_core::platform::data_dir::DataMode;
use unlucky_core::platform::paths::Platform;
use crate::context::Ctx;
pub use accounts::CoreAccounts;
pub use diagnose::CoreDiagnose;
pub use game::CoreGame;
pub use session::CoreSessions;
pub use setting::CoreSetting;
/// 按一次 CLI 上下文装配五个端口。
#[must_use]
pub fn build(ctx: &Ctx) -> Ports {
Ports {
// 数据根键(`core.*`)落便携目录那份,与 `Ctx::resolve` 同源——CLI `config set`
// 与 AI `set_config` 共用 `Ctx` 给出的两个落点(finding …-agent-settingport-core-ai)。
setting: Arc::new(CoreSetting::split(
ctx.launcher_config(),
ctx.portable_launcher_config(),
)),
sessions: Arc::new(CoreSessions::new(ctx.sessions_root())),
diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())),
game: Arc::new(CoreGame::new(
ctx.layout(),
ctx.game(),
data_mode(ctx),
platform(ctx),
)),
accounts: Arc::new(CoreAccounts::new(ctx.auth_data())),
}
}
pub(crate) fn data_mode(ctx: &Ctx) -> DataMode {
ctx.data.mode
}
pub(crate) fn platform(ctx: &Ctx) -> Platform {
ctx.platform
}
@@ -0,0 +1,201 @@
//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。
use serde_json::{json, Value};
use unlucky_agent::port::{
GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult,
};
use super::events::SessionSource;
/// M18 的会话索引 + 事件流。
#[derive(Debug, Clone)]
pub struct CoreSessions {
source: SessionSource,
}
impl CoreSessions {
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
}
}
}
fn summary_of(
entry: &unlucky_core::launch::session_index::SessionIndexEntry,
) -> GameSessionSummary {
GameSessionSummary {
session_id: entry.session_id.clone(),
instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()),
instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()),
game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()),
started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()),
ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()),
duration_ms: entry.duration_ms,
exit_code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
window_detected: entry.window_detected,
event_count: entry.event_count,
crash_found: entry.crash_found,
diagnosis_top: entry.diagnosis_top.clone(),
}
}
fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent {
LogEvent {
seq: event
.get("seq")
.and_then(Value::as_u64)
.unwrap_or(fallback_seq),
ts: event
.get("ts")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0),
kind: event
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
payload: event.get("payload").cloned().unwrap_or(Value::Null),
}
}
impl GameSessionPort for CoreSessions {
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>> {
let mut entries = self.source.entries()?;
if let Some(instance) = instance_id {
entries.retain(|entry| entry.instance_id == instance);
}
entries.truncate(limit);
Ok(entries.iter().map(summary_of).collect())
}
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
let entry = self.source.find(session_id)?;
let raw = self.source.events(&entry)?;
let events: Vec<LogEvent> = raw
.iter()
.enumerate()
.map(|(index, event)| to_log_event(event, index as u64))
.collect();
let mut kind_counts = std::collections::BTreeMap::new();
for event in &events {
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
}
let matched: Vec<&LogEvent> = events
.iter()
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
.collect();
let max = if query.max_events == 0 {
matched.len()
} else {
query.max_events
};
let truncated = matched.len() > max;
Ok(LogPage {
session_id: entry.session_id,
total_events: events.len() as u64,
matched_events: matched.len() as u64,
truncated,
kind_counts,
first_ts: events.first().map(|e| e.ts.clone()),
last_ts: events.last().map(|e| e.ts.clone()),
events: matched.into_iter().take(max).cloned().collect(),
})
}
}
/// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。
#[must_use]
pub fn log_event_json(event: &LogEvent) -> Value {
json!({
"seq": event.seq,
"ts": event.ts,
"t_ms": event.t_ms,
"kind": event.kind,
"payload": event.payload,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn seed_session(root: &Path) {
let game_dir = root.join("game");
std::fs::create_dir_all(&game_dir).expect("建目录");
let entry = json!({
"v": 1,
"session_id": "20261029T124501-9f3c2a",
"instance_id": "inst-1",
"instance_name": "1.20.1 Forge",
"game_version": "1.20.1",
"started_at": "2026-10-29T12:45:01.123Z",
"ended_at": "2026-10-29T12:46:31.123Z",
"duration_ms": 90000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": true,
"event_count": 2,
"log_path": "20261029T124501-9f3c2a.jsonl",
"crash_found": true,
"diagnosis_top": "integrity.forge_missing_library"
});
std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
let events = [
json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}),
json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}),
];
let text: String = events
.iter()
.map(|event| format!("{event}\n"))
.collect::<String>();
std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件");
}
#[test]
fn lists_and_slices_a_session() {
let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
seed_session(&root);
let port = CoreSessions::new(root.clone());
let sessions = port.list_sessions(10, None).expect("应能列出");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].exit_code, Some(1));
assert!(sessions[0].crash_found);
let page = port
.read_log(
"20261029T124501",
&LogQuery {
max_events: 1,
kinds: vec!["stderr".to_string()],
..LogQuery::default()
},
)
.expect("应能读");
assert_eq!(page.total_events, 2);
assert_eq!(page.events.len(), 1);
assert_eq!(page.events[0].kind, "stderr");
assert!(page.kind_counts.contains_key("stdout"));
let _ = std::fs::remove_dir_all(&root);
}
}
@@ -0,0 +1,211 @@
//! 配置端口:`core::setting` 的直译(**唯一真相源**在 core,这里不复制校验规则)。
//!
//! 三条纪律:
//! 1. 每次调用都重新 `SettingStore::load`——外部 MCP 客户端可能刚改了配置,
//! 缓存一份快照会让审计里的 `old` 骗人;
//! 2. **敏感值一律掩码**(M12 `Sensitivity::High` + agent 的凭据词表两道):
//! 工具结果会回灌模型、也可能发往远端 LLM,AI 只需要知道「这个键是什么、怎么改」;
//! 3. **落点与 `ull config` 同源**(分流规则仍只在 `crate::context` 一处):数据根键
//! 读写便携目录那份 `launcher.json`,其余键读写解析后的数据根。只持一份路径时
//! standard / `--data-dir` 模式下 AI 改 `core.data_mode` 会「写了没反应」
//! (finding 20261031-worker-chore40-improve-agent-settingport-core-ai)。
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use unlucky_agent::confirm::Sensitivity;
use unlucky_agent::port::{PortResult, SettingChange, SettingKey, SettingPort};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::{Sensitivity as CoreSensitivity, ValueKind};
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
use crate::context::is_data_root_key;
/// 掩码占位(与 CLI `config get` 同一串,读输出的人不用学两套)。
pub const MASK: &str = "<redacted>";
/// 把 `core::setting` 包成 agent 的 [`SettingPort`]。
#[derive(Debug, Clone)]
pub struct CoreSetting {
/// 解析后的数据根那份 `launcher.json`(非数据根键的落点)。
path: PathBuf,
/// 便携目录那份 `launcher.json`(数据根键的落点);与 `path` 重合或拿不到
/// exe 目录时为 `None`。
core_path: Option<PathBuf>,
}
impl CoreSetting {
/// 只指向数据根那份 `<数据根>/.ULL/config/launcher.json`(两份落点重合时够用)。
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self::split(path, None)
}
/// 数据根那份 + 便携目录那份(`Ctx::portable_launcher_config`,与数据根解析同源)。
///
/// 两份路径重合(便携模式)时只留一份,免得同一个文件被加载两次。
#[must_use]
pub fn split(path: impl Into<PathBuf>, core_path: Option<PathBuf>) -> Self {
let path = path.into();
let core_path = core_path.filter(|core| core.as_path() != path.as_path());
Self { path, core_path }
}
/// 某个键的落点文件(数据根键 → 便携目录那份,其余 → 数据根那份)。
fn path_for(&self, key: &str) -> &Path {
match &self.core_path {
Some(core) if is_data_root_key(key) => core.as_path(),
_ => self.path.as_path(),
}
}
/// 按落点加载 store(读写都用它,`get`/`set` 不会各看一份文件)。
fn store_for(&self, key: &str) -> SettingStore {
SettingStore::load(self.path_for(key), launcher_schema())
}
/// 数据根那份(schema / 白名单等与落点无关的元数据都从它出)。
fn store(&self) -> SettingStore {
SettingStore::load(&self.path, launcher_schema())
}
}
/// `summary` 里某个键的**原始**取值来源(掩码在 `summary` 里做):数据根键取便携目录
/// 那份——也就是 `Ctx::resolve` 读的同一份,否则概要和「真正生效值」是两套真相。
fn raw_value<'a>(
data: &'a SettingStore,
core: Option<&'a SettingStore>,
key: &str,
) -> Option<&'a Value> {
match core {
Some(core) if is_data_root_key(key) => core.effective(key),
_ => data.effective(key),
}
}
fn sensitivity_of(value: CoreSensitivity) -> Sensitivity {
match value {
CoreSensitivity::Low => Sensitivity::Low,
CoreSensitivity::Medium => Sensitivity::Medium,
CoreSensitivity::High => Sensitivity::High,
}
}
fn kind_label(kind: &ValueKind) -> &'static str {
match kind {
ValueKind::Bool => "boolean",
ValueKind::Int { .. } => "integer",
ValueKind::Float { .. } => "number",
ValueKind::Text { .. } => "string",
ValueKind::Path => "path",
ValueKind::Enum(_) => "enum",
ValueKind::TextList => "string[]",
ValueKind::EnumList(_) => "enum[]",
ValueKind::Any => "json",
}
}
/// 该键的值要不要在 AI 侧掩码:凭据词表命中,或 schema 标 `High`。
fn mask(value: Value, sensitive: bool) -> Value {
if sensitive {
json!(MASK)
} else {
value
}
}
impl SettingPort for CoreSetting {
/// 全量概要:每个键的生效值都取自**它自己的落点文件**(数据根键取便携目录那份)。
fn summary(&self) -> Value {
let store = self.store();
let core = self
.core_path
.as_ref()
.map(|path| SettingStore::load(path, launcher_schema()));
let schema = store.doc().schema().clone();
let mut map = Map::new();
for spec in schema.specs() {
if spec.is_deprecated() {
continue;
}
let sensitive = spec.ai.sensitivity == CoreSensitivity::High
|| unlucky_agent::redact::is_sensitive_key(spec.key);
let value = raw_value(&store, core.as_ref(), spec.key)
.cloned()
.unwrap_or(Value::Null);
map.insert(spec.key.to_string(), mask(value, sensitive));
}
Value::Object(map)
}
fn get(&self, path: &str) -> PortResult<Value> {
if unlucky_agent::redact::is_sensitive_key(path) {
return Err(format!(
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看"
));
}
let store = self.store_for(path);
let schema = store.doc().schema().clone();
let Some(value) = store.effective(path).cloned() else {
return Err(format!("配置键 `{path}` 不在 schema 里"));
};
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
Ok(mask(value, sensitive))
}
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
let file = self.path_for(path);
let mut store = self.store_for(path);
let schema = store.doc().schema().clone();
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
let old = store
.effective(path)
.cloned()
.map(|old| mask(old, sensitive));
store
.set(path, value.clone())
.map_err(|error| error.to_string())?;
match store.save() {
Ok(SaveOutcome::Saved(_)) | Ok(SaveOutcome::Skipped(SkipReason::NotDirty)) => {}
// 只读/不可信:**不能**报成功——外部客户端会以为改完了。
Ok(SaveOutcome::Skipped(reason)) => {
return Err(format!(
"{} 当前不可写({});请让用户检查文件权限或内容",
file.display(),
reason.as_str()
))
}
Err(error) => return Err(format!("写 {} 失败:{error}", file.display())),
}
Ok(SettingChange {
path: path.to_string(),
old,
new: mask(value.clone(), sensitive),
})
}
fn writable_keys(&self) -> Vec<SettingKey> {
let store = self.store();
let schema = store.doc().schema().clone();
schema
.specs()
.iter()
.filter(|spec| spec.ai.writable && !spec.is_deprecated())
.map(|spec| SettingKey {
path: spec.key.to_string(),
sensitivity: sensitivity_of(spec.ai.sensitivity),
value_type: kind_label(&spec.kind).to_string(),
description: spec.doc.to_string(),
})
.collect()
}
fn schema(&self) -> Value {
// core 的 `to_json_schema()` 只遍历 `ai.writable && !deprecated`,
// 凭据键(`locked`)天然不在其中——这正是 `get_config_schema` 要的东西。
self.store().doc().schema().to_json_schema()
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,226 @@
//! [`super`] 的单测:数据根键与其余键的落点分流、AI 侧写入真的生效。
//!
//! 与 `commands/config/tests.rs` 同一套造场手法:临时目录 + `Ctx::resolve_in`
//! 把便携目录钉死在临时路径里,不动真实数据根。
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
use crate::cli::Globals;
use crate::commands::agent::ports::build;
use crate::context::Ctx;
fn temp_config() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-core-setting-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir.join("launcher.json")
}
#[test]
fn schema_omits_credential_keys_and_writable_keys_match_policy() {
let port = CoreSetting::new(temp_config());
let schema = port.schema();
let text = schema.to_string();
assert!(!text.contains("ai.api_key"), "凭据键不得进 AI schema");
assert!(text.contains("agent.mcp.port"), "M39 的新键应可写:{text}");
let paths: Vec<String> = port.writable_keys().into_iter().map(|k| k.path).collect();
assert!(paths.contains(&"download.concurrency".to_string()));
assert!(!paths.contains(&"ai.api_key".to_string()));
}
#[test]
fn summary_masks_high_sensitivity_but_keeps_low() {
let port = CoreSetting::new(temp_config());
let summary = port.summary();
assert_eq!(summary["ai.base_url"], json!(MASK), "High 键要掩码");
assert_ne!(
summary["download.concurrency"],
json!(MASK),
"Low 键不必掩码"
);
}
#[test]
fn credential_key_get_is_refused() {
let port = CoreSetting::new(temp_config());
let err = port.get("ai.api_key").unwrap_err();
assert!(err.contains("凭据类配置"), "{err}");
}
#[test]
fn set_then_get_round_trips_a_low_key() {
let path = temp_config();
let port = CoreSetting::new(path.clone());
let change = port
.set("download.concurrency", &json!(16))
.expect("应写入");
assert_eq!(change.new, json!(16));
assert_eq!(port.get("download.concurrency").unwrap(), json!(16));
assert!(path.is_file(), "应落盘 {}", path.display());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("ull-ai-setting-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 正确的 `$schema`(认不出的前缀会让加载降级成只读)。
fn schema_url() -> String {
use unlucky_core::setting::keys::launcher_version;
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
}
fn write_json(path: &Path, value: &Value) {
std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
std::fs::write(path, value.to_string()).expect("写配置");
}
fn read_json(path: &Path) -> Value {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
serde_json::from_slice(&bytes).expect("合法 JSON")
}
/// standard 模式现场:便携目录那份说 standard + 自定义 launcher home(数据根留在临时目录里)。
fn standard_fixture(temp: &TempDir) -> (PathBuf, PathBuf, PathBuf) {
let exe = temp.path().join("exe");
let launcher_home = temp.path().join("lhome");
let portable = exe.join(".ULL").join("config").join("launcher.json");
write_json(
&portable,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": {
"data_mode": "standard",
"launcher_home": launcher_home.display().to_string(),
},
}),
);
(exe, portable, launcher_home)
}
/// **AI 面改 `core.data_mode` 真的生效**:写便携目录那份,下一次解析换模式。
///
/// 走的是 `ports::build` 装出来的同一个端口(`set_config` 工具背后就是它)。
#[test]
fn ai_set_of_core_data_mode_takes_effect_on_the_next_resolve() {
let temp = TempDir::new("core-ai");
let (exe, portable, launcher_home) = standard_fixture(&temp);
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe.clone())).expect("解析数据根");
assert_eq!(ctx.data.mode.as_str(), "standard");
let ports = build(&ctx);
ports
.setting
.set("core.data_mode", &json!("portable"))
.expect("AI 侧写 core.data_mode");
assert_eq!(
read_json(&portable)["core"]["data_mode"],
json!("portable"),
"数据根键要落在便携目录那份"
);
let data_file = launcher_home.join("config").join("launcher.json");
assert!(
!data_file.exists() || read_json(&data_file)["core"].get("data_mode").is_none(),
"数据根那份不该被写入 core.data_mode"
);
let next = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("重新解析");
assert_eq!(
next.data.mode.as_str(),
"portable",
"改了没反应到此为止:下一次解析必须换模式"
);
// 其余键仍走数据根那份,且不污染便携目录那份。
ports
.setting
.set("download.concurrency", &json!(16))
.expect("AI 侧写普通键");
assert_eq!(read_json(&data_file)["download"]["concurrency"], json!(16));
assert!(
read_json(&portable)["download"]
.get("concurrency")
.is_none(),
"普通键不该写进便携目录那份"
);
}
/// 概要的取值来源与解析同源:数据根键取便携那份(哪怕数据根那份留着陈旧值)。
#[test]
fn summary_takes_data_root_keys_from_the_portable_file() {
let temp = TempDir::new("core-summary");
let (exe, portable, launcher_home) = standard_fixture(&temp);
// 老版本 AI 写错文件留下的现场:数据根那份有个陈旧的 core.data_mode。
let data_file = launcher_home.join("config").join("launcher.json");
write_json(
&data_file,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": { "data_mode": "custom" },
"download": { "concurrency": 4 },
}),
);
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("解析数据根");
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
let data = port.store();
let core = SettingStore::load(&portable, launcher_schema());
assert_eq!(
raw_value(&data, Some(&core), "core.data_mode"),
Some(&json!("standard")),
"数据根键取便携那份 = 解析读到的那份"
);
assert_eq!(
raw_value(&data, Some(&core), "download.concurrency"),
Some(&json!(4)),
"其余键仍取数据根那份"
);
// `core.*` 是 M12 的 High 键,概要里照旧掩码(值不可见,但来源必须对)。
assert_eq!(port.summary()["core.data_mode"], json!(MASK));
}
/// 便携模式下两份落点本就是同一份文件:不留第二份,免得重复加载。
#[test]
fn portable_mode_keeps_a_single_landing_file() {
let temp = TempDir::new("core-portable");
let ctx =
Ctx::resolve_in(&Globals::default(), Some(temp.path().to_path_buf())).expect("解析数据根");
assert_eq!(ctx.data.mode.as_str(), "portable");
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
assert_eq!(
port.path_for("core.data_mode"),
ctx.launcher_config().as_path()
);
}
+218
View File
@@ -0,0 +1,218 @@
//! `ull agent serve`:把注册表挂成 MCP server,长驻运行。
//!
//! 装配顺序(每一步都对应 02 文档的一条约束):
//! 1. 设置 → `McpConfig`(`agent.mcp.*`,命令行可覆盖);
//! 2. 端口([`super::ports`])→ 注册表(`tools::build_v1_registry`);
//! 3. 只读模式 → `read_only_subset()`(物理上不含写工具,列表/调用天然一致);
//! 4. 执行器:`JsonlAudit`(落盘 `<data>/agent/audit`)+ fail-closed 闸门 +
//! `ConfirmPolicy { yolo }`(读 `ai.yolo`)+ `DenyGuidance::External`(没有弹窗可点);
//! 5. 传输:stdio 或 Streamable HTTP(127.0.0.1)。
use std::sync::Arc;
use serde_json::Value;
use unlucky_agent::audit::{audit_dir, JsonlAudit};
use unlucky_agent::brain::exec::ToolExecutor;
use unlucky_agent::clock::SystemClock;
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
use unlucky_agent::event::NoopSink;
use unlucky_agent::mcp::{serve_http, serve_stdio, McpConfig, RegistryServer};
use unlucky_agent::tools::build_v1_registry;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::AgentServeArgs;
use crate::context::Ctx;
use crate::error::CliError;
/// 起一次 server(阻塞直到进程被杀/stdio 对端关闭)。
///
/// # Errors
/// 注册表装配失败、端口被占用、非回环监听等返回用户错误。
pub fn run(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
let settings = Settings::load(ctx);
let config = settings.mcp_config(args);
let yolo = settings.yolo();
let read_only = config.read_only;
if !settings.enabled {
eprintln!(
"[unlucky-agent] 提示:设置键 agent.mcp.enabled=false,本次是手动启动(GUI 不会自动起)"
);
}
let ports = super::ports::build(ctx);
let registry = Arc::new(
build_v1_registry(&ports)
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?,
);
// 路由注册表始终是完整的那份;「哪些工具真能跑」由执行器持有的注册表决定
// (只读模式下是 read_only_subset):硬调写工具会得到可读的 tool-level 错误 + 审计,
// 而不是 rmcp 的协议级 -32602(客户端只会显示 "internal error")。
let exposed = if read_only {
Arc::new(registry.read_only_subset())
} else {
Arc::clone(&registry)
};
let audit = Arc::new(JsonlAudit::new(
audit_dir(&ctx.layout().data()),
Arc::new(SystemClock),
));
let executor = Arc::new(
ToolExecutor::new(
Arc::clone(&exposed),
audit,
Arc::new(NoUiGate),
ConfirmPolicy { yolo },
Arc::new(SystemClock),
Arc::new(NoopSink),
)
.with_guidance(DenyGuidance::External),
);
let server = RegistryServer::new(registry, executor, transport_label(args), read_only);
if args.stdio {
// stdio:stdout 是协议流,人类提示一律走 stderr。
eprintln!(
"[unlucky-agent] MCP server 走 stdio({} 个工具;写权限:{};YOLO:{})",
server.tool_count(),
if read_only { "关(只读)" } else { "开" },
yolo
);
return block_on(async move { serve_stdio(server).await })
.map(|()| crate::error::EXIT_OK)
.map_err(|error| CliError::user("mcp_stdio", error.to_string()));
}
let url = config.url();
eprintln!("[unlucky-agent] MCP 端点:{url}");
block_on(async move { serve_http(server, config).await })
.map(|()| crate::error::EXIT_OK)
.map_err(|error| CliError::user("mcp_http", error.to_string()))
}
fn transport_label(args: &AgentServeArgs) -> &'static str {
if args.stdio {
"mcp-stdio"
} else {
"mcp-http"
}
}
/// 在独立线程里跑一个 tokio 运行时(stdio 形态要独占 stdin/stdout,不能被主线程抢)。
fn block_on<F, T>(future: F) -> Result<T, unlucky_agent::mcp::McpServeError>
where
F: std::future::Future<Output = Result<T, unlucky_agent::mcp::McpServeError>> + Send + 'static,
T: Send + 'static,
{
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| unlucky_agent::mcp::McpServeError::Io(error.to_string()))?;
runtime.block_on(future)
}
/// 从 `launcher.json` 读 MCP 相关设置(坏文件退化成默认值,不阻断启动)。
struct Settings {
enabled: bool,
port: u16,
allow_write: bool,
token: Option<String>,
yolo: bool,
}
impl Settings {
fn load(ctx: &Ctx) -> Self {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let doc = store.doc();
let port = doc
.effective("agent.mcp.port")
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(47823);
Self {
enabled: doc
.effective("agent.mcp.enabled")
.and_then(Value::as_bool)
.unwrap_or(false),
port,
allow_write: doc
.effective("agent.mcp.allow_write")
.and_then(Value::as_bool)
.unwrap_or(false),
token: doc
.effective_str("agent.mcp.token")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string),
yolo: doc
.effective("ai.yolo")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
fn mcp_config(&self, args: &AgentServeArgs) -> McpConfig {
McpConfig {
host: args.host.clone().unwrap_or_else(|| "127.0.0.1".to_string()),
port: args.port.unwrap_or(self.port),
token: self.token.clone(),
// 命令行 --allow-write 只能**收紧或放开到设置允许的程度**:
// 设置里没开写权限时,命令行也不放开(设置是用户的意思表示,命令行是运行参数)。
read_only: !(self.allow_write || args.allow_write),
}
}
fn yolo(&self) -> bool {
self.yolo
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args() -> AgentServeArgs {
AgentServeArgs::default()
}
#[test]
fn settings_default_to_read_only_on_47823() {
// Settings 需要 Ctx(要真实数据根),这里只测「无设置时的合成规则」:
// 用 Default 的字段逐项对照,避免为此造一个假 Ctx。
let defaults = Settings {
enabled: false,
port: 47823,
allow_write: false,
token: None,
yolo: false,
};
let config = defaults.mcp_config(&args());
assert_eq!(config.port, 47823);
assert!(config.read_only);
assert_eq!(config.host, "127.0.0.1");
}
#[test]
fn cli_flags_override_port_and_write_switch() {
let settings = Settings {
enabled: true,
port: 47823,
allow_write: true,
token: Some("t".to_string()),
yolo: true,
};
let overridden = settings.mcp_config(&AgentServeArgs {
port: Some(50000),
allow_write: false,
..AgentServeArgs::default()
});
assert_eq!(overridden.port, 50000);
assert!(!overridden.read_only, "设置里允许写时命令行不必再开");
assert_eq!(overridden.token.as_deref(), Some("t"));
assert!(settings.yolo());
// `enabled` 只是 GUI 的自动启动开关:命令行显式 `agent serve` 不看它
// (所以这里 enabled=true 既不影响 mcp_config,也不影响能不能起服务)。
assert!(settings.enabled);
}
}
+341
View File
@@ -0,0 +1,341 @@
//! `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("`ull 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;
+256
View File
@@ -0,0 +1,256 @@
//! [`super`] 的单测:数据根键与其余键的落点分流、dump 合并、`problems` 通道。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use super::{dump, get, set};
use crate::cli::Globals;
use crate::context::Ctx;
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-config-test-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 正确的 `$schema`(写成真前缀,加载才不会降级)。
fn schema_url() -> String {
use unlucky_core::setting::keys::launcher_version;
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
}
fn write_json(path: &Path, value: &Value) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, value.to_string()).expect("写配置");
}
fn read_json(path: &Path) -> Value {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
serde_json::from_slice(&bytes).expect("合法 JSON")
}
fn ctx_with(globals: Globals, exe_dir: &Path) -> Ctx {
Ctx::resolve_in(&globals, Some(exe_dir.to_path_buf())).expect("解析数据根")
}
/// 便携模式:两个落点是同一份文件,`core.data_mode` 改了立刻是生效值。
#[test]
fn portable_mode_keeps_core_keys_in_one_file() {
let temp = TempDir::new("portable");
let ctx = ctx_with(Globals::default(), temp.path());
assert_eq!(ctx.data.mode.as_str(), "portable");
assert_eq!(
ctx.setting_path_for("core.data_mode"),
ctx.launcher_config(),
"便携模式下两份路径本来就该重合"
);
set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode");
let file = temp
.path()
.join(".ULL")
.join("config")
.join("launcher.json");
assert!(file.is_file(), "应落在 {}", file.display());
assert_eq!(read_json(&file)["core"]["data_mode"], json!("standard"));
let report = get(&ctx, Some("core.data_mode"), true).expect("读回来");
// `core.*` 在 schema 里标了 `AiPolicy::sensitive()`(High),读路径默认掩码成
// `<redacted>`,所以要看生效值得像用户那样加 `--show-secret`。
assert_eq!(report.data["value"], json!("standard"));
assert_eq!(
report.data["path"],
json!(file.display().to_string()),
"read 与 write 必须同一份"
);
}
/// standard 模式:数据根键走便携目录那份(与解析同源),其余键走数据根。
#[test]
fn standard_mode_routes_core_keys_to_the_portable_file() {
let temp = TempDir::new("standard");
let exe = temp.path().join("exe");
let portable = exe.join(".ULL").join("config").join("launcher.json");
let launcher_home = temp.path().join("lhome");
write_json(
&portable,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": {
"data_mode": "standard",
"launcher_home": launcher_home.display().to_string(),
},
}),
);
let ctx = ctx_with(Globals::default(), &exe);
assert_eq!(ctx.data.mode.as_str(), "standard");
assert_eq!(ctx.data.launcher_home, launcher_home);
assert_ne!(
ctx.setting_path_for("core.data_mode"),
ctx.launcher_config()
);
let report = get(&ctx, Some("core.data_mode"), true).expect("读 core.data_mode");
assert_eq!(
report.data["value"],
json!("standard"),
"get 展示的必须是真正生效的值"
);
assert_eq!(report.data["path"], json!(portable.display().to_string()));
// 普通键仍写数据根。
set(&ctx, "download.concurrency", "16").expect("写 download.concurrency");
let core_file = ctx.launcher_config();
assert_eq!(read_json(&core_file)["download"]["concurrency"], json!(16));
// 数据根键写便携目录那份,且不动数据根那份的 core.*。
set(&ctx, "core.data_mode", "portable").expect("写 core.data_mode");
assert_eq!(read_json(&portable)["core"]["data_mode"], json!("portable"));
assert!(
read_json(&core_file)["core"].get("data_mode").is_none(),
"数据根那份不该被写入 core.data_mode"
);
}
/// `--data-dir` 模式:数据根由命令行钉死,core.* 仍落便携目录那份并给出提示。
#[test]
fn data_dir_flag_routes_core_keys_to_portable_and_says_so() {
let temp = TempDir::new("datadir");
let exe = temp.path().join("exe");
std::fs::create_dir_all(&exe).expect("建 exe 目录");
let data = temp.path().join("data");
let globals = Globals {
data_dir: Some(data.clone()),
..Globals::default()
};
let ctx = ctx_with(globals, &exe);
assert_eq!(ctx.data.mode.as_str(), "custom");
assert_eq!(ctx.data.launcher_home, data.join(".ULL"));
let portable = exe.join(".ULL").join("config").join("launcher.json");
set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode");
assert!(portable.is_file(), "应落在 {}", portable.display());
assert!(!data
.join(".ULL")
.join("config")
.join("launcher.json")
.exists());
let report = get(&ctx, Some("core.data_mode"), true).expect("读回来");
assert_eq!(report.data["value"], json!("standard"));
assert!(
report.human.iter().any(|line| line.contains("--data-dir")),
"{:?}",
report.human
);
}
/// 全量 dump:`core.*` 取便携目录那份的生效值,并标明两份文件与数据根。
#[test]
fn dump_merges_core_keys_from_the_portable_file() {
let temp = TempDir::new("dump");
let exe = temp.path().join("exe");
let portable = exe.join(".ULL").join("config").join("launcher.json");
let launcher_home = temp.path().join("home2");
write_json(
&portable,
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"core": {
"data_mode": "standard",
"launcher_home": launcher_home.display().to_string(),
},
}),
);
// 数据根那份写一个普通键(默认值不落盘,dump 里只有文件里真有的键)。
write_json(
&launcher_home.join("config").join("launcher.json"),
&json!({
"$schema": schema_url(),
"schemaVersion": 1,
"download": { "concurrency": 4 },
}),
);
let ctx = ctx_with(Globals::default(), &exe);
let report = dump(&ctx, true).expect("全量 dump");
assert_eq!(
report.data["config"]["core"]["data_mode"],
json!("standard")
);
assert_eq!(
report.data["core_path"],
json!(portable.display().to_string())
);
assert_eq!(report.data["data_root"]["mode"], json!("standard"));
assert_eq!(
report.data["config"]["download"]["concurrency"],
json!(4),
"非 core.* 键仍取数据根那份"
);
}
/// 认不出的 `$schema` 前缀:值照读 + `problems` 里有 UnknownSchema,不再静默回落。
#[test]
fn unknown_schema_prefix_warns_and_still_shows_file_values() {
let temp = TempDir::new("badschema");
let exe = temp.path().join("exe");
let portable = exe.join(".ULL").join("config").join("launcher.json");
write_json(
&portable,
&json!({
"$schema": "https://unlucky-launcher.local/schema/launcher/1.2.0.json",
"schemaVersion": 1,
"core": { "data_mode": "portable" },
"download": { "concurrency": 32 },
}),
);
let ctx = ctx_with(Globals::default(), &exe);
let report = get(&ctx, Some("download.concurrency"), false).expect("读普通键");
assert_eq!(report.data["value"], json!(32), "值不该被回落成默认 8");
let problems = report.data["problems"].as_array().expect("problems 数组");
assert!(
problems
.iter()
.any(|problem| problem.as_str().is_some_and(|p| p.contains("$schema"))),
"{problems:?}"
);
assert!(
report.human.iter().any(|line| line.contains("$schema")),
"{:?}",
report.human
);
}
+216
View File
@@ -0,0 +1,216 @@
//! `ull launch` 的账户选择:三轨账户 → [`AuthPlaceholders`](唯一的身份出口)。
//!
//! 选择规则(headless 没有交互,必须确定):
//! - `--account <name>`:按玩家名 / 登录名 / 条目 id 精确找(大小写不敏感);
//! - 不给 `--account`:**唯一账户自动选中**;零账户回落离线占位(`--username` 或 `Player`,
//! 与 M21 的行为一致,保证没有账户时 `ull launch` 照旧能跑);多账户报错并列出候选
//! (猜一个等于让用户拿错身份进服务器,宁可让他选)。
//!
//! 令牌策略:`--dry-run` **不刷新、不写盘**(M30 的 dry-run 纯读语义),只对过期状态给告警;
//! 真启动前对微软轨做一次 `ensure_fresh`(静默续期),刷新成功立刻回写凭据
//! (refresh_token 轮换后旧值就作废了,不能丢)。
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::MicrosoftAuth;
use unlucky_core::auth::offline::offline_login;
use unlucky_core::auth::AccountStore;
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::context::Ctx;
use crate::error::CliError;
/// 解析结果。
pub struct Identity {
/// 喂给 launch 的占位符集合。
pub placeholders: AuthPlaceholders,
/// 选中的账户(`None` = 离线占位,没有账户)。
pub account: Option<AccountMeta>,
/// 需要展示给用户的说明(令牌过期、authlib 注入未落地等)。
pub notes: Vec<String>,
}
/// 解析本次启动的身份。
///
/// # Errors
///
/// 账户不存在/重名冲突/多账户未指定/凭据过期且刷新失败/凭据文件缺条目。
pub fn resolve(
ctx: &Ctx,
account: Option<&str>,
username: Option<&str>,
dry_run: bool,
) -> Result<Identity, CliError> {
let store = AccountStore::open(ctx.auth_data());
let chosen: Option<AccountMeta> = match account {
Some(key) => Some(
store
.find(key)
.ok_or_else(|| unknown_account(key, &store))?
.clone(),
),
None => match store.accounts() {
[] => None,
[only] => Some(only.clone()),
many => {
return Err(CliError::user(
"account_ambiguous",
format!("有 {} 个账户,需要指定用哪个", many.len()),
)
.with_hint(format!(
"ull launch <instance> --account <名字>;候选:{}",
names_of(&store)
)))
}
},
};
let Some(meta) = chosen else {
// 零账户:回落离线占位(保留 M21 行为)。
return Ok(offline_fallback(username));
};
if username.is_some() {
return Err(CliError::usage(format!(
"已选中账户 {},`--username` 只用于没有账户时的离线占位",
meta.display_name()
))
.with_hint("去掉 --username,或用 `ull accounts add offline <名字>` 建账户"));
}
let mut notes = Vec::new();
let placeholders = match meta.kind {
AccountKind::Offline => offline_login(&meta).map_err(super::map_auth_error)?,
AccountKind::Microsoft => microsoft_identity(ctx, &store, &meta, dry_run, &mut notes)?,
AccountKind::AuthlibInjector => authlib_identity(&store, &meta, &mut notes)?,
};
Ok(Identity {
placeholders,
account: Some(meta),
notes,
})
}
/// 零账户时的离线占位(uuid 随机,userType 用 HMCL 的 `legacy` 占位口径不变)。
fn offline_fallback(username: Option<&str>) -> Identity {
Identity {
placeholders: AuthPlaceholders {
player_name: username.unwrap_or("Player").to_string(),
uuid: ProfileId::generate().as_str().replace('-', ""),
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
},
account: None,
notes: vec![
"没有账户,按离线占位启动(ull accounts add offline <名字> 可建账户)".to_string(),
],
}
}
fn microsoft_identity(
ctx: &Ctx,
store: &AccountStore,
meta: &AccountMeta,
dry_run: bool,
notes: &mut Vec<String>,
) -> Result<AuthPlaceholders, CliError> {
let Some(Credentials::Microsoft(session)) = store.credentials(&meta.id) else {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()),
)
.with_hint("重新登录:ull accounts add msa"));
};
if dry_run {
// dry-run 不刷新:令牌过期只提示,命令行里的 token 反正会被脱敏。
if session.is_expired(unlucky_core::auth::microsoft::now_ms()) {
notes.push("微软令牌已过期(--dry-run 不刷新,真启动会自动续期)".to_string());
}
return session.to_placeholders().map_err(super::map_auth_error);
}
let auth = MicrosoftAuth::from_env().map_err(super::map_auth_error)?;
let (refreshed, reason) =
block_on(auth.ensure_fresh(session)).map_err(super::map_auth_error)?;
if let Some(reason) = reason {
notes.push(format!("{}(已静默续期)", reason.message()));
persist_refresh(ctx, meta, &refreshed)?;
}
refreshed.to_placeholders().map_err(super::map_auth_error)
}
/// 刷新成功后立刻回写凭据(refresh_token 已轮换,旧值作废)。
fn persist_refresh(
ctx: &Ctx,
meta: &AccountMeta,
session: &unlucky_core::auth::MicrosoftSession,
) -> Result<(), CliError> {
let mut store = AccountStore::open(ctx.auth_data());
store
.update_credentials(&meta.id, Credentials::Microsoft(session.clone()))
.map_err(super::map_auth_error)?;
let mut updated = meta.clone();
// profile_name 属于可变的私有缓存,刷新后顺手把元数据里的展示名补上。
if !session.profile_name.trim().is_empty() {
updated.profile_name = session.profile_name.clone();
store.update_meta(updated).map_err(super::map_auth_error)?;
}
store.save().map_err(super::map_auth_error)
}
fn authlib_identity(
store: &AccountStore,
meta: &AccountMeta,
notes: &mut Vec<String>,
) -> Result<AuthPlaceholders, CliError> {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&meta.id) else {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 的 authlib 凭据缺失或类型不符", meta.display_name()),
));
};
if !session.has_profile() {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 还没有选中角色", meta.display_name()),
)
.with_hint("多角色服务器要在图形界面里选角色"));
}
notes.push("authlib-injector 的 -javaagent 参数属后续 mission,本次启动不会注入".to_string());
session.to_placeholders().map_err(super::map_auth_error)
}
fn names_of(store: &AccountStore) -> String {
store
.accounts()
.iter()
.map(|meta| meta.display_name().to_string())
.collect::<Vec<_>>()
.join("、")
}
fn unknown_account(key: &str, store: &AccountStore) -> CliError {
let hint = if store.is_empty() {
"还没有账户:ull accounts add offline <名字> 或 ull accounts add msa".to_string()
} else {
format!("可用账户:{}", names_of(store))
};
CliError::user("not_found", format!("找不到账户 `{key}`")).with_hint(hint)
}
#[cfg(test)]
mod tests {
use super::offline_fallback;
#[test]
fn fallback_uses_username_or_player() {
let identity = offline_fallback(Some("Alex"));
assert_eq!(identity.placeholders.player_name, "Alex");
assert!(identity.account.is_none());
assert_eq!(identity.placeholders.user_type, "legacy");
assert_eq!(identity.placeholders.uuid.len(), 32);
assert_eq!(offline_fallback(None).placeholders.player_name, "Player");
}
}
+159
View File
@@ -0,0 +1,159 @@
//! `ull install <version> --loader <kind> [--loader-version <v>]`:加载器层的编排。
//!
//! ```text
//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile)
//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点)
//! → [--dry-run 到此为止,但多带一段 processors 计划]
//! → 原版已装?否 → vanilla::plan_and_install(先装原版)
//! → 需要 installer?→ 依赖库 → 选 Java → 串行跑 processors(M35)
//! → execute(loader 库并发下载 → 全量就绪才写 versions/<loader-id>/<loader-id>.json)
//! → register(实例 versionId 指向 loader 版本)
//! ```
//!
//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层,
//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。
//!
//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是
//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。
use unlucky_core::addon::loader::{
LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::execute;
use super::report_loader::ProcessorSection;
use super::{
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error,
register_instance, report, report_loader, vanilla, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 执行加载器安装。
///
/// # Errors
///
/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、
/// installer processors 失败、实例登记失败。
pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Report, CliError> {
let version = args.version.trim().to_string();
if version.is_empty() {
return Err(CliError::usage(
"install 需要版本 id:ull install <version> --loader fabric",
));
}
let engine = build_engine(ctx);
let layout = ctx.layout();
let game = ctx.game();
let meta_cache = layout.meta_cache();
let now = now_ms();
let release = block_on(async {
let source = LoaderSource::new(&engine, &meta_cache);
source
.resolve(kind, &version, args.loader_version.as_deref())
.await
})
.map_err(|error: LoaderError| map_loader_error(&error, ctx))?;
let name = args
.name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| release.version_id.clone());
ensure_name_free(ctx, &name, &release.version_id)?;
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &game,
release: &release,
environment: &Environment::host(),
vanilla_installed: game.is_version_installed(&version),
});
let processor_plan = loader_processors::plan(ctx, &release)?;
if args.dry_run {
let section = processor_plan.as_ref().map(|plan| ProcessorSection {
plan,
libraries: None,
run: None,
skipped: false,
});
return Ok(report_loader::build(
ctx, &engine, now, true, &plan, None, None, &name, section,
));
}
if plan.vanilla_install_needed {
let (_, vanilla_report) = vanilla::plan_and_install(
ctx,
&engine,
&version,
false,
args.progress,
args.json_progress,
)?;
if let Some(report) = &vanilla_report {
if !report.is_complete() {
return Err(failure_error(report, ctx));
}
}
}
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
let mut processor_libs = None;
let mut processor_run = None;
let mut processor_skipped = false;
if let Some(processor_plan) = &processor_plan {
if game.is_version_installed(&release.version_id) {
processor_skipped = true;
} else {
let (libraries, run) = loader_processors::execute(ctx, &engine, processor_plan, args)?;
processor_libs = Some(libraries);
processor_run = Some(run);
}
}
let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len());
let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink))
.map_err(|error| map_install_error(&error, ctx))?;
if !install_report.is_complete() {
return Err(failure_error(&install_report, ctx));
}
let registration = register_instance(ctx, &name, &release.version_id)?;
let section = processor_plan
.as_ref()
.map(|processor_plan| ProcessorSection {
plan: processor_plan,
libraries: processor_libs.as_ref(),
run: processor_run.as_ref(),
skipped: processor_skipped,
});
Ok(report_loader::build(
ctx,
&engine,
now,
false,
&plan,
Some(&install_report),
Some(&registration),
&name,
section,
))
}
/// 加载器错误 → CLI 错误(稳定码 `loader_*`)。
fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError {
let hint = match error.hint() {
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
None => format!("游戏根:{}", ctx.game().root().display()),
};
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
}
@@ -0,0 +1,181 @@
//! 加载器的 installer processors 段(M35):依赖库下载 → 选 Java → 串行执行。
//!
//! 只在 `LoaderSupport::RequiresInstaller` 的版本上出现(Forge 1.13~1.20.x 主流段、
//! NeoForge 1.20.5+)。其余加载器(Fabric/Quilt、Forge spec 0)走纯 json 路线,这一段整块跳过。
//!
//! ```text
//! ProcessorPlan::build(core,纯计算:变量表 + 每步命令行 + 依赖库清单)
//! → download_libraries(M27 引擎:镜像/健康度/退避/校验)
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
//! → run(串行 java -cp,产物存在性硬校验、摘要软校验)
//! ```
//!
//! 顺序上与版本 JSON 的关系是**硬约束**:processors 全部成功之后才让 M27 的 `execute`
//! 写 `versions/<id>/<id>.json`——那份清单是"这版装完了"的判据,抢在 processors 前写会
//! 留下一个启动必崩却被报成"已安装"的实例。
use std::sync::Arc;
use unlucky_core::addon::loader::processors::{
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
};
use unlucky_core::addon::loader::LoaderRelease;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::InstallReport;
use super::{failure_error, report, CONCURRENCY};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::progress_line;
/// 组执行计划;不需要跑 installer 的加载器返回 `None`。
///
/// # Errors
///
/// install profile 的 `data` 表/`processors` 形状不对时返回 `processor_plan` 错误。
pub(super) fn plan(ctx: &Ctx, release: &LoaderRelease) -> Result<Option<ProcessorPlan>, CliError> {
if !release.requires_installer() {
return Ok(None);
}
let (Some(profile), Some(installer)) = (
release.install_profile.as_ref(),
release.installer.as_deref(),
) else {
return Err(CliError::user(
"loader_metadata",
format!(
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径",
release.kind.label(),
release.loader_version
),
)
.with_hint("重跑一次(元数据缓存可能不完整)"));
};
let game = ctx.game();
let scratch = ctx
.layout()
.cache()
.join("processors")
.join(safe_name(&release.version_id));
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile,
layout: &game,
installer,
scratch: &scratch,
environment: &Environment::host(),
})
.map_err(map_processor_error)?;
Ok(Some(plan))
}
/// 下载依赖库 + 执行 processors。
///
/// # Errors
///
/// 依赖库下载失败(稳定码沿用 `install_*`)、找不到可用 Java、processor 非零退出、
/// 产物没落盘。
pub(super) fn execute(
ctx: &Ctx,
engine: &DownloadEngine,
plan: &ProcessorPlan,
args: &InstallArgs,
) -> Result<(InstallReport, ProcessorRun), CliError> {
let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len());
let libraries = block_on(processors::download_libraries(
engine,
&plan.libraries,
CONCURRENCY,
sink,
));
if !libraries.is_complete() {
return Err(failure_error(&libraries, ctx));
}
let managed = ctx.layout().java_managed();
let Some(java) = processors::select_java(&managed, &plan.java_requirement) else {
return Err(CliError::user(
"processor_java_missing",
format!(
"跑 installer processors 需要 {},但一个可用的 Java 都没找到",
plan.java_requirement.describe()
),
)
.with_hint(format!(
"`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
plan.java_requirement.minimum,
managed.display()
)));
};
let sink = step_sink(args.progress, args.json_progress, plan.step_count());
let run = processors::run(plan, &java, sink).map_err(map_processor_error)?;
Ok((libraries, run))
}
/// 每步一行的实时进度(只写 stderr)。
fn step_sink(enabled: bool, json_mode: bool, total: usize) -> Option<StepSink> {
if !enabled {
return None;
}
Some(Arc::new(move |outcome: &StepOutcome| {
let record = serde_json::json!({
"event": "processor",
"index": outcome.index,
"total": total,
"jar": outcome.label,
"status": outcome.status.as_str(),
"elapsed_ms": outcome.elapsed_ms,
});
let text = format!(
"processor {}/{} {} → {}(输出 {} 个{})",
outcome.index,
total,
outcome.label,
if outcome.status.as_str() == "skipped" {
"跳过(产物已就绪)"
} else {
"执行完成"
},
outcome.outputs,
if outcome.elapsed_ms > 0 {
format!(",{} ms", outcome.elapsed_ms)
} else {
String::new()
}
);
if json_mode {
progress_line(&record.to_string());
} else {
progress_line(&text);
}
}))
}
/// processor 执行错误 → CLI 错误(稳定码 `processor_*`)。
fn map_processor_error(error: ProcessorError) -> CliError {
let cli = CliError::user(error.kind().as_str(), error.message().to_string());
match error.hint() {
Some(hint) => cli.with_hint(hint.to_string()),
None => cli,
}
}
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉)。
fn safe_name(raw: &str) -> String {
raw.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>()
.trim_end_matches('.')
.to_string()
}
+151
View File
@@ -0,0 +1,151 @@
//! `ull install <version> [--loader <kind>] [--loader-version <v>]`:装版本本体 / 装加载器层。
//!
//! ```text
//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register)
//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register
//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON)
//! ```
//!
//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态
//! ([`report`] / [`report_loader`])、把错误翻译成稳定错误码、往 stderr 打实时进度。
//! 所有版本/继承/库/下载逻辑都在 core(CLI 不复制任何路径或镜像规则)。
mod loader;
mod loader_processors;
mod report;
mod report_loader;
mod vanilla;
use std::sync::Arc;
use unlucky_core::addon::loader::LoaderKind;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HealthRegistry, MirrorTable,
};
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 文件级并发上限:资源是海量小文件,16 路足够压满带宽又不至于打爆镜像。
pub(super) const CONCURRENCY: usize = 16;
/// 执行 `install`:按 `--loader` 分发。
///
/// # Errors
///
/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
match args
.loader
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
None | Some("vanilla") | Some("none") => vanilla::run(ctx, args),
Some(raw) => match LoaderKind::parse(raw) {
Some(kind) => loader::run(ctx, args, kind),
None => Err(
CliError::user("loader_unknown", format!("未知加载器 `{raw}`"))
.with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"),
),
},
}
}
/// 登记实例(幂等):`profiles.json` + `config/instances/<id>.json`。
///
/// 原版安装与加载器安装共用:前者 `version_id` = 原版 id,后者 = loader 版本 id
/// (实例的 `versionId` 必须指向 loader 版本,否则启动会跑成原版)。
pub(super) fn register_instance(
ctx: &Ctx,
name: &str,
version_id: &str,
) -> Result<Registration, CliError> {
let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
unlucky_core::game::install::instance::register(
&ctx.layout(),
name,
version_id,
Some(&created_at),
)
.map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}")))
}
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
///
/// 地区判定**不做网络探测**(P2 首版;探测开关属设置层接线),
/// 因此中文环境默认镜像优先、其它环境直连——与 D7 的"不探测时"约定一致。
pub(super) fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
/// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。
pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(), CliError> {
let profiles = super::instances::read_profiles(ctx)?;
let conflict = profiles
.profiles
.iter()
.find(|profile| profile.name == name && profile.version_id != version);
match conflict {
Some(profile) => Err(CliError::user(
"instance_exists",
format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id),
)
.with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")),
None => Ok(()),
}
}
/// 安装期错误 → CLI 错误(保留稳定错误码与提示)。
pub(super) fn map_install_error(error: &InstallError, ctx: &Ctx) -> CliError {
let hint = match error.hint() {
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
None => format!("游戏根:{}", ctx.game().root().display()),
};
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
}
/// 有文件失败 → 按"最严重的失败类别"给稳定码,并列出前几条明细。
pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError {
let code = match report.failure_kind() {
Some(DownloadErrorKind::Integrity) => "install_integrity",
Some(DownloadErrorKind::Io) => "install_io",
_ => "install_network",
};
let mut message = format!(
"{} 个文件没下成(共 {} 个)",
report.failed(),
report.files.len()
);
for outcome in report.failures(3) {
if let Some(failure) = &outcome.failure {
message.push_str(&format!(";{}:{}", outcome.name, failure.message));
}
}
CliError::user(code, message).with_hint(format!(
"重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}",
ctx.game().root().display()
))
}
+262
View File
@@ -0,0 +1,262 @@
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。
//!
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
//! 差别只是多出逐文件清单与源选择。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use serde_json::json;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::{
FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration,
};
use crate::context::Ctx;
use crate::output::{progress_line, Report};
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
const PROGRESS_EVERY: usize = 32;
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
const MAX_PROGRESS_FAILURES: usize = 5;
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
///
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
///
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
if !enabled {
return None;
}
let done = Arc::new(AtomicUsize::new(0));
let failures = Arc::new(AtomicUsize::new(0));
Some(Arc::new(move |outcome: &FileOutcome| {
let index = done.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(failure) = &outcome.failure {
let seen = failures.fetch_add(1, Ordering::Relaxed);
if seen < MAX_PROGRESS_FAILURES {
emit(
json,
json!({
"event": "file_failed",
"done": index,
"total": total,
"name": outcome.name,
"role": outcome.role.as_str(),
"kind": failure.kind.as_str(),
"message": failure.message,
}),
&format!("失败 {index}/{total} {}:{}", outcome.name, failure.message),
);
} else if seen == MAX_PROGRESS_FAILURES {
emit(
json,
json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}),
"(更多失败不再逐条打印,见最终错误汇总)",
);
}
return;
}
if index.is_multiple_of(PROGRESS_EVERY) || index == total {
emit(
json,
json!({"event": "progress", "done": index, "total": total}),
&format!("下载 {index}/{total}"),
);
}
}))
}
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
fn emit(json_mode: bool, record: serde_json::Value, text: &str) {
if json_mode {
progress_line(&record.to_string());
} else {
progress_line(text);
}
}
/// 排版一次安装结果。
#[allow(clippy::too_many_arguments)]
pub fn build(
ctx: &Ctx,
engine: &DownloadEngine,
now: u64,
dry_run: bool,
plan: &InstallPlan,
report: Option<&InstallReport>,
registration: Option<&Registration>,
name: &str,
version: &str,
) -> Report {
let samples = if dry_run {
plan.source_samples(engine, now)
} else {
Vec::new()
};
let mut data = json!({
"dry_run": dry_run,
"version_id": plan.version_id,
"jar_id": plan.jar_id,
"game_root": plan.game_root.display().to_string(),
"plan": plan.summary_json(),
"result": report.map(|report| json!({
"downloaded": report.downloaded(),
"skipped": report.skipped(),
"failed": report.failed(),
"bytes": report.bytes(),
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
"warnings": report.warnings,
"files": report.files.iter().map(outcome_json).collect::<Vec<_>>(),
})),
"instance": registration.map(|registration| json!({
"id": registration.profile.id.as_str(),
"name": registration.profile.name,
"version_id": registration.profile.version_id,
"created": registration.created,
"config_path": registration.config_path.display().to_string(),
"profiles_path": ctx.profiles_config().display().to_string(),
})),
});
if dry_run {
data["files"] = json!(plan.files_json());
data["sources"] = json!(samples);
}
Report::new(
data,
human_lines(dry_run, plan, report, registration, &samples, name, version),
)
}
/// 单个文件的执行结果(`--json` 的 `result.files`;脚本据此分流"哪几个没下成")。
fn outcome_json(outcome: &FileOutcome) -> serde_json::Value {
json!({
"role": outcome.role.as_str(),
"name": outcome.name,
"dest": outcome.dest.display().to_string(),
"status": outcome.status.as_str(),
"bytes": outcome.bytes,
"source": outcome.source,
"error": outcome.failure.as_ref().map(|failure| json!({
"kind": failure.kind.as_str(),
"message": failure.message,
})),
})
}
fn human_lines(
dry_run: bool,
plan: &InstallPlan,
report: Option<&InstallReport>,
registration: Option<&Registration>,
samples: &[serde_json::Value],
name: &str,
version: &str,
) -> Vec<String> {
let mut human = Vec::new();
let (total_bytes, unknown) = plan.total_bytes();
human.push(if dry_run {
format!("安装计划:{}(jar {})", plan.version_id, plan.jar_id)
} else {
format!("已安装 {}(jar {})", plan.version_id, plan.jar_id)
});
for (role, (count, bytes)) in plan.by_role() {
human.push(format!(
" {:<8} {} 个 / {}",
role.label(),
count,
human_bytes(bytes)
));
}
human.push(format!(
" 合计 {} 个文件 / {}{}(游戏根 {})",
plan.file_count(),
human_bytes(total_bytes),
if unknown > 0 {
format!(" + {unknown} 个大小未知")
} else {
String::new()
},
plan.game_root.display()
));
for sample in samples {
let sources: Vec<&str> = sample["candidates"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|candidate| candidate["source"].as_str())
.collect()
})
.unwrap_or_default();
human.push(format!(
" 源选择 {} → {}",
sample["label"].as_str().unwrap_or_default(),
sources.join(" → ")
));
}
for warning in &plan.warnings {
human.push(format!("告警: {warning}"));
}
if let Some(report) = report {
human.push(format!(
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
report.downloaded(),
report.skipped(),
report.failed(),
human_bytes(report.bytes())
));
if let Some(path) = &report.version_json {
human.push(format!("版本清单: {}", path.display()));
}
}
match registration {
Some(registration) => human.push(format!(
"已登记实例 {}({})→ 版本 {}({})",
name,
registration.profile.id.as_str(),
version,
if registration.created {
"新建"
} else {
"已存在,复用"
}
)),
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
}
human
}
/// 字节 → 人读(MB/GB 一位小数,KB 取整;不做假精度)。
pub fn human_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{} KB", bytes / KB)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::human_bytes;
#[test]
fn formats_sizes_without_fake_precision() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(1023), "1023 B");
assert_eq!(human_bytes(1024), "1 KB");
assert_eq!(human_bytes(1024 * 1024), "1.0 MB");
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB");
}
}
@@ -0,0 +1,274 @@
//! 加载器安装的呈现形态(人类可读行 + `--json` 载荷)。
//!
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
use serde_json::json;
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
use unlucky_core::addon::loader::LoaderInstallPlan;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::{InstallReport, Registration};
use super::report::human_bytes;
use crate::context::Ctx;
use crate::output::Report;
/// processors 段(M35)的报告输入:计划 + 依赖库下载结果 + 执行结果。
///
/// `--dry-run` 只有 `plan`;幂等跳过时 `libraries`/`run` 都是 `None` 且 `skipped = true`。
pub struct ProcessorSection<'a> {
/// 执行计划(dry-run 也带上:将执行的 processor 清单 + 所需 Java)。
pub plan: &'a ProcessorPlan,
/// processor 依赖库的下载结果。
pub libraries: Option<&'a InstallReport>,
/// 执行结果。
pub run: Option<&'a ProcessorRun>,
/// 整段跳过(版本 JSON 已在盘上 = processors 早已跑完)。
pub skipped: bool,
}
/// 排版一次加载器安装结果。
///
/// 参数多是因为载荷本身就多:计划(loader 版本 + 库)、processors 段、执行结果、实例登记、
/// `--dry-run` 的源选择样本。拆成结构体会让调用方多写十行,不如保持扁平。
#[allow(clippy::too_many_arguments)]
pub fn build(
ctx: &Ctx,
engine: &DownloadEngine,
now: u64,
dry_run: bool,
plan: &LoaderInstallPlan,
report: Option<&InstallReport>,
registration: Option<&Registration>,
name: &str,
processors: Option<ProcessorSection<'_>>,
) -> Report {
let samples = if dry_run {
plan.install.source_samples(engine, now)
} else {
Vec::new()
};
let mut data = json!({
"dry_run": dry_run,
"loader": plan.release.to_json(),
"vanilla_install_needed": plan.vanilla_install_needed,
"game_root": plan.install.game_root.display().to_string(),
"plan": plan.summary_json(),
"result": report.map(|report| json!({
"downloaded": report.downloaded(),
"skipped": report.skipped(),
"failed": report.failed(),
"bytes": report.bytes(),
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
"warnings": report.warnings,
})),
"instance": registration.map(|registration| json!({
"id": registration.profile.id.as_str(),
"name": registration.profile.name,
"version_id": registration.profile.version_id,
"created": registration.created,
"config_path": registration.config_path.display().to_string(),
"profiles_path": ctx.profiles_config().display().to_string(),
})),
});
if dry_run {
data["files"] = json!(plan.install.files_json());
data["sources"] = json!(samples);
}
if let Some(section) = &processors {
data["processors"] = processor_json(section);
}
Report::new(
data,
human_lines(
dry_run,
plan,
report,
registration,
&samples,
name,
processors.as_ref(),
),
)
}
/// processors 段的 JSON 载荷(计划 + 依赖库结果 + 逐步骤结果)。
fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
let mut node = section.plan.to_json();
if let Some(object) = node.as_object_mut() {
object.insert("skipped".to_string(), json!(section.skipped));
object.insert(
"libraries_result".to_string(),
json!(section.libraries.map(|report| json!({
"downloaded": report.downloaded(),
"skipped": report.skipped(),
"failed": report.failed(),
"bytes": report.bytes(),
}))),
);
object.insert(
"run".to_string(),
json!(section.run.map(ProcessorRun::to_json)),
);
}
node
}
fn human_lines(
dry_run: bool,
plan: &LoaderInstallPlan,
report: Option<&InstallReport>,
registration: Option<&Registration>,
samples: &[serde_json::Value],
name: &str,
processors: Option<&ProcessorSection<'_>>,
) -> Vec<String> {
let release = &plan.release;
let mut human = Vec::new();
human.push(format!(
"{} {}《{} {}》(继承原版 {},主类 {})",
if dry_run {
"加载器安装计划"
} else {
"已安装加载器"
},
release.kind.label(),
release.mc_version,
release.loader_version,
release.mc_version,
release.version_json.main_class.as_deref().unwrap_or("?"),
));
human.push(format!(
" 版本 id {} → {}",
release.version_id,
plan.install.version_json_path.display()
));
human.push(format!(
" 原版 {}",
if plan.vanilla_install_needed {
"未安装:执行时先装原版"
} else {
"已安装:跳过"
}
));
let (total_bytes, unknown) = plan.install.total_bytes();
human.push(format!(
" loader 库 {} 个 / {}{}",
plan.install.file_count(),
human_bytes(total_bytes),
if unknown > 0 {
format!(" + {unknown} 个大小未知")
} else {
String::new()
}
));
for sample in samples {
let sources: Vec<&str> = sample["candidates"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|candidate| candidate["source"].as_str())
.collect()
})
.unwrap_or_default();
human.push(format!(
" 源选择 {} → {}",
sample["label"].as_str().unwrap_or_default(),
sources.join(" → ")
));
}
for warning in plan.warnings() {
human.push(format!("告警: {warning}"));
}
if let Some(section) = processors {
human.extend(processor_lines(section));
}
if let Some(report) = report {
human.push(format!(
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
report.downloaded(),
report.skipped(),
report.failed(),
human_bytes(report.bytes())
));
if let Some(path) = &report.version_json {
human.push(format!("loader 版本清单: {}", path.display()));
}
}
match registration {
Some(registration) => human.push(format!(
"已登记实例 {}({})→ 版本 {}({})",
name,
registration.profile.id.as_str(),
registration.profile.version_id,
if registration.created {
"新建"
} else {
"已存在,复用"
}
)),
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
}
human
}
/// processors 段的人读行(计划 / 依赖库 / 逐步骤 / 告警)。
fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
let plan = section.plan;
let mut lines = Vec::new();
lines.push(format!(
" installer processors {} 步,需要 {}(installer {})",
plan.step_count(),
plan.java_requirement.describe(),
plan.installer.display()
));
if section.skipped {
lines.push(" 版本清单已存在:processors 整段跳过(幂等)".to_string());
}
if let Some(libraries) = section.libraries {
lines.push(format!(
" processor 依赖库 {} 个:下载 {} / 跳过 {} / 失败 {}",
plan.libraries.len(),
libraries.downloaded(),
libraries.skipped(),
libraries.failed()
));
}
if let Some(run) = section.run {
lines.push(format!(
" Java {}({}){}",
run.java
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
run.java.origin.as_str(),
run.java.binary.display()
));
let total = run.steps.len();
for step in &run.steps {
lines.push(format!(
" [{}/{}] {} → {}{}",
step.index,
total,
step.label,
if step.status.as_str() == "skipped" {
"跳过(产物已就绪)".to_string()
} else {
format!("执行 {} ms", step.elapsed_ms)
},
if step.outputs > 0 {
format!("(产物 {} 个)", step.outputs)
} else {
String::new()
}
));
}
for warning in &run.warnings {
lines.push(format!(" 告警: {warning}"));
}
}
for warning in &plan.warnings {
lines.push(format!(" 告警: {warning}"));
}
lines
}
+160
View File
@@ -0,0 +1,160 @@
//! `ull install <version>`(无加载器):M27 原版安装流程的宿主。
//!
//! ```text
//! RemoteSource::resolve(版本清单缓存 → 版本 JSON → 继承链)
//! → RemoteSource::asset_index(资源索引,顺便拿字节给下面预取)
//! → InstallPlan::build(纯计算:文件清单 / 总大小 / 角色)
//! → [--dry-run 到此为止]
//! → execute(跳过已就绪 → 引擎并发下载 → 原子写版本 JSON)
//! → register(profiles.json + config/instances/<id>.json)
//! ```
//!
//! 异步整段包在一次 `block_on` 里:CLI 不依赖 tokio(D18 依赖树纪律),
//! reqwest 的连接池也不会跨运行时而失效。
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{
execute, InstallError, InstallErrorKind, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use super::{
build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 执行 `install`(原版)。
///
/// # Errors
///
/// 用法错误、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
let version = args.version.trim().to_string();
if version.is_empty() {
return Err(CliError::usage(
"install 需要版本 id:ull install <version>",
));
}
let name = args
.name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| version.clone());
ensure_name_free(ctx, &name, &version)?;
let engine = build_engine(ctx);
let now = now_ms();
let (plan, report) = plan_and_install(
ctx,
&engine,
&version,
args.dry_run,
args.progress,
args.json_progress,
)?;
if let Some(report) = &report {
if !report.is_complete() {
return Err(failure_error(report, ctx));
}
}
let registration = if args.dry_run {
None
} else {
Some(super::register_instance(ctx, &name, &version)?)
};
Ok(report::build(
ctx,
&engine,
now,
args.dry_run,
&plan,
report.as_ref(),
registration.as_ref(),
&name,
&version,
))
}
/// 规划 + 执行原版安装(`--dry-run` 只规划),供加载器安装当"前置步骤"复用。
///
/// # Errors
///
/// 版本不存在、网络失败、清单装不全、写盘失败。
pub(super) fn plan_and_install(
ctx: &Ctx,
engine: &DownloadEngine,
version: &str,
dry_run: bool,
progress: bool,
json_progress: bool,
) -> Result<(InstallPlan, Option<InstallReport>), CliError> {
let game = ctx.game();
let meta_cache = ctx.layout().meta_cache();
let now = now_ms();
block_on(async {
let source = RemoteSource::new(engine, &game, &meta_cache, now);
let resolved = source.resolve(version).await?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await?;
Some((index, game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if dry_run {
return Ok::<Attempt, InstallError>(Attempt { plan, report: None });
}
// 计划里没有客户端 jar(旧版清单缺 downloads.client)→ 拒绝"装出个空壳"。
if !plan.is_installable() {
let reasons = if plan.warnings.is_empty() {
"清单缺少客户端 jar 的下载信息".to_string()
} else {
plan.warnings.join(";")
};
return Err(InstallError::new(
InstallErrorKind::Metadata,
format!("版本 {version} 装不全:{reasons}"),
)
.with_hint(
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`",
));
}
let sink = report::progress_sink(progress, json_progress, plan.file_count());
let report = execute(engine, &plan, CONCURRENCY, sink).await?;
Ok(Attempt {
plan,
report: Some(report),
})
})
.map(|attempt| (attempt.plan, attempt.report))
.map_err(|error: InstallError| map_install_error(&error, ctx))
}
/// 一次安装尝试的产物(`--dry-run` 只有计划)。
struct Attempt {
plan: InstallPlan,
report: Option<InstallReport>,
}
+90
View File
@@ -0,0 +1,90 @@
//! `instances list`:实例与已安装版本的总览。
//!
//! 安装/登记在 [`super::install`];这里只读(`profiles.json` + `versions/` 的存在性)。
//! 读 `profiles.json` 的实现委托给 `core::game::install::instance`,避免 CLI 自带一份
//! 解析逻辑(同一份文件两处解析迟早会漂移)。
use std::path::PathBuf;
use serde_json::{json, Value};
use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout;
use unlucky_core::game::ProfileList;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull instances list`。
///
/// # Errors
///
/// `profiles.json` 读不了或解析失败时返回用户错误。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let profiles = read_profiles(ctx)?;
let game = ctx.game();
let installed = game.installed_versions().unwrap_or_default();
let mut rows: Vec<Value> = Vec::new();
let mut body: Vec<String> = Vec::new();
for profile in &profiles.profiles {
let present = game.is_version_installed(&profile.version_id);
body.push(format!(
" {} [{}] {} {}",
profile.name,
profile.version_id,
if present { "已安装" } else { "载荷缺失" },
profile.id.as_str()
));
rows.push(json!({
"id": profile.id.as_str(),
"name": profile.name,
"version_id": profile.version_id,
"version_present": present,
"game_dir": option_path(profile.game_dir.as_ref()),
"java_path": option_path(profile.java_path.as_ref()),
"last_played_at": profile.last_played_at,
}));
}
let data = json!({
"data_root": ctx.data.launcher_home.display().to_string(),
"game_root": ctx.data.game_home.display().to_string(),
"instances": rows,
"installed_versions": installed,
});
let mut human = vec![format!(
"实例 {} 个(数据根 {})",
profiles.profiles.len(),
ctx.data.launcher_home.display()
)];
if body.is_empty() {
human.push(" (空)用 `ull install <version>` 登记第一个实例".to_string());
}
human.extend(body);
if installed.is_empty() {
human.push("已安装版本: (无)".to_string());
} else {
human.push(format!("已安装版本: {}", installed.join(", ")));
}
Ok(Report::new(data, human))
}
/// 读实例清单(文件不存在 = 空列表,不算错误)。
///
/// # Errors
///
/// 读盘失败或 JSON 非法时返回用户错误。
pub fn read_profiles(ctx: &Ctx) -> Result<ProfileList, CliError> {
let path = ctx.profiles_config();
read_profiles_from_layout(&ctx.layout()).map_err(|error| {
CliError::user(
"profiles_parse",
format!("读实例清单 {} 失败:{error}", path.display()),
)
})
}
fn option_path(path: Option<&PathBuf>) -> Option<String> {
path.map(|path| path.display().to_string())
}
+365
View File
@@ -0,0 +1,365 @@
//! Java 运行时:`ull java list` / `install` / `vendors`(D13 v1 + D23 v2)。
//!
//! 四件事:建下载引擎、把 core 的结果排成人读与 `--json` 两种形态、把错误翻成稳定码、
//! 从设置里取 JDK 的落点与发行版优先级。探测/下载/解压/映射/回退链全在 `core::game::java`。
//!
//! D23 之后的三个用户可见变化:
//!
//! 1. **落点可选**:`java.installRoot` 设了就装到那里;否则 Standard 数据模式装到
//! `%APPDATA%\Java\<发行方>\<版本>\`(mac/Linux 见 `game::java::location`),
//! Portable 仍在数据根 `java-managed/`(v1 布局不变);
//! 2. **发行版可选**:`--vendor temurin|oracle|microsoft|zulu` 钉死一家;不给就按
//! `java.vendorPriority` → 地区默认序跑回退链,降级原因进 `--json` 的 `chain` 块;
//! 3. **`java vendors`** 一眼看清四家的可用性、镜像与当前顺序。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{json, Value};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, RegionProfile,
};
use unlucky_core::game::java::{
self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode,
DEFAULT_MAJOR,
};
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
pub use super::java_launch::{resolve_for_launch, LaunchJava};
pub use super::java_vendors::vendors;
/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。
const MIN_INSTALLABLE_MAJOR: u32 = 8;
/// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。
///
/// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在
/// [`resolve_for_launch`] 里求值(那一步还会看托管目录与版本要求)。
#[must_use]
pub fn selection(explicit: Option<&Path>) -> JavaSelection {
let (binary, origin) = match explicit {
Some(path) => (path.to_path_buf(), JavaOrigin::Instance),
None => match env_java() {
Some(binary) => (binary, JavaOrigin::JavaHome),
None => (PathBuf::from(java::java_binary_name()), JavaOrigin::Path),
},
};
let runtime = java::probe(&binary, origin);
JavaSelection::new(binary, runtime.major.unwrap_or(DEFAULT_MAJOR), runtime.bits)
}
/// 托管 JDK 可能存在的全部安装根(Portable 数据根 + Standard/Custom 落点,D23 后不止一个)。
#[must_use]
pub fn managed_roots(ctx: &Ctx) -> Vec<PathBuf> {
let settings = JavaSettings::load(ctx);
location::scan_roots(
ctx.data.mode,
&ctx.layout(),
settings.install_root.as_deref(),
ctx.platform,
&ProcessEnv,
)
}
/// `ull java list`:探测托管目录 / `JAVA_HOME` / `PATH` / 实例覆盖,并显示各 JDK 的落点。
///
/// # Errors
///
/// 实例清单读不了时返回用户错误(其余探测失败只体现为缺项)。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let settings = JavaSettings::load(ctx);
let roots = managed_roots(ctx);
// 托管 → JAVA_HOME → PATH → 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表),
// 同一路径只列一次且保留优先级高者(`discover::dedupe` 负责)。
let mut candidates = java::candidates_in_roots(&roots);
let profiles = super::instances::read_profiles(ctx)?;
for profile in &profiles.profiles {
if let Some(java_path) = &profile.java_path {
candidates.push(java::probe(java_path, JavaOrigin::Instance));
}
}
let candidates = java::discover::dedupe(candidates);
let candidates: Vec<JavaRuntime> = candidates
.into_iter()
.filter(|candidate| candidate.major.is_some())
.collect();
let mode = settings.location_mode(ctx);
let installed_root = settings.location(ctx).map(|root| root.root).ok();
let data = json!({
// 兼容 v1 的 `managed_root`(首个扫描根),同时给出多根清单(D23 之后不止一个根)。
"managed_root": roots.first().map(|root| root.display().to_string()),
"managed_roots": roots.iter().map(|root| root.display().to_string()).collect::<Vec<_>>(),
"install": {
"mode": mode.as_str(),
"root": installed_root.as_ref().map(|root| root.display().to_string()),
"override": settings.install_root.as_ref().map(|root| root.display().to_string()),
},
"candidates": candidates.iter().map(candidate_json).collect::<Vec<_>>(),
});
let mut human = vec![format!("Java {} 个", candidates.len())];
if candidates.is_empty() {
human.push(
"(没找到可用 Java)用 `ull java install 21` 装一个,或设 JAVA_HOME / `ull launch --java <path>`"
.to_string(),
);
}
human.push(format!(
"落点模式:{}",
describe_mode(mode, settings.install_root.as_deref())
));
for root in &roots {
human.push(format!(" 扫描根:{}", root.display()));
}
for candidate in &candidates {
human.push(format!(
" {} {} [{}]",
candidate
.version
.clone()
.unwrap_or_else(|| "版本未知".to_string()),
candidate.binary.display(),
source_label(candidate)
));
}
Ok(Report::new(data, human))
}
/// `ull java install <major> [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。
///
/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎,
/// 国内环境按 D7 的地区策略优先院校/国内镜像。
///
/// # Errors
///
/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。
pub fn install(ctx: &Ctx, major: u32, vendor: Option<JdkVendor>) -> Result<Report, CliError> {
if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) {
return Err(CliError::usage(format!(
"大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)"
)));
}
let settings = JavaSettings::load(ctx);
let engine = build_engine(ctx);
let install_root = settings.location(ctx)?;
let request = match vendor {
Some(vendor) => {
InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone())
}
None => InstallRequest::new(major).with_priority(settings.priority.clone()),
};
let installed =
block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?;
let data = json!({
"major": installed.major,
"vendor": installed.vendor.id(),
"release": installed.release.release_name,
"version": installed.release.version,
"semver": installed.release.semver,
"archive": installed.release.archive.name,
"sha256": installed.release.archive.sha256,
"size": installed.release.archive.size,
"root": installed.root.display().to_string(),
"install_mode": installed.mode.as_str(),
"binary": installed.binary().display().to_string(),
"reused": installed.reused(),
"source": installed.downloaded,
"bytes": installed.bytes,
"files": installed.files,
"extracted": installed.extracted,
"chain": installed.chain.to_json(),
"runtime": candidate_json(&installed.runtime),
});
let mut human = Vec::new();
if installed.reused() {
human.push(format!(
"Java {}({})已就绪({}),未走网络",
installed.major,
installed.vendor.id(),
installed.root.display()
));
} else {
human.push(format!(
"已安装 Java {}({},来源 {},{} 字节 / {} 个文件)",
installed.major,
installed.release.summary(),
installed.downloaded.as_deref().unwrap_or("?"),
installed.bytes,
installed.files
));
}
human.push(format!("落点:{}", installed.location_line()));
human.push(format!("可执行文件:{}", installed.binary().display()));
if let Some(version) = &installed.runtime.version {
human.push(format!("探测:{version}"));
}
for note in installed.chain.degradations() {
human.push(format!("降级:{note}"));
}
human.push("`ull launch` 会自动优先用它".to_string());
Ok(Report::new(data, human))
}
/// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct JavaSettings {
pub(crate) install_root: Option<PathBuf>,
pub(crate) priority: Vec<JdkVendor>,
}
impl JavaSettings {
/// 读 `<数据根>/.ULL/config/launcher.json`(坏文件退化成默认值,不阻断命令)。
pub(crate) fn load(ctx: &Ctx) -> Self {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
let install_root = store
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
let priority = store
.effective("java.vendorPriority")
.and_then(Value::as_array)
.map(|items| {
let texts: Vec<String> = items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
vendor::parse_priority(&texts)
})
.unwrap_or_default();
Self {
install_root,
priority,
}
}
/// 落点模式(只描述,不建目录)。
fn location_mode(&self, ctx: &Ctx) -> LocationMode {
if self.install_root.is_some() {
LocationMode::Custom
} else if ctx.data.mode == unlucky_core::platform::data_dir::DataMode::Standard {
LocationMode::Standard
} else {
LocationMode::Portable
}
}
/// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。
fn location(&self, ctx: &Ctx) -> Result<java::JavaInstallRoot, CliError> {
java::JavaInstallRoot::resolve(
ctx.data.mode,
&ctx.layout(),
self.install_root.as_deref(),
ctx.platform,
&ProcessEnv,
)
.map_err(map_java_error)
}
}
/// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。
fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String {
match mode {
LocationMode::Custom => format!(
"custom(java.installRoot = {})",
override_root.unwrap_or(Path::new("?")).display()
),
LocationMode::Standard => {
"standard(%APPDATA%\\Java\\<发行方>\\<版本> 等系统标准目录)".to_string()
}
LocationMode::Portable => "portable(<数据根>/.ULL/data/java-managed)".to_string(),
}
}
/// 一个运行时的 `--json` 形状。
fn candidate_json(runtime: &JavaRuntime) -> Value {
json!({
"binary": runtime.binary.display().to_string(),
"source": runtime.origin.as_str(),
"source_category": runtime.origin.category(),
"version": runtime.version,
"major": runtime.major,
"bits": runtime.bits,
"vendor": runtime.vendor,
"install_root": install_root_of(runtime),
})
}
/// 托管 JDK 的实际目录(`bin/java` 的上两级);非托管来源为 `None`。
fn install_root_of(runtime: &JavaRuntime) -> Option<String> {
if runtime.origin != JavaOrigin::Managed {
return None;
}
runtime
.binary
.parent()
.and_then(Path::parent)
.map(|dir| dir.display().to_string())
}
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。
fn map_java_error(error: java::JavaError) -> CliError {
let cli = CliError::user(error.code(), error.message().to_string());
match error.hint() {
Some(hint) => cli.with_hint(hint.to_string()),
None => cli,
}
}
/// `JAVA_HOME/bin/java`(存在才返回)。
fn env_java() -> Option<PathBuf> {
let home = std::env::var_os("JAVA_HOME")?;
let binary = PathBuf::from(home)
.join("bin")
.join(java::java_binary_name());
binary.is_file().then_some(binary)
}
/// 运行时 → 展示标签(托管项带上 vendor)。
fn source_label(runtime: &JavaRuntime) -> String {
match &runtime.vendor {
Some(vendor) => format!("{} · {vendor}", runtime.origin.as_str()),
None => runtime.origin.as_str().to_string(),
}
}
/// 当前地区档案(`java vendors` 展示「国内/国外」口径;也给 GUI 复用)。
#[must_use]
pub fn region_profile(ctx: &Ctx) -> RegionProfile {
build_engine(ctx).region().clone()
}
+129
View File
@@ -0,0 +1,129 @@
//! 启动期的 Java 解析(`ull launch` 用):实例覆盖 > 托管匹配 > 系统。
//!
//! 从 `commands/java.rs` 拆出来(那边还要放 list/install/vendors,超 400 行硬上限)。
//! 这里的职责只有两条:①按 D13 的优先链挑一个 Java;②把结果整理成 `--json` 块与告警行。
//! 真正「谁优先」的判定在 `core::game::java::probe` / `select`(纯函数),本文件只做装配。
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use unlucky_core::game::java::{self, JavaMismatch, JavaOrigin, JavaRequirement, JavaRuntime};
use crate::context::Ctx;
/// 启动期的 Java 解析结果。
#[derive(Debug, Clone)]
pub struct LaunchJava {
/// 喂给 `PlanInput.java` 的显式覆盖;`None` = 让 `snapshot::prepare` 按系统兜底。
pub override_path: Option<PathBuf>,
/// 实际选中的运行时。
pub runtime: Option<JavaRuntime>,
/// 目标版本对 Java 的要求。
pub requirement: JavaRequirement,
/// 不符说明(只提示,不阻断)。
pub mismatch: Option<JavaMismatch>,
}
impl LaunchJava {
/// `--json` 的 `java` 块(app 层 GUI 弹窗 P1 后直接吃这份数据)。
#[must_use]
pub fn to_json(&self) -> Value {
let runtime = self.runtime.as_ref();
json!({
"binary": runtime.map(|runtime| runtime.binary.display().to_string()),
"major": runtime.and_then(|runtime| runtime.major),
"bits": runtime.map(|runtime| runtime.bits),
"source": runtime.map(|runtime| runtime.origin.as_str()),
"vendor": runtime.and_then(|runtime| runtime.vendor.clone()),
"required": self.requirement.describe(),
"required_major": self.requirement.minimum,
"requirement_source": self.requirement.source.as_str(),
"mismatch": self.mismatch.map(|mismatch| json!({
"code": mismatch.code(),
"message": mismatch.message(),
})),
})
}
/// 告警行(dry-run 与真启动都输出)。
///
/// 两类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查);
/// ②Java 与版本要求不符(D13:提示,不阻断)。
#[must_use]
pub fn warnings(&self) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(runtime) = &self.runtime {
if runtime.major.is_none() {
warnings.push(format!(
"探测不到 {} 的 `java -version` 输出(路径可能不对)",
runtime.binary.display()
));
}
}
if let Some(mismatch) = self.mismatch {
warnings.push(mismatch.message());
}
warnings
}
/// 人读一行(Java 来源 + 要求)。
#[must_use]
pub fn summary(&self) -> String {
let source = self
.runtime
.as_ref()
.map_or("未探测到", |runtime| runtime.origin.as_str());
let version = self
.runtime
.as_ref()
.and_then(|runtime| runtime.major)
.map_or_else(|| "未知".to_string(), |major| major.to_string());
format!(
"Java {version}({source})· 该版本要求 {}",
self.requirement.describe()
)
}
}
/// 解析启动用的 Java:实例覆盖 > 托管匹配 > 系统。
///
/// 覆盖优先级的求值顺序写在 `core::game::java::probe`(纯函数,可注入候选);
/// 这里只负责「实例覆盖单独成路」与「把结果钉成路径」。
#[must_use]
pub fn resolve_for_launch(ctx: &Ctx, version_id: &str, explicit: Option<&Path>) -> LaunchJava {
let requirement = java::requirement_for_version(&ctx.game(), version_id);
let (runtime, mismatch) = match explicit {
Some(path) => {
let runtime = java::probe(path, JavaOrigin::Instance);
let mismatch = runtime
.major
.and_then(|major| java::check(major, &requirement));
(Some(runtime), mismatch)
}
None => {
// 先看托管 + 环境变量;都不满足要求才去扫盘(别为了一次启动白跑 N 次 java -version)。
// 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。
let roots = super::java::managed_roots(ctx);
let mut candidates = java::discover_managed_roots(&roots);
candidates.extend(java::system_candidates());
let mut resolution = java::resolve(&candidates, &requirement);
if resolution.mismatch.is_some() {
candidates.extend(java::standard_candidates());
resolution = java::resolve(&java::discover::dedupe(candidates), &requirement);
}
(resolution.runtime, resolution.mismatch)
}
};
// 托管 JDK 要钉死路径(用户 PATH 上的 java 可能正是我们要避开的那一个);
// 系统来源交回 `snapshot::prepare` 的 `selection()` 兜底,避免重复实现一遍环境判断。
let override_path = match &runtime {
Some(runtime) if runtime.origin == JavaOrigin::Managed => Some(runtime.binary.clone()),
_ => explicit.map(Path::to_path_buf),
};
LaunchJava {
override_path,
runtime,
requirement,
mismatch,
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `ull java vendors`:四家发行版的可用性 / 镜像 / 当前优先级顺序(D23)。
//!
//! 从 `commands/java.rs` 拆出来(那边 list/install 已经占满一屏)。这里只读不写:
//! 不联网、不改配置,把 `mirrors.json` 的表与设置里的优先级排成一张人读 + `--json` 的对照表。
//!
//! 存在的意义是**可观测**:用户问「国内为什么默认走 Oracle 而不是微软」时,命令要能直接答
//! (顺序 + 每家的镜像与实测吞吐),而不是让人翻文档。
use serde_json::{json, Value};
use unlucky_core::download::MirrorTable;
use unlucky_core::game::java::{vendor, JdkVendor};
use super::java::{region_profile, JavaSettings};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull java vendors`。
///
/// # Errors
///
/// 设置文件坏掉时退化为默认值;命令本身不产生用户错误。
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
let settings = JavaSettings::load(ctx);
let domestic = region_profile(ctx).is_domestic();
let order = vendor::order(&settings.priority, domestic);
let table = MirrorTable::builtin();
let rows: Vec<Value> = order
.iter()
.enumerate()
.map(|(index, item)| vendor_json(&table, *item, index))
.collect();
let priority_source = if settings.priority.is_empty() {
"region_default"
} else {
"java.vendorPriority"
};
let data = json!({
"region": if domestic { "domestic" } else { "global" },
"priority_source": priority_source,
"order": vendor::ids(&order),
"vendors": rows,
});
let mut human = vec![format!(
"发行版顺序({},来源 {}):{}",
if domestic { "国内" } else { "国外" },
if settings.priority.is_empty() {
"地区默认序"
} else {
"java.vendorPriority"
},
vendor::ids(&order).join(" → ")
)];
for row in &rows {
human.push(format!(
" {} {} {} 支持 Java {}",
row["rank"].as_u64().unwrap_or(0),
row["vendor"].as_str().unwrap_or("?"),
row["display"].as_str().unwrap_or(""),
join_numbers(&row["majors"], "/")
));
for source in row["sources"].as_array().into_iter().flatten() {
if let Some(text) = source.as_str() {
human.push(format!(" 源:{text}"));
}
}
}
Ok(Report::new(data, human))
}
/// 一家的 `--json` 形状(源清单按家取,缺表时为空数组而不是报错)。
fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
let sources: Vec<String> = match item {
JdkVendor::Temurin => table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| {
format!(
"{}{}",
mirror.name,
if mirror.latest_only {
"(只留最新补丁)"
} else {
""
}
)
})
.chain(std::iter::once(
"GitHub 官方(国内套 gh-proxy)".to_string(),
))
.collect(),
JdkVendor::Oracle => table
.oracle_mirrors()
.map(|mirror| match mirror.speed.trim() {
"" => format!("{} {}", mirror.name, mirror.base()),
speed => format!("{} {} {}", mirror.name, mirror.base(), speed),
})
.collect(),
JdkVendor::Microsoft => table
.microsoft_openjdk()
.map(|microsoft| {
let speed = if microsoft.measured_speed.trim().is_empty() {
"实测约 400 KB/s".to_string()
} else {
microsoft.measured_speed.clone()
};
vec![format!(
"aka.ms → VS CDN({speed});无国内镜像、无 GitHub 资产"
)]
})
.unwrap_or_default(),
JdkVendor::Zulu => table
.zulu()
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
.unwrap_or_default(),
};
json!({
"rank": index + 1,
"vendor": item.id(),
"display": item.display(),
"majors": item.hint_majors(),
"supported": (8..=99u32).filter(|major| item.supports(*major)).collect::<Vec<_>>(),
"sources": sources,
})
}
/// JSON 数字数组 → `21/17/11` 这种分隔串。
fn join_numbers(value: &Value, separator: &str) -> String {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(Value::as_u64)
.map(|number| number.to_string())
.collect::<Vec<_>>()
.join(separator)
})
.unwrap_or_default()
}
+377
View File
@@ -0,0 +1,377 @@
//! `ull launch <instance> [--dry-run]`(D18:`--dry-run` 只打印组装后的命令行)。
//!
//! D20 的隔离档位在**这里**落成 `--gameDir`:本命令按覆盖链
//! (临时参数 > 实例 `isolation` > 全局 `instances.mode`)派生出运行目录,
//! 交给 [`snapshot::prepare`] 装进 `LaunchDirs.game_dir`(占位符 `${game_directory}`
//! 与子进程 CWD 都取这个值),并在启动时做一次切换检测(只探测、不迁移)。
use std::path::PathBuf;
use serde_json::json;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig, Isolation,
};
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::game::ProfileList;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::LaunchArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
use super::identity::{self, Identity};
use super::snapshot::{self, PlanInput};
/// 执行 `launch`。
///
/// # Errors
///
/// 实例/版本不存在、版本 JSON 不可用、命令行组装失败、进程起不来时返回用户错误。
pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
let profiles = super::instances::read_profiles(ctx)?;
let game = ctx.game();
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance);
let (input, instance_found) = match profile {
Some(profile) => (
PlanInput {
version_id: profile.version_id.clone(),
instance_id: profile.id.as_str().to_string(),
instance_name: profile.name.clone(),
game_dir: profile.game_dir.clone(),
java: args.java.clone().or_else(|| profile.java_path.clone()),
username: args.username.clone(),
max_memory: args.max_memory,
..PlanInput::default()
},
true,
),
None => {
// 也接受「直接给版本 id」:没有实例档案时按版本跑一次(脚本化常见)。
if !game.is_version_installed(&args.instance) {
return Err(CliError::user(
"not_found",
format!(
"既没有实例 `{}`,也没有版本 `{}`",
args.instance, args.instance
),
)
.with_hint(instance_hint(&profiles)));
}
(
PlanInput {
version_id: args.instance.clone(),
instance_name: args.instance.clone(),
java: args.java.clone(),
username: args.username.clone(),
max_memory: args.max_memory,
..PlanInput::default()
},
false,
)
}
};
// 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用),
// 不该在一个注定 not_found 的启动上白跑一遍、更不该顺手改写凭据。
let identity = identity::resolve(
ctx,
args.account.as_deref(),
args.username.as_deref(),
args.dry_run,
)?;
let input = PlanInput {
auth: Some(identity.placeholders.clone()),
..input
};
let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?;
let input = PlanInput {
// 派生结果就是 `--gameDir` 的取值(覆盖链已求值完)。
game_dir: Some(isolation.game_dir.clone()),
..input
};
// D13:实例覆盖 > 托管匹配 > 系统。托管命中时把路径钉死交给 `snapshot::prepare`。
let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref());
let input = PlanInput {
java: java.override_path.clone(),
..input
};
let plan = snapshot::prepare(ctx, &input)?;
let config = LauncherConfig::new(ctx.sessions_root())
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let launcher = Launcher::new(config);
let mut human = Vec::new();
human.push(format!(
"隔离档位 {}(来源 {})· 运行目录 {}",
isolation.isolation.as_str(),
isolation.source.as_str(),
isolation.game_dir.display()
));
if let Some(warning) = &isolation.warning {
human.push(format!("提示: {warning}"));
}
human.push(identity_line(&identity));
for note in &identity.notes {
human.push(format!("提示: {note}"));
}
human.push(java.summary());
for warning in java.warnings() {
human.push(format!("告警: {warning}"));
}
if args.dry_run {
let dry = launcher
.dry_run(&plan.request)
.map_err(|error| map_launch_error(&error, ctx))?;
let mut lines = vec![dry.line().to_string()];
lines.push(format!(
"(--dry-run:未启动进程 · classpath {} 项 · 编码 {})",
dry.classpath_entries, dry.encoding
));
if let Some(natives) = &dry.natives {
lines.push(format!("natives:{}", natives.summary()));
}
let mut warnings = dry.warnings.clone();
warnings.extend(java.warnings());
// 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。
for warning in &dry.warnings {
lines.push(format!("告警: {warning}"));
}
human.extend(lines);
let natives = dry.natives.as_ref().map(|natives| {
json!({
"dir": natives.dir.display().to_string(),
"files": natives.files,
"skipped": natives.skipped,
"ignored": natives.ignored,
"jars": natives.jars,
"custom": natives.custom,
"up_to_date": natives.up_to_date,
"era": natives.era.as_str(),
})
});
let data = json!({
"dry_run": true,
"instance_found": instance_found,
"version_id": plan.version_id,
"isolation": isolation.isolation.as_str(),
"isolation_source": isolation.source.as_str(),
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(),
"program": dry.program.display().to_string(),
"argv": dry.argv,
"display": dry.display,
"classpath_entries": dry.classpath_entries,
"encoding": dry.encoding,
"warnings": warnings,
"natives": natives,
"adapter_warnings": plan.warnings,
});
return Ok(Report::new(data, human));
}
let handle = launcher
.launch(&plan.request)
.map_err(|error| map_launch_error(&error, ctx))?;
let data = json!({
"dry_run": false,
"instance_found": instance_found,
"isolation": isolation.isolation.as_str(),
"isolation_source": isolation.source.as_str(),
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(),
"warnings": java.warnings(),
"session_id": handle.session_id,
"log_path": handle.log_path.display().to_string(),
"exit_code": handle.exit.code,
"exit_type": handle.exit.exit_type.as_str(),
"uptime_ms": handle.uptime_ms,
"summary": handle.exit.summary(),
"artifacts": handle.artifacts.iter().map(|artifact| artifact.path.display().to_string()).collect::<Vec<_>>(),
"adapter_warnings": plan.warnings,
});
human.push(format!(
"会话 {} 结束:{}",
handle.session_id,
handle.exit.summary()
));
human.push(format!("事件流: {}", handle.log_path.display()));
Ok(Report::new(data, human))
}
/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。
struct IsolationPlan {
/// 生效运行目录(喂 `--gameDir`)。
game_dir: PathBuf,
/// 生效档位。
isolation: Isolation,
/// 档位来自哪一层(`temporary` / `instance` / `global`)。
source: unlucky_core::game::IsolationSource,
/// 切换检测提示(两侧都有内容时;`None` = 不必提示)。
warning: Option<String>,
}
/// 按覆盖链派生运行目录,并在启动时做一次切换检测。
///
/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。
/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`,
/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。
///
/// 遗留实例(`profiles.json` 有档案、`config/instances/<id>.json` 还没有——M27 之前的形态)
/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。
/// `--dry-run` 仍然一个字节都不写。
fn resolve_isolation(
ctx: &Ctx,
profile: Option<&unlucky_core::game::InstanceProfile>,
input: &PlanInput,
dry_run: bool,
) -> Result<IsolationPlan, CliError> {
let global_mode = global_mode(ctx);
let mut config = match profile {
Some(profile) => {
read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| {
CliError::user(
"instance_config",
format!("读实例设置 {} 失败:{error}", profile.id.as_str()),
)
})?
}
None => None,
};
// 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。
let bootstrap = config.is_none() && profile.is_some();
if bootstrap {
config = Some(InstanceConfig::new(
input.instance_id.clone(),
input.version_id.clone(),
));
}
// 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义
// 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**,
// 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。
if let Some(config) = config.as_mut() {
if config.isolation.is_none() {
if let Some(dir) = profile
.and_then(|profile| profile.game_dir.clone())
.filter(|dir| !dir.as_os_str().is_empty())
{
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(dir);
}
}
}
let derivation = derive_game_dir(
&ctx.game(),
&input.version_id,
global_mode,
config.as_ref(),
None,
)
.map_err(|error| CliError::user("isolation", error.to_string()))?;
// 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。
let at = format_rfc3339_millis(now());
let mut dirty = false;
let warning = match config.as_mut() {
Some(config) => {
let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation)
.map_err(|error| CliError::user("isolation_probe", error.to_string()))?;
let warning = config.take_mode_change_warning();
dirty = bootstrap || changed || warning.is_some();
warning.map(|warning| warning.message())
}
None => None,
};
// `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。
if !dry_run && dirty {
if let Some(config) = config.as_ref() {
write_instance_config(&ctx.layout(), config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
}
}
Ok(IsolationPlan {
game_dir: derivation.game_dir,
isolation: derivation.isolation,
source: derivation.source,
warning,
})
}
/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。
fn global_mode(ctx: &Ctx) -> IsolationMode {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
store
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display());
CliError::user("launch", error.to_string()).with_hint(hint)
}
fn instance_hint(profiles: &ProfileList) -> String {
if profiles.profiles.is_empty() {
"用 `ull instances list` 查看实例,或 `ull install <version>` 登记一个".to_string()
} else {
let names: Vec<&str> = profiles
.profiles
.iter()
.map(|profile| profile.name.as_str())
.collect();
format!("可用实例:{}", names.join("、"))
}
}
/// 人读一行:本次用谁的身份启动。
fn identity_line(identity: &Identity) -> String {
match &identity.account {
Some(meta) => format!(
"账户 {}({})· UUID {}",
meta.display_name(),
meta.kind.label(),
identity.placeholders.uuid
),
None => format!(
"账户 离线占位 {}(未登记账户)",
identity.placeholders.player_name
),
}
}
/// `--json` 里的身份块(**只有可公开字段**,token 永远不出现)。
fn account_json(identity: &Identity) -> serde_json::Value {
match &identity.account {
Some(meta) => json!({
"name": meta.display_name(),
"kind": meta.kind.as_str(),
"profile_id": identity.placeholders.uuid,
"account_id": meta.id.as_str(),
"registered": true,
}),
None => json!({
"name": identity.placeholders.player_name,
"kind": "offline-placeholder",
"profile_id": identity.placeholders.uuid,
"registered": false,
}),
}
}
+57
View File
@@ -0,0 +1,57 @@
//! 元命令:`--version` / 无子命令 / `help`。
use serde_json::json;
use crate::cli::help;
use crate::os;
use crate::output::Report;
/// 启动器名(`${launcher_name}` 与 `--version` 输出共用)。
pub const LAUNCHER_NAME: &str = "UnluckyLauncher";
/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。
///
/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。
#[must_use]
pub fn version() -> Report {
let info = os::detect();
let data = json!({
"name": "ull",
"package": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"),
"os": os::to_json(&info),
});
let mut human = vec![
format!(
"ull {}({})",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_NAME")
),
format!("os: {}", os::summary_line(&info)),
];
human.extend(os::notes(&info));
Report::new(data, human)
}
/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。
#[must_use]
pub fn welcome() -> Report {
let version_report = version();
let mut human = version_report.human;
human.push(String::new());
human.extend(help::general().lines().map(str::to_string));
let mut data = version_report.data;
data["help"] = json!(help::general());
Report::new(data, human)
}
/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。
#[must_use]
pub fn help(topic: Option<&str>) -> Report {
let text = topic.and_then(help::topic).unwrap_or_else(help::general);
let data = json!({
"topic": topic,
"text": text,
});
Report::new(data, text.lines().map(str::to_string).collect())
}
+155
View File
@@ -0,0 +1,155 @@
//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。
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 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::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("重新执行 `ull accounts add msa` 拿新的用户码".to_string())
}
AuthError::CredentialExpired => Some("重新登录:ull accounts add msa".to_string()),
AuthError::Unsupported(_) => {
Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string())
}
AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()),
AuthError::NotFound { .. } => Some("用 `ull 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()))
}
#[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}");
}
}
+292
View File
@@ -0,0 +1,292 @@
//! `sessions list` / `logs` / `diag`:读 M18 的会话索引与 JSONL 事件流。
//!
//! 数据源形状见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5:
//! - `<sessions_root>/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序);
//! - `<sessions_root>/game/<id>.jsonl`:事件流(`kind` + `payload`)。
//!
//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。
use std::path::Path;
use serde_json::{json, Value};
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
use crate::cli::{DiagArgs, LogsArgs};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// `ull sessions list [--limit N]`。
///
/// # Errors
///
/// 索引文件读不了时返回用户错误(不存在 = 空列表)。
pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
let index = ctx.session_index();
let entries = session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect();
let rows: Vec<Value> = shown
.iter()
.map(|entry| {
json!({
"session_id": entry.session_id,
"instance_id": entry.instance_id,
"instance_name": entry.instance_name,
"game_version": entry.game_version,
"started_at": entry.started_at,
"ended_at": entry.ended_at,
"duration_ms": entry.duration_ms,
"exit_code": entry.exit_code,
"exit_type": entry.exit_type,
"silent_failure": entry.silent_failure,
"window_detected": entry.window_detected,
"crash_found": entry.crash_found,
"diagnosis_top": entry.diagnosis_top,
})
})
.collect();
let mut human = vec![format!(
"会话 {} 条(索引 {})",
shown.len(),
index.display()
)];
if shown.is_empty() {
human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string());
}
for entry in &shown {
human.push(format!(
" {} {} {} {} {}",
entry.session_id,
entry.instance_name,
entry.game_version,
entry.started_at,
exit_label(entry),
));
}
Ok(Report::new(json!({ "sessions": rows }), human))
}
/// `ull logs <sid> [--tail N]`:打印事件流末尾 N 条。
///
/// # Errors
///
/// 会话不存在/前缀有歧义、事件流读不了时返回用户错误。
pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result<Report, CliError> {
let entry = find_entry(ctx, &args.session_id)?;
let path = event_path(ctx, &entry);
let events = read_events(&path)?;
let start = events.len().saturating_sub(args.tail.max(1));
let shown = &events[start..];
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
let shown: Vec<Value> = shown.iter().map(redact_value).collect();
let mut human = vec![format!(
"会话 {} · {} · 事件 {} 条(显示末尾 {})",
entry.session_id,
entry.instance_name,
events.len(),
shown.len()
)];
human.extend(shown.iter().map(render_event));
Ok(Report::new(
json!({
"session_id": entry.session_id,
"path": path.display().to_string(),
"total": events.len(),
"events": shown,
}),
human,
))
}
/// `ull diag <sid> [--tail N]`:规则引擎诊断。
///
/// # Errors
///
/// 会话不存在、事件流读不了、结论序列化失败时返回错误。
pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
let entry = find_entry(ctx, &args.session_id)?;
let path = event_path(ctx, &entry);
let events = read_events(&path)?;
let mut stdout_lines: Vec<&str> = Vec::new();
let mut stderr_lines: Vec<&str> = Vec::new();
let mut error_lines: u32 = 0;
for event in &events {
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
continue;
};
match event.get("kind").and_then(Value::as_str) {
Some("stdout") => stdout_lines.push(line),
Some("stderr") => {
stderr_lines.push(line);
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
error_lines += 1;
}
}
_ => {}
}
}
let stdout_text = tail_join(&stdout_lines, args.tail);
let stderr_text = tail_join(&stderr_lines, args.tail);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr_text,
);
let mut input = DiagnosisInput::new().with_exit(exit);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
if !stderr_text.is_empty() {
input.push_text(SignalOrigin::Stderr, stderr_text);
}
let loader = RuleLoader::embedded();
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
let report = diagnoser.analyze(&input);
let record = serde_json::to_value(report.to_record())
.map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?;
let mut human = vec![format!(
"会话 {} · {} · {} · {}",
entry.session_id,
entry.instance_name,
entry.game_version,
exit_label(&entry)
)];
match report.primary() {
Some(finding) => {
human.push(format!("主因: {}", finding.message));
if let Some(detail) = &finding.detail {
human.push(format!("说明: {detail}"));
}
for evidence in finding.evidence.iter().take(3) {
human.push(format!("证据: {}", evidence.render()));
}
}
None => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()),
}
human.push(format!(
"规则评估 {} 条 · 结论 {} 条 · 次生 {} 条",
report.rules_evaluated,
report.findings.len(),
report.secondary.len()
));
let human: Vec<String> = human
.iter()
.map(|line| unlucky_agent::redact::redact_text(line))
.collect();
let payload = redact_value(&json!({
"session_id": entry.session_id,
"instance_id": entry.instance_id,
"game_version": entry.game_version,
"exit": {
"code": entry.exit_code,
"exit_type": entry.exit_type,
"silent_failure": entry.silent_failure,
"duration_ms": entry.duration_ms,
},
"diagnosis": record,
}));
Ok(Report::new(payload, human))
}
fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
let index = ctx.session_index();
let entries = session_index::read_all(&index)
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
return Ok(entry.clone());
}
let matches: Vec<&SessionIndexEntry> = entries
.iter()
.filter(|entry| entry.session_id.starts_with(key))
.collect();
match matches.as_slice() {
[only] => Ok((*only).clone()),
[] => Err(
CliError::user("session_not_found", format!("找不到会话 `{key}`"))
.with_hint(format!("索引文件:{}", index.display())),
),
_ => Err(CliError::user(
"session_ambiguous",
format!("`{key}` 匹配到 {} 个会话,请给更长的前缀", matches.len()),
)),
}
}
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
ctx.sessions_root().join("game").join(&entry.log_path)
}
fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
let text = std::fs::read_to_string(path)
.map_err(|error| CliError::io("读会话事件流", path, &error))?;
Ok(text
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect())
}
fn tail_join(lines: &[&str], tail: usize) -> String {
let start = lines.len().saturating_sub(tail.max(1));
let mut text = lines[start..].join("\n");
if !text.is_empty() {
text.push('\n');
}
text
}
fn render_event(event: &Value) -> String {
let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?");
let detail = event
.pointer("/payload/line")
.and_then(Value::as_str)
.or_else(|| event.pointer("/payload/summary").and_then(Value::as_str))
.unwrap_or_default();
format!("[{ts}] {kind} {detail}")
}
/// 递归脱敏任意 JSON(所有字符串过 `redact_text`:主目录用户名段 + Bearer 令牌)。
fn redact_value(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(unlucky_agent::redact::redact_text(text)),
Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), redact_value(child)))
.collect(),
),
other => other.clone(),
}
}
fn exit_label(entry: &SessionIndexEntry) -> String {
let code = entry
.exit_code
.map_or_else(|| "无退出码".to_string(), |code| format!("退出码 {code}"));
let exit_type = entry
.exit_type
.clone()
.unwrap_or_else(|| "unknown".to_string());
let silent = if entry.silent_failure {
" · 静默失败"
} else {
""
};
format!("{code}/{exit_type}{silent}")
}
+205
View File
@@ -0,0 +1,205 @@
//! 实例 → [`LaunchRequest`] 的装配:launch 命令与 `--dry-run` 共用这一段。
//!
//! 全部走 core 的既有公共面(`game::resolve` / `arguments.expand` / `GameLayout` /
//! `launch::adapter::build_request`),CLI 不复制任何继承/参数展开逻辑。
use std::path::PathBuf;
use crate::context::Ctx;
use crate::error::CliError;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::{self, ProfileId, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{AuthPlaceholders, LaunchRequest, MemoryOptions};
/// 装配输入(来自命令行 + 实例档案)。
#[derive(Debug, Clone, Default)]
pub struct PlanInput {
/// 版本 id(实例绑定的 `versions/<id>/`)。
pub version_id: String,
/// 实例 id(写进会话索引;无档案时为空)。
pub instance_id: String,
/// 实例显示名。
pub instance_name: String,
/// 已派生的运行目录(D20 隔离覆盖链在 `super::launch` 求值完,这里只消费)。
pub game_dir: Option<PathBuf>,
/// Java 可执行文件覆盖。
pub java: Option<PathBuf>,
/// 离线账户名(无账户时生效;有账户时身份由 `auth` 决定)。
pub username: Option<String>,
/// 已解析好的账户占位(`None` = 退回 `username` 的离线占位)。
pub auth: Option<AuthPlaceholders>,
/// 最大堆 MB。
pub max_memory: Option<u32>,
}
/// 装配结果。
#[derive(Debug, Clone)]
pub struct Plan {
/// 实际使用的版本 id。
pub version_id: String,
/// 装配好的启动请求。
pub request: LaunchRequest,
/// 非致命告警(缺主类/缺资源索引等)。
pub warnings: Vec<String>,
}
/// 装配一个 [`LaunchRequest`]。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂(缺父版本/成环)时返回用户错误。
pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
let game = ctx.game();
let version_id = input.version_id.clone();
let manifest_path = game.version_json(&version_id);
let manifest = read_version_json(&manifest_path)?;
let source = |id: &str| -> Option<VersionJson> {
let path = game.version_json(id);
read_version_json(&path).ok()
};
let resolved = game::resolve(&source, &manifest).map_err(|error| {
CliError::user("inherit", format!("解析继承链失败:{error}"))
.with_hint(format!("检查 {}", manifest_path.display()))
})?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
continue;
}
let Ok(coordinate) = library.coordinate() else {
continue;
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, library.name.clone()));
}
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
let natives = unlucky_core::launch::natives::collect(
&game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let primary_jar = game.version_jar(&jar_id);
// 运行目录:调用方(`ull launch`)已按隔离覆盖链派生;`None` 兜底到游戏根
// (库/其它消费者直接构造 `PlanInput` 时保持旧行为)。
let game_dir = input
.game_dir
.clone()
.unwrap_or_else(|| ctx.data.game_home.clone());
let assets_root = game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&input.instance_name),
version_type: None,
game_version: None,
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar,
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let options = AdapterOptions {
java: super::java::selection(input.java.as_deref()),
memory: MemoryOptions {
min_mb: None,
max_mb: input.max_memory,
},
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
player_name: input
.username
.clone()
.unwrap_or_else(|| "Player".to_string()),
uuid: ProfileId::generate().as_str().replace('-', ""),
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
}),
launcher_name: super::meta::LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let warnings = output.warnings.iter().map(describe_warning).collect();
let mut request = output.request;
// 会话索引要用实例身份(adapter 默认拿版本 id/名)。
if !input.instance_id.is_empty() {
request.instance.id = input.instance_id.clone();
}
request.instance.name = input.instance_name.clone();
Ok(Plan {
version_id,
request,
warnings,
})
}
fn read_version_json(path: &std::path::Path) -> Result<VersionJson, CliError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(CliError::user(
"version_missing",
format!("版本 JSON 不存在:{}", path.display()),
)
.with_hint("先准备好版本本体文件(下载装配属 P1 后续 mission)"));
}
Err(error) => return Err(CliError::io("读版本 JSON", path, &error)),
};
VersionJson::from_json_str(&text)
.map_err(|error| CliError::user("version_parse", format!("{}:{error}", path.display())))
}
/// 适配层告警 → 人读一行(`AdapterWarning` 没有 Display,这里集中翻译)。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => {
"版本 JSON 既没有 arguments.game 也没有 minecraftArguments".to_string()
}
AdapterWarning::MissingAssetsIndex => {
"版本 JSON 没有资源索引 id(${assets_index_name} 为空)".to_string()
}
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
+238
View File
@@ -0,0 +1,238 @@
//! 数据根解析与目录视图。
//!
//! 三模式(D6 / `04-data-and-config.md` §1)全部交给 `core::platform::data_dir`:
//! - `--data-dir <dir>` → custom 模式(`<dir>/.ULL` + `<dir>/.minecraft`);
//! - 否则先按便携目录的 `launcher.json` 读 `core.data_mode` / `core.data_custom_root` /
//! `core.launcher_home` / `core.game_home`,再交 [`Resolver`] 解析(含 `redirect.json` 跟随)。
//!
//! CLI 不自己拼路径:目录形状的真相在 `core::platform::layout`。
//!
//! `ull config get/set` 的落点也从这里出:[`Ctx::setting_path_for`] 让决定数据根的
//! `core.*` 键落在**便携目录那份** `launcher.json`(与解析同源),其余键落解析后的数据根。
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::layout::{
LauncherLayout, CONFIG_DIR, LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR,
};
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::Globals;
use crate::error::CliError;
use crate::os;
/// 决定数据根本身的配置键(`request_from_portable_config` 读的就是它们)。
///
/// `ull config get/set` 对这几个键必须读写**同一份文件**(便携目录那份),否则会出现
/// 「`config get core.data_mode` 说 portable、实际按 standard 跑」这种两套真相
/// (finding 20261030-worker-java2-improve-ull-config-get-set-launcher-json)。
pub const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// 该键是否决定数据根。
#[must_use]
pub fn is_data_root_key(key: &str) -> bool {
DATA_ROOT_KEYS.contains(&key)
}
/// 便携目录那份 `launcher.json` 的路径(`<exe 目录>/.ULL/config/launcher.json`)。
#[must_use]
pub fn portable_config_path(exe_dir: &Path) -> PathBuf {
exe_dir
.join(LAUNCHER_HOME_DIR)
.join(CONFIG_DIR)
.join(LAUNCHER_CONFIG_FILE)
}
/// 一次命令执行的上下文:数据根 + 平台 + 系统摘要。
#[derive(Debug, Clone)]
pub struct Ctx {
/// 解析后的数据目录(launcher home / game home)。
pub data: ResolvedData,
/// 宿主平台族。
pub platform: Platform,
/// 系统信息与支持等级(D19 探测 / D21 Win10+ 单基线)。
pub os: unlucky_core::platform::os_info::OsInfo,
/// exe 目录(便携目录那份 `launcher.json` 的根;拿不到时为 `None`)。
exe_dir: Option<PathBuf>,
/// `--data-dir` 是否由命令行强制指定(此时数据根不看 `core.*` 键)。
data_dir_forced: bool,
}
impl Ctx {
/// 解析数据根。
///
/// # Errors
///
/// `--data-dir` 非绝对路径、exe 目录拿不到、`redirect.json` 损坏等返回用户错误。
pub fn resolve(globals: &Globals) -> Result<Self, CliError> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf));
Self::resolve_in(globals, exe_dir)
}
/// 指定 exe 目录的解析入口(单测用它把便携目录钉在临时目录里)。
///
/// # Errors
///
/// 同 [`Self::resolve`]。
pub fn resolve_in(globals: &Globals, exe_dir: Option<PathBuf>) -> Result<Self, CliError> {
let platform = Platform::current();
let data_dir_forced = globals.data_dir.is_some();
let request = match &globals.data_dir {
Some(dir) => {
if !dir.is_absolute() {
return Err(CliError::usage(format!(
"--data-dir 需要绝对路径,收到 `{}`",
dir.display()
)));
}
DataDirRequest {
mode: DataMode::Custom,
custom_root: Some(dir.clone()),
..DataDirRequest::default()
}
}
None => request_from_portable_config(exe_dir.clone()),
};
let resolver = Resolver::new(platform, exe_dir.clone());
let data = resolver
.resolve_default(&request)
.map_err(|error| CliError::user("data_dir", error.to_string()))?;
Ok(Self {
data,
platform,
os: os::detect(),
exe_dir,
data_dir_forced,
})
}
/// 启动器数据布局(`.ULL/` 四分区)。
#[must_use]
pub fn layout(&self) -> LauncherLayout {
self.data.layout()
}
/// 游戏目录布局(`.minecraft/`)。
#[must_use]
pub fn game(&self) -> GameLayout {
GameLayout::new(self.data.game_home.clone())
}
/// `launcher.json` 路径。
#[must_use]
pub fn launcher_config(&self) -> PathBuf {
self.layout().launcher_config()
}
/// 便携目录那份 `launcher.json`(数据根解析的唯一输入;拿不到 exe 目录时为 `None`)。
#[must_use]
pub fn portable_launcher_config(&self) -> Option<PathBuf> {
self.exe_dir.as_deref().map(portable_config_path)
}
/// `--data-dir` 是否由命令行强制指定(此时 `core.*` 数据根键本轮不参与解析)。
#[must_use]
pub const fn data_dir_forced(&self) -> bool {
self.data_dir_forced
}
/// 某个配置键的读写落点。
///
/// 数据根键([`DATA_ROOT_KEYS`])走便携目录那份——和 [`Self::resolve`] 读的是同一份文件,
/// 这样 `config get core.data_mode` 展示的就是真正生效的值,`config set` 也真的会影响
/// 下次解析;其余键走解析后的数据根(`<data>/.ULL/config/launcher.json`)。
#[must_use]
pub fn setting_path_for(&self, key: &str) -> PathBuf {
if is_data_root_key(key) {
if let Some(path) = self.portable_launcher_config() {
return path;
}
}
self.launcher_config()
}
/// `profiles.json` 路径。
#[must_use]
pub fn profiles_config(&self) -> PathBuf {
self.layout().profiles_config()
}
/// 会话根(`<data>/agent/sessions`,launch 的事件流与索引都在这下面)。
#[must_use]
pub fn sessions_root(&self) -> PathBuf {
self.layout().agent_sessions()
}
/// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。
#[must_use]
pub fn auth_data(&self) -> PathBuf {
self.layout().auth_data()
}
/// 会话索引文件(`<sessions_root>/game/index.jsonl`)。
#[must_use]
pub fn session_index(&self) -> PathBuf {
unlucky_core::launch::session_index::index_path(&self.sessions_root())
}
/// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。
///
/// # Errors
///
/// 系统不支持时返回 `unsupported_os` 用户错误(文案与核心自检同一份)。
pub fn ensure_supported(&self) -> Result<(), CliError> {
let check = os::check();
if check.is_usable() {
return Ok(());
}
Err(CliError::user("unsupported_os", check.message)
.with_hint(format!("最低要求:{}", os::BASELINE)))
}
/// 环境级提示(低于基线时的升级指引;一等基线时为空)。
#[must_use]
pub fn notes(&self) -> Vec<String> {
os::notes(&self.os)
}
}
/// 便携目录的 `launcher.json` → 三模式请求。
///
/// 只读 `effective`(文件值 → 默认值),坏文件也永不 panic(`SettingStore` 的加载降级)。
/// 路径由 [`portable_config_path`] 决定,`config get/set` 用的是同一个函数(同源)。
fn request_from_portable_config(exe_dir: Option<PathBuf>) -> DataDirRequest {
let mut request = DataDirRequest::default();
let Some(exe_dir) = exe_dir else {
return request;
};
let path = portable_config_path(&exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text("core.data_mode").as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text("core.data_custom_root") {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text("core.launcher_home") {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text("core.game_home") {
request.game_home = Some(PathBuf::from(home));
}
request
}
+157
View File
@@ -0,0 +1,157 @@
//! 错误形状与退出码。
//!
//! 约定(10-cli-design.md §3):**0 成功 / 1 用户错误 / 2 内部错误**。
//! 用户错误 = 用法错、目标不存在、环境不支持、依赖模块未接线;
//! 内部错误 = 不该发生的事(序列化失配等),脚本见到 2 应该当 bug 报。
use std::fmt;
use serde_json::{json, Value};
/// 退出码:成功。
pub const EXIT_OK: i32 = 0;
/// 退出码:用户错误。
pub const EXIT_USER: i32 = 1;
/// 退出码:内部错误。
pub const EXIT_INTERNAL: i32 = 2;
/// 错误大类(决定退出码)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
/// 用户侧问题:改参数/改环境可解决。
User,
/// 内部问题:不该发生,应报 bug。
Internal,
}
impl ErrorKind {
/// 稳定名(`--json` 输出用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Internal => "internal",
}
}
/// 对应的进程退出码。
#[must_use]
pub const fn exit_code(self) -> i32 {
match self {
Self::User => EXIT_USER,
Self::Internal => EXIT_INTERNAL,
}
}
}
/// 一条 CLI 错误。
///
/// `code` 是给脚本的稳定机读标识(`usage` / `not_found` / `unsupported_os` …),
/// `message` 是给人看的中文说明,`hint` 是「下一步该做什么」。
#[derive(Debug, Clone)]
pub struct CliError {
kind: ErrorKind,
code: &'static str,
message: String,
hint: Option<String>,
}
impl CliError {
/// 用法错误(参数不对)。
#[must_use]
pub fn usage(message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::User,
code: "usage",
message: message.into(),
hint: None,
}
}
/// 用户错误,带稳定 code。
#[must_use]
pub fn user(code: &'static str, message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::User,
code,
message: message.into(),
hint: None,
}
}
/// 内部错误,带稳定 code。
#[must_use]
pub fn internal(code: &'static str, message: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Internal,
code,
message: message.into(),
hint: None,
}
}
/// IO 失败(读配置/读写会话文件):按用户错误处理,路径进消息。
#[must_use]
pub fn io(op: &str, path: &std::path::Path, error: &std::io::Error) -> Self {
Self::user("io", format!("{op}失败:{}({error})", path.display()))
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 错误大类。
#[must_use]
pub const fn kind(&self) -> ErrorKind {
self.kind
}
/// 稳定错误码。
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
/// 人类可读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
/// 进程退出码。
#[must_use]
pub const fn exit_code(&self) -> i32 {
self.kind.exit_code()
}
/// `--json` 里的错误对象。
#[must_use]
pub fn to_json(&self) -> Value {
let mut error = json!({
"code": self.code,
"kind": self.kind.as_str(),
"message": self.message,
});
if let Some(hint) = &self.hint {
error["hint"] = Value::String(hint.clone());
}
json!({ "ok": false, "error": error })
}
}
impl fmt::Display for CliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.message)
}
}
impl std::error::Error for CliError {}
+27
View File
@@ -0,0 +1,27 @@
//! Unlucky-Launcher 命令行(`unlucky_cli`):`ull` 二进制的可测试内核。
//!
//! 设计定稿:`DevDocs/PlanDoc/10-cli-design.md`(D18)—— core 是零 UI 纯库,
//! CLI 只是与 Slint GUI 平级的第二个消费者,因此**本 crate 不依赖任何 UI 框架**。
//!
//! 分层:
//! - [`cli`]:手写参数解析(不引 clap)+ 命令模型 + 帮助文案;
//! - [`commands`]:每个子命令一个函数,输入 [`context::Ctx`],输出 [`output::Report`];
//! - [`context`]:数据根解析(三模式 / `--data-dir` 覆盖)与目录视图;
//! - [`os`]:系统摘要与 D19/D21 支持级别(事实来自 core::platform::os_info,这里只做呈现);
//! - [`output`]:人类可读 + `--json` 双格式输出;
//! - [`error`]:错误形状与退出码(0 成功 / 1 用户错误 / 2 内部错误)。
//!
//! 可测试性:入口是 [`run`],参数是 `&[String]`,输出写进两个 `&mut dyn Write`,
//! 返回值就是进程退出码——集成测试因此不需要真的起子进程也能覆盖三条通道。
#![forbid(unsafe_code)]
pub mod cli;
pub mod commands;
pub mod context;
pub mod error;
pub mod os;
pub mod output;
pub mod runner;
pub use runner::run;
+9
View File
@@ -0,0 +1,9 @@
//! `ull` 二进制入口:把真实 stdio 交给 [`unlucky_cli::run`],退出码照传。
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
let code = unlucky_cli::run(&args, &mut stdout, &mut stderr);
std::process::exit(code);
}
+78
View File
@@ -0,0 +1,78 @@
//! 系统摘要与启动自检的 **CLI 呈现层**(D19 探测 + D21 Win10+ 单基线)。
//!
//! 事实采集与等级判定全在 `unlucky_core::platform::os_info`(M20):
//! - [`os_info::detect`] 真探一次(会起子进程,别在热路径反复调用);
//! - [`os_info::current`] 进程内缓存(CLI 每次执行只真探一次);
//! - [`os_info::check`] 启动自检(等级 + 双语文案 + 系统信息)。
//!
//! 本模块只做 CLI 关心的事:一行摘要、`--json` 对象、拒绝运行时的文案。
//! 支持级别只有两档(D21 用户拍板「直接 win10 起步」):[`SupportLevel::One`] 与
//! [`SupportLevel::Unsupported`]——Win8.1/8/7 不承诺 GUI 也不承诺 CLI,
//! 识别出旧版本正是为了明确报「不支持」。
use serde_json::{json, Value};
use unlucky_core::platform::os_info::{self, OsInfo, SupportLevel};
/// 取进程内缓存的系统信息(一次进程只真探一次)。
#[must_use]
pub fn detect() -> OsInfo {
os_info::current().clone()
}
/// 启动自检(等级 + 双语文案 + 系统信息),供拒绝运行时直接打印。
#[must_use]
pub fn check() -> os_info::SupportCheck {
os_info::check()
}
/// 一行摘要(`ull --version` 附带)。
#[must_use]
pub fn summary_line(info: &OsInfo) -> String {
let version = if info.version_string.is_empty() {
"版本未知"
} else {
info.version_string.as_str()
};
format!(
"{} · {}({})· {}",
info.product,
version,
info.arch,
support_label(info.support_level)
)
}
/// `--json` 里的系统对象。
#[must_use]
pub fn to_json(info: &OsInfo) -> Value {
json!({
"family": info.family.manifest_name(),
"product": info.product,
"version": if info.version_string.is_empty() { None } else { Some(&info.version_string) },
"major_minor": info.major_minor.map(|(major, minor)| format!("{major}.{minor}")),
"build": info.build,
"arch": info.arch,
"session": info.session.as_str(),
"support": info.support_level.as_str(),
"baseline": BASELINE,
})
}
/// 支持基线的一句话(`--json` 与帮助文案共用)。
pub const BASELINE: &str = "Windows 10+ / macOS 12+ / Linux(内核 ≥ 3.2,glibc ≥ 2.17)";
/// 环境级提示:仅在低于基线时非空(一等基线不打岔)。
#[must_use]
pub fn notes(info: &OsInfo) -> Vec<String> {
match info.support_level {
SupportLevel::One => Vec::new(),
SupportLevel::Unsupported => info.describe().zh.lines().map(str::to_string).collect(),
}
}
fn support_label(level: SupportLevel) -> &'static str {
match level {
SupportLevel::One => "满足最低要求",
SupportLevel::Unsupported => "不支持(低于最低要求)",
}
}
+136
View File
@@ -0,0 +1,136 @@
//! 双格式输出:人类可读行 + `--json` 机器可读载荷。
//!
//! 一个命令只产出 [`Report`](`data` 给脚本,`human` 给人);怎么打印由 [`print_report`]
//! 一处决定,命令里不出现 `println!`——这样集成测试能直接断言输出形状,
//! 也能保证 `--json` 覆盖到每个命令(10-cli-design.md §3 全局参数)。
use std::io::Write;
use serde_json::{json, Value};
use crate::error::CliError;
/// 一个命令的结果。
#[derive(Debug, Clone)]
pub struct Report {
/// `--json` 模式下的 `data` 载荷。
pub data: Value,
/// 人类可读输出(一行一条)。
pub human: Vec<String>,
}
impl Report {
/// 构造。
#[must_use]
pub fn new(data: Value, human: Vec<String>) -> Self {
Self { data, human }
}
}
/// 命令结果 + 环境提示(D19/D21 低于基线时的升级指引之类,不混进 `data`)。
#[derive(Debug, Clone)]
pub struct Outcome {
/// 命令结果。
pub report: Report,
/// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。
pub warnings: Vec<String>,
}
impl Outcome {
/// 无提示的结果。
#[must_use]
pub fn new(report: Report) -> Self {
Self {
report,
warnings: Vec::new(),
}
}
/// 带上环境提示。
#[must_use]
pub fn with_warnings(report: Report, warnings: Vec<String>) -> Self {
Self { report, warnings }
}
}
/// 打印成功结果。
///
/// # Errors
///
/// 写 stdout/stderr 失败时返回 IO 错误(管道被关掉等)。
pub fn print_report(
outcome: &Outcome,
json: bool,
quiet: bool,
out: &mut dyn Write,
err: &mut dyn Write,
) -> std::io::Result<()> {
if json {
// 信封固定三个键,脚本按 `ok` 分流后直接吃 `data`。
let envelope = json!({
"ok": true,
"data": outcome.report.data,
"warnings": outcome.warnings,
});
writeln!(out, "{}", serde_json::to_string_pretty(&envelope)?)?;
return Ok(());
}
if !quiet {
for line in &outcome.report.human {
writeln!(out, "{line}")?;
}
}
for warning in &outcome.warnings {
writeln!(err, "提示: {warning}")?;
}
Ok(())
}
/// 打印错误(人类模式进 stderr,JSON 模式进 stdout 的错误信封)。
///
/// # Errors
///
/// 写 stdout/stderr 失败时返回 IO 错误。
pub fn print_error(
error: &CliError,
json: bool,
out: &mut dyn Write,
err: &mut dyn Write,
) -> std::io::Result<()> {
if json {
writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?;
return Ok(());
}
writeln!(err, "错误: {}", error.message())?;
if let Some(hint) = error.hint() {
writeln!(err, "提示: {hint}")?;
}
Ok(())
}
/// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。
///
/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务
/// 需要无缓冲的实时反馈,才单独开这条通道。行内不含 ANSI,`--no-color` 无需处理。
///
/// `--json` / `--quiet` 的抑制由**调用方**决定(`InstallArgs::progress` 已按全局选项算好),
/// 这样这条通道不需要知道全局选项长什么样。
///
/// 写不出去(管道早闭)就静默放弃:进度永远不该让命令失败。
pub fn progress_line(text: &str) {
let mut stderr = std::io::stderr();
let _ = writeln!(stderr, "{text}");
let _ = stderr.flush();
}
#[cfg(test)]
mod tests {
use super::progress_line;
#[test]
fn progress_line_never_panics() {
// 只验证"写不出去也不抛"(真实 stderr 在测试里通常可写)。
progress_line("测试进度 1/2");
progress_line("");
}
}
+54
View File
@@ -0,0 +1,54 @@
//! 进程入口的可测试形态:解析 → 分发 → 打印 → 退出码。
//!
//! `main` 只做两件事:把真实 stdio 传进来、把返回码交给进程。
use std::io::Write;
use crate::cli;
use crate::commands;
use crate::error::EXIT_OK;
use crate::output;
/// 跑一条命令行,返回进程退出码(0 / 1 / 2)。
///
/// `args` 不含 argv[0]。`--json` 与 `--quiet` 在解析失败时也要生效,
/// 因此这里先扫一遍原始参数,而不是依赖解析结果。
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let json = args.iter().any(|arg| arg == "--json");
let quiet = args.iter().any(|arg| arg == "--quiet" || arg == "-q");
let invocation = match cli::parse(args) {
Ok(invocation) => invocation,
Err(error) => {
let _ = output::print_error(&error, json, out, err);
return error.exit_code();
}
};
// `ull agent serve` 提前分流:stdio 形态下 stdout 是 JSON-RPC 流,
// 任何一行额外输出(包括返回后的 Report)都会让客户端解析失败。
if let cli::Command::AgentServe(serve_args) = &invocation.command {
return match crate::commands::agent::run_serve_with(&invocation.globals, serve_args, err) {
Ok(code) => code,
Err(error) => {
let _ = output::print_error(&error, json, out, err);
error.exit_code()
}
};
}
match commands::run(&invocation) {
Ok(outcome) => {
if let Err(write_err) = output::print_report(&outcome, json, quiet, out, err) {
// 输出写不出去(管道早闭)不再折腾,直接按内部错误退出。
let _ = writeln!(err, "错误: 写输出失败:{write_err}");
return crate::error::EXIT_INTERNAL;
}
EXIT_OK
}
Err(error) => {
let _ = output::print_error(&error, json, out, err);
error.exit_code()
}
}
}