Files
Unlucky-Launcher/crates/cli/src/cli/model.rs
T
AskaEth 9f5c001387 feat(game): 从其他启动器只读登记已有版本(ull instances scan|adopt)+ 登记可一并定隔离档(M221)
盘上已经铺好的版本(多半是 PCL/HMCL 装的)今天在 ULL 里看不见,用户只能重装一遍。这个
commit 把它们登记成实例,**只读外部、只写自己的配置**:不搬文件、不补清单、不改任何外部
文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。

core(`game::adopt`,新模块):
- `scan` / `inspect`:只读扫描 + 逐项检查,坏 JSON(带行列号)/ 缺 id / id 与目录名不一致 /
  缺父 / 成环 / 读不出来 / 越界 / 同名冲突一律**结构化**报错(稳定 `code`),不吞成空列表;
  `versions/` 不存在才是空结果,读不了就是错误;
- 越界判据用 canonicalize 后的前缀比较,同时覆盖**符号链接、Windows 目录联接(junction)与
  祖先链接**(junction 不需要管理员权限,是最常见的「把别人版本目录挂进来」);越界条目在
  边界检查就停手,一个字节都不去读;
- `plan`(零写盘,喂 dry-run 与后续 GUI 预检)/ `adopt`(plan + 只写自己的配置):
  **缺省运行目录落 ULL 数据区 `<data>/instances/<实例目录名>`(custom 档),绝不沿用全局
  `isolated`**——那条缺省的运行目录正是 `versions/<versionId>/`,会在他人的版本目录里长出
  mods/saves/config;`shared` / `isolated` 必须显式选,且 `target_notes()` 给出后果话术;
- 缺省运行目录已存在且非空 ⇒ 拒绝(不覆盖、不猜「合并进已有实例」);数据区里已有的祖先
  若解析到数据根之外(符号链接/联接)⇒ 拒绝,不沿它写盘;
- 登记与隔离档一次写清:`install/instance` 拆出 `register` 子模块,新增
  `register_with(InstanceSettings)`;写盘顺序改成「先实例设置、后 profiles」(中断只留孤立
  设置文件,不会留下按全局档启动的实例),profiles 写失败会撤掉刚写的设置。`register`
  签名与行为不变(`register` = `register_with(inherit)`;同版本同名的幂等复用照旧)。

CLI:
- `ull instances scan`(只读列出候选与原因)、`ull instances adopt <version-id> [--name N]
  [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`;`--game-dir` 单独给即
  custom,相对路径在解析期就报用法错误;
- 人读/`--json`/help 三条出口齐备(`error.detail.problems` 逐条可分流),已有命令未改。

测试(真磁盘夹具,`cargo test -p unlucky-core --test adopt` 22 passed /
`cargo test -p unlucky-cli --test adopt_test` 10 passed):
- 一份「别的启动器铺好的」`.minecraft`(versions/libraries/assets/mods/saves/config +
  launcher_profiles.json),扫描与登记前后做「路径集合 + 字节数 + 内容哈希 + mtime」全等断言;
- 正常 / 缺清单 / 坏 JSON / 缺父 / 成环 / id 不匹配 / 非法 id / 符号链接越界 / 根内符号链接
  放行 / **junction 越界** / 数据区 junction 写盘边界 / 缺省非 isolated / shared 与 isolated
  的话术 / 重名 / 重复 / 非空目录 / 空目录复用 / dry-run 零写盘;
- CLI 侧验 `launch --dry-run` 的 `--gameDir` 指向选定运行目录(argv 已脱敏,比尾段;
  `data.game_dir` 全等);
- 变异核对(只改实现、跑完即回滚):缺省档改 `VersionDir` ⇒ 红;边界判据摘掉 ⇒ 符号链接与
  junction 两条红;写盘边界摘掉 ⇒ 数据区 junction 那条红;缺父当链终点 ⇒ 两条红。

界限:没有启动真实 Minecraft;GUI(presenter/app-iced)接线另派;D20 单游戏根模型未改
(不扫第二个 `.minecraft`)。
2026-11-09 22:21:37 +08:00

284 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 命令模型(纯数据):解析结果就是这里的一个 [`Invocation`]。
use std::path::PathBuf;
use unlucky_core::game::AdoptTarget;
/// 全局选项(所有命令共享)。
#[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,
/// `instances scan`:列出当前游戏根里可登记 / 不可登记的外部版本(只读,M221)。
InstancesScan,
/// `instances adopt <version-id> [--name N] [--isolation ...] [--game-dir DIR] [--dry-run]`。
InstancesAdopt(InstancesAdoptArgs),
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
PackImport(PackImportArgs),
/// `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),
/// `update check`:查一次更新,不动任何文件。
UpdateCheck(UpdateCheckArgs),
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
UpdateApply(UpdateApplyArgs),
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
AgentConfig {
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
port: Option<u16>,
},
}
/// `update check` 的参数(现在没有选项,留一个结构体以免将来加选项时改签名)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateCheckArgs {}
/// `update apply` 的参数。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UpdateApplyArgs {
/// 替换目标(缺省 = 本进程的 exe;取证/自定义安装位置时才给)。
pub dest: Option<PathBuf>,
}
/// `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,
}
/// `pack import` 的参数(M111)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackImportArgs {
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
pub path: PathBuf,
/// 目标实例目录(缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`,M216 前是
/// 共享游戏目录 `game_home`)。
pub target: Option<PathBuf>,
/// 实例显示名(缺省 = 包名)。
pub name: Option<String>,
/// 冲突时用包内内容覆盖(缺省保留用户文件)。
pub overwrite: bool,
/// 不登记实例(只落内容)。
pub no_register: 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,
}
/// `instances adopt` 的参数(M221:把盘上已有的版本登记成实例,只读外部、只写自己的配置)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstancesAdoptArgs {
/// 要接入的版本 id(= `versions/<id>/`)。
pub version_id: String,
/// 展示名(缺省 = 版本 id)。
pub name: Option<String>,
/// 运行目录选择:缺省 [`AdoptTarget::InstanceDir`](ULL 数据区新目录),
/// `shared` / `isolated` / `custom` 都必须显式给。
pub target: AdoptTarget,
/// 只检查、不写任何文件(`--dry-run`)。
pub dry_run: bool,
}
/// `instances launch-args` 的参数(M157:实例级启动参数)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceLaunchArgs {
/// 实例 id 或实例名。
pub instance: String,
/// 实例级 JVM 参数(空白分隔,支持引号)。
pub jvm_args: Option<String>,
/// 实例级游戏参数。
pub game_args: Option<String>,
/// 实例级最大堆(MiB 文本;空串由 `--memory ""` 表达「跟随全局」)。
pub memory_mb: Option<String>,
/// 实例级 Java 可执行文件。
pub java: Option<String>,
/// 图形后端(`default` / `opengl` / `vulkan`)。
pub backend: Option<String>,
/// 先清空五项再应用给出的项(`--clear`;不带其它选项时等于「全部跟随全局」)。
pub clear: bool,
}
impl InstanceLaunchArgs {
/// 这次调用是不是纯读取(没有任何写入口)。
#[must_use]
pub fn is_read_only(&self) -> bool {
!self.clear
&& self.jvm_args.is_none()
&& self.game_args.is_none()
&& self.memory_mb.is_none()
&& self.java.is_none()
&& self.backend.is_none()
}
}
/// `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,
}