feat(addon): M111 整合包导入管线(CF / mrpack / MultiMC)+ ull pack import
接 M110 的 pack 模型与安全闸,做导入:三格式 → 实例目录,落 D10 §2 三条硬要求。 core(`addon::import`): - 三方合并:`merge::decide` 判据表(磁盘 × 基线 × 包内),base 侧落在 `<实例根>/.ULL/pack-baseline.json`;无基线时磁盘同名文件算用户已有内容 ⇒ 不覆盖。 冲突逐条进报告(user_modified / user_deleted / pre_existing + 可选处置)。 - 两阶段:先 overrides 再补远端;失败分级只有一条判据(是不是"某个文件搞不到"): 缺文件 ⇒ Partial;穿越/符号链接/限额/清单坏/摘要不符/IO ⇒ Failed 且整包回滚。 - 事务:`Staging` 临时目录 + `CommitGuard`(全新导入 rename 整目录、合并态 journal 逆序 回滚、Drop 即回滚),基线作为最后一个暂存文件同生共死。 - 三格式语义:mrpack `client-overrides` 胜 `overrides`、`server-overrides` 不解压; `.disabled`/`.old` 按路径后缀识别并原样保留;MultiMC `formatVersion != 1` 整份拒绝、 非加载器 component 逐项进报告;CF `classId` 认不出就不猜。 - 加载器只做翻译(`LoaderTarget`:kind + 原版 + 加载器版本 + 版本 id,含 CF NeoForge `1.20.1-47.1.106` 的 MC 前缀剥离),网络全部经注入的 `PackFetcher` (生产实现 `DownloadEngineFetcher` 只走 core::download,无第二个 reqwest client)。 - 报告:三态 + 逐项原因 + 稳定文案 key 表(`report::keys`,含 ALL 守门表)。 CLI(`ull pack import <file> [--target] [--name] [--overwrite] [--no-register]`): - 退出码三态 0 / 3 / 1(`Outcome::exit_code` + runner 透传,其它命令仍 0); - `--json` 恒有信封(`data.status` / `data.error.code` / `data.stages`),Failed 不抛栈。 测试:29 条 core(三格式端到端、合并 6 条、必须先失败 8 条含双检互补样本与确定性 提交失败回滚、报告契约 5 条)+ 5 条 CLI(三态退出码、JSON 契约、既有命令回归、help)。 fmt / clippy(core+cli, -D warnings) 绿;文件全部 ≤400 行。
This commit is contained in:
1 parent
78bcc3038b
commit
4760714743
32 files changed
+5044
-5
No files matched your search
@@ -19,6 +19,10 @@ pub const fn general() -> &'static str {
|
||||
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
|
||||
launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
|
||||
[--max-memory <MB>] 启动实例;--dry-run 只打印组装后的命令行
|
||||
pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
|
||||
导入整合包(CF manifest.json / Modrinth .mrpack /
|
||||
MultiMC·Prism 实例)→ 落内容 → 登记实例;
|
||||
缺文件降级为「部分成功」(退出码 3)
|
||||
accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**)
|
||||
accounts add offline <name> 添加离线账户(HMCL 兼容 UUID)
|
||||
accounts add msa 添加微软账户(设备码交互式登录)
|
||||
@@ -46,7 +50,7 @@ pub const fn general() -> &'static str {
|
||||
--help, -h 帮助(有位置参数时按该命令给帮助)
|
||||
--version, -V 版本 + 系统摘要
|
||||
|
||||
退出码: 0 成功 / 1 用户错误 / 2 内部错误"
|
||||
退出码: 0 成功 / 1 用户错误 / 2 内部错误 / 3 部分成功(仅 pack import)"
|
||||
}
|
||||
|
||||
/// 单个命令的帮助;未知主题返回 `None`(调用方退回总帮助)。
|
||||
@@ -59,6 +63,27 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
|
||||
列出 profiles.json 里的实例与 versions/ 下已安装的版本。
|
||||
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}",
|
||||
),
|
||||
"pack" => Some(
|
||||
"\
|
||||
%prog% pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
|
||||
|
||||
导入一个整合包到实例目录。三种格式按优先级探测(形状校验在前,不靠文件名抢):
|
||||
CurseForge manifestType=minecraftModpack 的 manifest.json + overrides/
|
||||
Modrinth modrinth.index.json + overrides/ client-overrides/(后者胜;
|
||||
server-overrides/ 客户端不解压)
|
||||
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
|
||||
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
|
||||
|
||||
目标目录缺省 = 数据根的共享游戏目录(.minecraft/);`--target` 可钉到实例目录。
|
||||
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
|
||||
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
|
||||
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
|
||||
· 目录穿越 / 符号链接 / 清单损坏 / 摘要不符一律整包回滚,目标目录零残留
|
||||
`--no-register`:只落内容,不写 profiles.json。
|
||||
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
|
||||
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
|
||||
data.stages 列出实际走过的阶段。",
|
||||
),
|
||||
"install" => Some(
|
||||
"\
|
||||
|
||||
@@ -13,7 +13,7 @@ pub mod program;
|
||||
|
||||
pub use model::{
|
||||
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
|
||||
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs,
|
||||
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
|
||||
};
|
||||
pub use parse::parse;
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ pub enum Command {
|
||||
InstancesList,
|
||||
/// `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]`。
|
||||
@@ -133,6 +135,21 @@ pub struct InstallArgs {
|
||||
pub json_progress: bool,
|
||||
}
|
||||
|
||||
/// `pack import` 的参数(M111)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackImportArgs {
|
||||
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
|
||||
pub path: PathBuf,
|
||||
/// 目标实例目录(缺省 = 数据根的共享游戏目录 `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 {
|
||||
|
||||
@@ -12,7 +12,7 @@ mod agent;
|
||||
|
||||
use super::model::{
|
||||
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
|
||||
LogsArgs,
|
||||
LogsArgs, PackImportArgs,
|
||||
};
|
||||
|
||||
/// `logs` 缺省尾巴行数。
|
||||
@@ -83,6 +83,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
"help" => Ok(Command::Help(tail.first().cloned())),
|
||||
"instances" => parse_instances(tail),
|
||||
"install" => parse_install(tail, globals),
|
||||
"pack" => parse_pack(tail),
|
||||
"launch" => parse_launch(tail),
|
||||
"sessions" => parse_sessions(tail),
|
||||
"logs" => parse_logs(tail),
|
||||
@@ -128,6 +129,32 @@ fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
}))
|
||||
}
|
||||
|
||||
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
|
||||
fn parse_pack(tail: &[String]) -> Result<Command, CliError> {
|
||||
let Some(sub) = tail.first().map(String::as_str) else {
|
||||
return Err(CliError::usage("pack 需要子命令 import")
|
||||
.with_hint("%prog% pack import <file> [--target <dir>]"));
|
||||
};
|
||||
if sub != "import" {
|
||||
return Err(CliError::usage(format!(
|
||||
"pack 的子命令只有 import,收到 `{sub}`"
|
||||
)));
|
||||
}
|
||||
let parsed = parse_flags(
|
||||
tail.get(1..).unwrap_or_default(),
|
||||
&["target", "name"],
|
||||
&["overwrite", "no-register"],
|
||||
)?;
|
||||
let path = require_single_positional(&parsed, "pack import <file>")?;
|
||||
Ok(Command::PackImport(PackImportArgs {
|
||||
path: PathBuf::from(path),
|
||||
target: parsed.value("target").map(PathBuf::from),
|
||||
name: parsed.value("name"),
|
||||
overwrite: parsed.has("overwrite"),
|
||||
no_register: parsed.has("no-register"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_launch(tail: &[String]) -> Result<Command, CliError> {
|
||||
let parsed = parse_flags(
|
||||
tail,
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod java_launch;
|
||||
pub mod java_vendors;
|
||||
pub mod launch;
|
||||
pub mod meta;
|
||||
pub mod pack;
|
||||
pub mod sessions;
|
||||
pub mod snapshot;
|
||||
|
||||
@@ -32,6 +33,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
|
||||
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
|
||||
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
|
||||
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
|
||||
Command::PackImport(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
|
||||
}
|
||||
Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)),
|
||||
Command::SessionsList { limit } => {
|
||||
with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit))
|
||||
@@ -121,6 +125,24 @@ where
|
||||
Ok(Outcome::with_warnings(report, ctx.notes()))
|
||||
}
|
||||
|
||||
/// 同 [`with_ctx`],但命令自己组装 [`Outcome`](需要指定退出码或多态结果时用)。
|
||||
///
|
||||
/// 只有 `pack import` 走这条路:它的三态要 0 / 3 / 1 三个退出码,`Report` 表达不了。
|
||||
fn with_ctx_outcome<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
|
||||
where
|
||||
F: FnOnce(&Ctx) -> Result<Outcome, CliError>,
|
||||
{
|
||||
let ctx = Ctx::resolve(globals)?;
|
||||
ctx.ensure_supported()?;
|
||||
let mut outcome = action(&ctx)?;
|
||||
outcome.warnings.extend(
|
||||
ctx.notes()
|
||||
.into_iter()
|
||||
.map(|line| crate::cli::program::fill(&line)),
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::map_auth_error;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! `pack import <file>`:整合包导入(M111)。
|
||||
//!
|
||||
//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。
|
||||
//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,
|
||||
//! 不是这里的打印逻辑。
|
||||
//!
|
||||
//! # 三态退出码(脚本契约)
|
||||
//!
|
||||
//! | `data.status` | 退出码 | 含义 |
|
||||
//! |---------------|--------|------|
|
||||
//! | `success` | `0` | 全部落地、无冲突 |
|
||||
//! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) |
|
||||
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
||||
//!
|
||||
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
|
||||
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
|
||||
//! 数据根不可用),与其它命令一致。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, ConflictChoice, ConflictPolicy, DownloadEngineFetcher, FileAction, ImportProgress,
|
||||
ImportReport, ImportRequest, ImportStatus,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::cli::PackImportArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::{Outcome, Report};
|
||||
|
||||
/// `ull pack import`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 整合包路径不存在(用户错误)。导入本身的失败**不是** `Err`:它走三态报告 + 退出码。
|
||||
pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
|
||||
if !args.path.exists() {
|
||||
return Err(CliError::user(
|
||||
"not_found",
|
||||
format!("整合包不存在:{}", args.path.display()),
|
||||
)
|
||||
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
|
||||
}
|
||||
let target = args
|
||||
.target
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.game().root().to_path_buf());
|
||||
|
||||
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorder = Arc::clone(&stages);
|
||||
let sink: unlucky_core::addon::ImportProgressSink =
|
||||
Arc::new(move |progress: &ImportProgress| {
|
||||
if let Ok(mut seen) = recorder.lock() {
|
||||
if seen.last().copied() != Some(progress.stage.as_str()) {
|
||||
seen.push(progress.stage.as_str());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let engine = build_engine(ctx);
|
||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
let layout = ctx.layout();
|
||||
let mut request = ImportRequest::new(&args.path, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_progress(sink);
|
||||
if args.overwrite {
|
||||
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
|
||||
}
|
||||
if !args.no_register {
|
||||
request = request.with_registration(&layout, args.name.as_deref());
|
||||
}
|
||||
|
||||
let report = block_on(import_pack(&request));
|
||||
let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default();
|
||||
Ok(outcome(ctx, &report, &target, &seen, args))
|
||||
}
|
||||
|
||||
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
|
||||
fn outcome(
|
||||
ctx: &Ctx,
|
||||
report: &ImportReport,
|
||||
target: &Path,
|
||||
stages: &[&'static str],
|
||||
args: &PackImportArgs,
|
||||
) -> Outcome {
|
||||
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"status": report.status.as_str(),
|
||||
"error": {"code": "serialize", "message": "报告序列化失败"},
|
||||
})
|
||||
});
|
||||
if let Some(object) = data.as_object_mut() {
|
||||
object.insert("target".to_string(), json!(target.display().to_string()));
|
||||
object.insert("stages".to_string(), json!(stages));
|
||||
object.insert("status_key".to_string(), json!(report.status.message_key()));
|
||||
object.insert("installed_files".to_string(), json!(report.installed()));
|
||||
object.insert(
|
||||
"target_is_default".to_string(),
|
||||
json!(args.target.is_none()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut human: Vec<String> = Vec::new();
|
||||
human.push(format!(
|
||||
"整合包 {}({})→ {}{}",
|
||||
report.pack_name.as_deref().unwrap_or("<未读成>"),
|
||||
report.format.as_deref().unwrap_or("-"),
|
||||
target.display(),
|
||||
if args.target.is_none() {
|
||||
"(缺省 = 数据根的共享游戏目录)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
if !stages.is_empty() {
|
||||
human.push(format!("阶段:{}", stages.join(" → ")));
|
||||
}
|
||||
human.push(format!(
|
||||
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
||||
status_label(report.status),
|
||||
count_action(report, FileAction::Installed),
|
||||
count_action(report, FileAction::Replaced),
|
||||
count_action(report, FileAction::SkippedIdentical),
|
||||
count_action(report, FileAction::KeptUser),
|
||||
report.conflicts.len(),
|
||||
report.missing.len(),
|
||||
));
|
||||
if let Some(loader) = &report.loader {
|
||||
human.push(format!(
|
||||
"加载器:{} {}(版本 id {})",
|
||||
loader.kind.label(),
|
||||
loader.loader_version,
|
||||
loader.version_id
|
||||
));
|
||||
if let Some(version_id) = &report.version_id {
|
||||
if !ctx.game().is_version_installed(version_id) {
|
||||
human.push(format!(
|
||||
"提示:{version_id} 还没安装,启动前先跑 `%prog% install` 装它"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(memory) = report.max_memory_mb {
|
||||
human.push(format!("内存:{memory} MB(来自 instance.cfg)"));
|
||||
}
|
||||
for component in &report.rejected_components {
|
||||
human.push(format!(
|
||||
"未支持的 component:{} {}({})",
|
||||
component.uid,
|
||||
component.version.as_deref().unwrap_or("-"),
|
||||
component.message_key
|
||||
));
|
||||
}
|
||||
for conflict in &report.conflicts {
|
||||
human.push(format!(
|
||||
"冲突:{}({},已{})",
|
||||
conflict.path,
|
||||
conflict.message_key,
|
||||
match conflict.choice {
|
||||
ConflictChoice::KeepUser => "保留你的文件",
|
||||
ConflictChoice::Overwrite => "按包覆盖",
|
||||
}
|
||||
));
|
||||
}
|
||||
for missing in &report.missing {
|
||||
human.push(format!(
|
||||
"缺文件:{}({}:{})",
|
||||
missing.display, missing.message_key, missing.detail
|
||||
));
|
||||
}
|
||||
if let Some(registered) = &report.registered {
|
||||
human.push(format!(
|
||||
"已登记实例 {}({}){}",
|
||||
registered.name,
|
||||
registered.version_id,
|
||||
if registered.created {
|
||||
""
|
||||
} else {
|
||||
"·复用已有档案"
|
||||
}
|
||||
));
|
||||
}
|
||||
if let Some(error) = &report.error {
|
||||
human.push(format!("错误:{}({})", error.message(), error.code()));
|
||||
if let Some(hint) = error.hint() {
|
||||
human.push(format!("提示:{hint}"));
|
||||
}
|
||||
if report.rolled_back {
|
||||
human.push("已整包回滚:目标目录与导入前一致".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
|
||||
}
|
||||
|
||||
fn status_label(status: ImportStatus) -> &'static str {
|
||||
match status {
|
||||
ImportStatus::Success => "成功",
|
||||
ImportStatus::Partial => "部分成功",
|
||||
ImportStatus::Failed => "失败(已回滚)",
|
||||
}
|
||||
}
|
||||
|
||||
fn count_action(report: &ImportReport, action: FileAction) -> usize {
|
||||
report
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.action == action)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 与 `install` 共用同一套引擎构造(镜像 / 地区 / 健康度 / 内容缓存)。
|
||||
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(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = store_cached(®ion_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)),
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,13 @@ pub struct Outcome {
|
||||
pub report: Report,
|
||||
/// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。
|
||||
pub warnings: Vec<String>,
|
||||
/// 命令自定义的退出码;`None` = 0(绝大多数命令)。
|
||||
///
|
||||
/// 只有"结果多态且脚本要分流"的命令才用得上:`ull pack import` 的三态是
|
||||
/// `0` 成功 / `3` 部分成功 / `1` 失败,光靠 0/1 分不出来。加这个字段而不是让命令
|
||||
/// 自己 `std::process::exit`,是为了让**输出照常刷新**(绕过 runner 的输出刷新会把
|
||||
/// "输出没刷完就退出"这种 bug 装进 CLI)。
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
@@ -46,6 +53,7 @@ impl Outcome {
|
||||
Self {
|
||||
report,
|
||||
warnings: Vec::new(),
|
||||
exit_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +66,16 @@ impl Outcome {
|
||||
.into_iter()
|
||||
.map(|line| fill_program(&line))
|
||||
.collect(),
|
||||
exit_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定进程退出码(不指定 = 0)。
|
||||
#[must_use]
|
||||
pub fn with_exit_code(mut self, code: i32) -> Self {
|
||||
self.exit_code = Some(code);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 打印成功结果。
|
||||
|
||||
@@ -44,7 +44,9 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = writeln!(err, "错误: 写输出失败:{write_err}");
|
||||
return crate::error::EXIT_INTERNAL;
|
||||
}
|
||||
EXIT_OK
|
||||
// 命令可以自己指定退出码(`ull pack import` 的三态:0 / 3 / 1);
|
||||
// 没指定就是 0——其它命令的行为不变。
|
||||
outcome.exit_code.unwrap_or(EXIT_OK)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = output::print_error(&error, json, out, err);
|
||||
|
||||
Reference in new issue
Block a user