Compare commits

..

8 Commits

Author SHA1 Message Date
de2b804aad feat(init): 支持补齐内置模板空目录
新增 init 子命令,根据项目中的 world_*_packs.json 解析实际包目录并创建标准空目录。

改用 .empty-dirs 维护内置模板空目录清单,删除会污染用户项目和网易 Bedrock 加载流程的 .gitkeep 占位文件。
2026-05-14 22:16:18 +08:00
55e92c4b4f fix(release): 保留空目录到 ZIP 产物
移除 count_files 和 dir_has_included_files 两个预检函数,不再在打包时
过滤空目录。所有遍历到的子目录(包括空目录和仅含 .gitkeep 的目录)
现在都会写入 ZIP 条目,确保项目目录结构完整保留。

新增三个测试覆盖:空子目录、仅 .gitkeep 目录、emod-package 匹配的
空目录。
2026-05-10 02:08:01 +08:00
02e72fc9d8 style: 应用 rustfmt 格式化
整理 components、commands、entity 和 utils 中的 import 顺序、尾逗号、换行与文件末尾换行。

这保持代码风格与 rustfmt 输出一致,减少后续功能提交里的格式噪音。
2026-05-09 22:02:15 +08:00
405cdaab81 feat(release): 支持自定义打包产物目录
release 现在会在设置 EMOD_ARTIFACTS_DIR 时将 ZIP 输出到按项目名隔离的 artifacts 子目录,未设置时保持写入项目目录。

新增单元测试覆盖默认输出和环境变量输出两条路径,避免发布产物污染源项目或路径回退失效。
2026-05-09 22:01:47 +08:00
933aa295b2 fix(release): zip 文件名带项目名前缀
发布包现在从 canonicalize 后的项目目录提取项目名,并生成 <项目名>_release_<版本>.zip。这样在项目根目录直接执行 release 时也能得到稳定、可区分的产物名称。
2026-05-09 17:13:29 +08:00
85aa369793 feat(release): 增强打包流程, 支持 --pin、.emod-ignore、.emod-package
- 新增 --pin/-P 参数: 保留当前版本号不打补丁, 用于失败后重试发布
- 新增 .emod-ignore: gitignore 风格的打包排除规则, 支持通配符和取反
- 新增 .emod-package: 自定义打包包含规则, 支持通配符匹配
- 添加 preflight_pack_dirs 预检: 在打包前验证行为包/资源包目录和清单文件
- 引入 PackDirs 结构体, 消除重复的目录拼接逻辑
- 修复 ZIP 条目路径在 Windows 使用反斜杠导致网易审核工具报错
- 新增 zip_entry_path 统一使用正斜杠
- 添加 10 个单元测试覆盖核心场景
2026-05-04 01:31:44 +08:00
2af7d3fc2f feat(utils): 添加 io_error 辅助函数用于 IO 错误上下文包装
将匿名 os error 包装为包含操作描述和路径的友好错误信息,
避免出现无法定位问题来源的原始系统错误。
2026-05-04 01:31:27 +08:00
11576a8693 Fix built-in template rename order 2026-04-26 20:48:26 +08:00
55 changed files with 1459 additions and 1728 deletions

1355
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,10 @@ edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.5.32", features = ["derive"] } clap = { version = "4.5.32", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] } uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8" toml = "0.8"
zip = "2.2.2" zip = "2.2.2"
walkdir = "2" walkdir = "2"
anyhow = "1.0.97" regex = "1.10"
dirs = "5.0"
regex = "1.10"

142
build.rs Normal file
View File

@@ -0,0 +1,142 @@
use std::{
collections::BTreeMap,
env, fs, io,
path::{Path, PathBuf},
};
const EMPTY_DIRS_FILE: &str = ".empty-dirs";
fn main() {
println!("cargo:rerun-if-changed=examples");
let examples_root = PathBuf::from("examples");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is not set"));
let out_file = out_dir.join("embedded_examples.rs");
let mut dirs = Vec::new();
let mut files = Vec::new();
let mut empty_dirs_by_example = BTreeMap::new();
if examples_root.is_dir() {
collect_examples(
&examples_root,
&examples_root,
&mut dirs,
&mut files,
&mut empty_dirs_by_example,
)
.expect("failed to collect example templates");
}
dirs.sort();
dirs.dedup();
files.sort_by(|a, b| a.0.cmp(&b.0));
for empty_dirs in empty_dirs_by_example.values_mut() {
empty_dirs.sort();
empty_dirs.dedup();
}
let mut generated = String::new();
generated.push_str(
"#[allow(dead_code)]\nstruct EmbeddedFile {\n path: &'static str,\n contents: &'static [u8],\n}\n\n",
);
generated.push_str("#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_DIRS: &[&str] = &[\n");
for dir in &dirs {
generated.push_str(&format!(" {:?},\n", dir));
}
generated.push_str("];\n\n");
generated.push_str("#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_FILES: &[EmbeddedFile] = &[\n");
for (relative_path, absolute_path) in &files {
generated.push_str(&format!(
" EmbeddedFile {{ path: {:?}, contents: include_bytes!(r#\"{}\"#) }},\n",
relative_path,
absolute_path.display()
));
}
generated.push_str("];\n\n");
generated.push_str(
"#[allow(dead_code)]\nstruct EmbeddedExampleEmptyDirs {\n example: &'static str,\n dirs: &'static [&'static str],\n}\n\n",
);
generated.push_str(
"#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_EMPTY_DIRS: &[EmbeddedExampleEmptyDirs] = &[\n",
);
for (example, empty_dirs) in &empty_dirs_by_example {
generated.push_str(&format!(
" EmbeddedExampleEmptyDirs {{ example: {:?}, dirs: &[\n",
example
));
for dir in empty_dirs {
generated.push_str(&format!(" {:?},\n", dir));
}
generated.push_str(" ] },\n");
}
generated.push_str("];\n");
fs::write(out_file, generated).expect("failed to write embedded example list");
}
fn collect_examples(
root: &Path,
dir: &Path,
dirs: &mut Vec<String>,
files: &mut Vec<(String, PathBuf)>,
empty_dirs_by_example: &mut BTreeMap<String, Vec<String>>,
) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let relative_path = path
.strip_prefix(root)
.expect("example path is outside examples root")
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
dirs.push(relative_path);
collect_examples(root, &path, dirs, files, empty_dirs_by_example)?;
} else if path.is_file() {
if path.file_name().and_then(|name| name.to_str()) == Some(EMPTY_DIRS_FILE) {
collect_empty_dirs(&relative_path, &path, dirs, empty_dirs_by_example)?;
} else {
files.push((relative_path, path.canonicalize()?));
}
}
}
Ok(())
}
fn collect_empty_dirs(
relative_path: &str,
path: &Path,
dirs: &mut Vec<String>,
empty_dirs_by_example: &mut BTreeMap<String, Vec<String>>,
) -> io::Result<()> {
let (example, file_name) = relative_path.split_once('/').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} must be inside an example directory", EMPTY_DIRS_FILE),
)
})?;
if file_name != EMPTY_DIRS_FILE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected {} path: {}", EMPTY_DIRS_FILE, relative_path),
));
}
let contents = fs::read_to_string(path)?;
let empty_dirs = empty_dirs_by_example
.entry(example.to_string())
.or_insert_with(Vec::new);
for line in contents.lines() {
let dir = line.trim();
if dir.is_empty() || dir.starts_with('#') {
continue;
}
dirs.push(format!("{}/{}", example, dir));
empty_dirs.push(dir.to_string());
}
Ok(())
}

View File

@@ -0,0 +1,37 @@
behavior_pack/BoxData
behavior_pack/entities
behavior_pack/items
behavior_pack/loot_tables
behavior_pack/netease_feature_rules
behavior_pack/netease_features
behavior_pack/netease_items_beh
behavior_pack/Parts
behavior_pack/Presets
behavior_pack/recipes
behavior_pack/spawn_rules
behavior_pack/structures
behavior_pack/trading
behavior_pack/Galaxy/Macro
behavior_pack/Galaxy/Template
behavior_pack/storyline/level
resource_pack/animation_controllers
resource_pack/animations
resource_pack/attachables
resource_pack/effects
resource_pack/entity
resource_pack/font
resource_pack/materials
resource_pack/models/animation
resource_pack/models/editor_materials
resource_pack/models/effect
resource_pack/models/geometry
resource_pack/models/mesh
resource_pack/models/netease_block
resource_pack/render_controllers
resource_pack/shaders/glsl
resource_pack/sounds
resource_pack/textures/entity
resource_pack/textures/models
resource_pack/textures/particle
resource_pack/textures/ui
resource_pack/ui

View File

@@ -1,7 +1,7 @@
use crate::commands::ComponentsArgs; use crate::commands::ComponentsArgs;
use crate::entity; use crate::entity;
use crate::utils::file;
use crate::error::Result; use crate::error::Result;
use crate::utils::file;
use serde_json::{json, to_string_pretty}; use serde_json::{json, to_string_pretty};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -18,40 +18,43 @@ pub fn execute(args: &ComponentsArgs) {
fn run_components(args: &ComponentsArgs) -> Result<()> { fn run_components(args: &ComponentsArgs) -> Result<()> {
let project_path = file::find_project_dir(&args.path)?; let project_path = file::find_project_dir(&args.path)?;
validate_input_files(&args.geo, &args.texture)?; validate_input_files(&args.geo, &args.texture)?;
let identifier = args.identifier.as_deref().unwrap_or("unknown"); let identifier = args.identifier.as_deref().unwrap_or("unknown");
match args.component.as_str() { match args.component.as_str() {
COMPONENT_3D_ITEM => create_3dmodel( COMPONENT_3D_ITEM => create_3dmodel(
args.geo.as_deref().unwrap_or("./model.geo.json"), args.geo.as_deref().unwrap_or("./model.geo.json"),
args.texture.as_deref().unwrap_or("./texture.png"), args.texture.as_deref().unwrap_or("./texture.png"),
identifier, identifier,
&project_path &project_path,
), ),
_ => Err(crate::error::CliError::NotFound( _ => Err(crate::error::CliError::NotFound(format!(
format!("组件 '{}' 不存在", args.component) "组件 '{}' 不存在",
)), args.component
))),
} }
} }
fn validate_input_files(geo: &Option<String>, texture: &Option<String>) -> Result<()> { fn validate_input_files(geo: &Option<String>, texture: &Option<String>) -> Result<()> {
let geo_path = geo.as_deref().unwrap_or("./model.geo.json"); let geo_path = geo.as_deref().unwrap_or("./model.geo.json");
let texture_path = texture.as_deref().unwrap_or("./texture.png"); let texture_path = texture.as_deref().unwrap_or("./texture.png");
if !PathBuf::from(geo_path).exists() { if !PathBuf::from(geo_path).exists() {
return Err(crate::error::CliError::NotFound( return Err(crate::error::CliError::NotFound(format!(
format!("几何文件 {} 不存在", geo_path) "几何文件 {} 不存在",
)); geo_path
)));
} }
if !PathBuf::from(texture_path).exists() { if !PathBuf::from(texture_path).exists() {
return Err(crate::error::CliError::NotFound( return Err(crate::error::CliError::NotFound(format!(
format!("材质文件 {} 不存在", texture_path) "材质文件 {} 不存在",
)); texture_path
)));
} }
Ok(()) Ok(())
} }
@@ -62,7 +65,7 @@ fn create_3dmodel(
project_path: &PathBuf, project_path: &PathBuf,
) -> Result<()> { ) -> Result<()> {
let project_info = entity::get_current_release_info(&project_path)?; let project_info = entity::get_current_release_info(&project_path)?;
let beh_path = project_path.join(format!( let beh_path = project_path.join(format!(
"behavior_pack_{}", "behavior_pack_{}",
project_info.behavior_identifier project_info.behavior_identifier
@@ -71,32 +74,32 @@ fn create_3dmodel(
"resource_pack_{}", "resource_pack_{}",
project_info.resource_identifier project_info.resource_identifier
)); ));
create_item_files(&beh_path, &res_path, identifier)?; create_item_files(&beh_path, &res_path, identifier)?;
copy_assets(&res_path, geo, texture, identifier)?; copy_assets(&res_path, geo, texture, identifier)?;
create_attachable_file(&res_path, identifier)?; create_attachable_file(&res_path, identifier)?;
Ok(()) Ok(())
} }
fn create_item_files(beh_path: &PathBuf, res_path: &PathBuf, identifier: &str) -> Result<()> { fn create_item_files(beh_path: &PathBuf, res_path: &PathBuf, identifier: &str) -> Result<()> {
let behavior_item = create_behavior_item_json(identifier); let behavior_item = create_behavior_item_json(identifier);
let resource_item = create_resource_item_json(identifier); let resource_item = create_resource_item_json(identifier);
let f_identifier = identifier.replace(":", "_"); let f_identifier = identifier.replace(":", "_");
let items_beh_dir = beh_path.join("netease_items_beh"); let items_beh_dir = beh_path.join("netease_items_beh");
let items_res_dir = res_path.join("netease_items_res"); let items_res_dir = res_path.join("netease_items_res");
fs::create_dir_all(&items_beh_dir)?; fs::create_dir_all(&items_beh_dir)?;
fs::create_dir_all(&items_res_dir)?; fs::create_dir_all(&items_res_dir)?;
let beh_item_path = items_beh_dir.join(format!("{}.json", f_identifier)); let beh_item_path = items_beh_dir.join(format!("{}.json", f_identifier));
let res_item_path = items_res_dir.join(format!("{}.json", f_identifier)); let res_item_path = items_res_dir.join(format!("{}.json", f_identifier));
fs::write(&beh_item_path, to_string_pretty(&behavior_item)?)?; fs::write(&beh_item_path, to_string_pretty(&behavior_item)?)?;
fs::write(&res_item_path, to_string_pretty(&resource_item)?)?; fs::write(&res_item_path, to_string_pretty(&resource_item)?)?;
Ok(()) Ok(())
} }
@@ -137,27 +140,22 @@ fn create_resource_item_json(identifier: &str) -> serde_json::Value {
}) })
} }
fn copy_assets( fn copy_assets(res_path: &PathBuf, geo: &str, texture: &str, identifier: &str) -> Result<()> {
res_path: &PathBuf,
geo: &str,
texture: &str,
identifier: &str,
) -> Result<()> {
let f_identifier = identifier.replace(":", "_"); let f_identifier = identifier.replace(":", "_");
copy_texture(res_path, texture, &f_identifier)?; copy_texture(res_path, texture, &f_identifier)?;
copy_geometry(res_path, geo, identifier, &f_identifier)?; copy_geometry(res_path, geo, identifier, &f_identifier)?;
Ok(()) Ok(())
} }
fn copy_texture(res_path: &PathBuf, texture: &str, f_identifier: &str) -> Result<()> { fn copy_texture(res_path: &PathBuf, texture: &str, f_identifier: &str) -> Result<()> {
let texture_dir = res_path.join("textures/models"); let texture_dir = res_path.join("textures/models");
fs::create_dir_all(&texture_dir)?; fs::create_dir_all(&texture_dir)?;
let target_texture = texture_dir.join(format!("{}.png", f_identifier)); let target_texture = texture_dir.join(format!("{}.png", f_identifier));
fs::copy(texture, target_texture)?; fs::copy(texture, target_texture)?;
Ok(()) Ok(())
} }
@@ -169,26 +167,26 @@ fn copy_geometry(
) -> Result<()> { ) -> Result<()> {
let geo_dir = res_path.join("models/entity"); let geo_dir = res_path.join("models/entity");
fs::create_dir_all(&geo_dir)?; fs::create_dir_all(&geo_dir)?;
let mut geo_value = file::read_file_to_json(&PathBuf::from(geo))?; let mut geo_value = file::read_file_to_json(&PathBuf::from(geo))?;
let geo_name = format!("geometry.{}", identifier.replace(":", ".")); let geo_name = format!("geometry.{}", identifier.replace(":", "."));
geo_value["format_version"] = json!("1.12.0"); geo_value["format_version"] = json!("1.12.0");
geo_value["minecraft:geometry"][0]["description"]["identifier"] = json!(geo_name); geo_value["minecraft:geometry"][0]["description"]["identifier"] = json!(geo_name);
let target_geo = geo_dir.join(format!("{}.geo.json", f_identifier)); let target_geo = geo_dir.join(format!("{}.geo.json", f_identifier));
file::write_json_to_file(&target_geo, &geo_value)?; file::write_json_to_file(&target_geo, &geo_value)?;
Ok(()) Ok(())
} }
fn create_attachable_file(res_path: &PathBuf, identifier: &str) -> Result<()> { fn create_attachable_file(res_path: &PathBuf, identifier: &str) -> Result<()> {
let attachable_dir = res_path.join("attachables"); let attachable_dir = res_path.join("attachables");
fs::create_dir_all(&attachable_dir)?; fs::create_dir_all(&attachable_dir)?;
let f_identifier = identifier.replace(":", "_"); let f_identifier = identifier.replace(":", "_");
let geo_name = identifier.replace(":", "."); let geo_name = identifier.replace(":", ".");
let attachable = json!({ let attachable = json!({
"format_version": "1.10.0", "format_version": "1.10.0",
"minecraft:attachable": { "minecraft:attachable": {
@@ -214,9 +212,9 @@ fn create_attachable_file(res_path: &PathBuf, identifier: &str) -> Result<()> {
} }
} }
}); });
let target_file = attachable_dir.join(format!("{}.json", &f_identifier)); let target_file = attachable_dir.join(format!("{}.json", &f_identifier));
fs::write(target_file, to_string_pretty(&attachable)?)?; fs::write(target_file, to_string_pretty(&attachable)?)?;
Ok(()) Ok(())
} }

View File

@@ -1,25 +1,20 @@
use crate::{ use crate::{
config::Config, commands::CreateArgs, entity::project::ProjectInfo, error::Result, template::TemplateEngine,
entity::project::ProjectInfo,
template::TemplateEngine,
utils::{file, http::HttpClient},
}; };
use std::{fs, path::PathBuf}; use std::{fs, path::PathBuf};
use crate::commands::CreateArgs;
use crate::error::Result;
use crate::utils::git;
use uuid::Uuid; use uuid::Uuid;
pub fn execute(args: &CreateArgs, temp_dir: &PathBuf) { include!(concat!(env!("OUT_DIR"), "/embedded_examples.rs"));
if let Err(e) = create_project(&args.name, args.target.as_deref(), temp_dir) {
eprintln!("错误: {}", e); pub fn execute(args: &CreateArgs) {
if let Err(e) = create_project(&args.name, args.target.as_deref()) {
eprintln!("Error: {}", e);
return; return;
} }
println!("成功: 项目已创建"); println!("Success: project created.");
} }
fn create_project(name: &str, target: Option<&str>, temp_dir: &PathBuf) -> Result<()> { fn create_project(name: &str, target: Option<&str>) -> Result<()> {
let target = target.unwrap_or("default"); let target = target.unwrap_or("default");
check_example_exists(target)?; check_example_exists(target)?;
@@ -27,30 +22,16 @@ fn create_project(name: &str, target: Option<&str>, temp_dir: &PathBuf) -> Resul
let local_dir = PathBuf::from(format!("./{}", name)); let local_dir = PathBuf::from(format!("./{}", name));
fs::create_dir(&local_dir)?; fs::create_dir(&local_dir)?;
let template_dir = clone_and_copy_template(target, temp_dir, &local_dir)?; copy_embedded_template(target, &local_dir)?;
initialize_project_with_template(&local_dir, &local_dir, name)?;
initialize_project_with_template(&template_dir, &local_dir, name)?;
Ok(()) Ok(())
} }
fn check_example_exists(target: &str) -> Result<()> { fn check_example_exists(target: &str) -> Result<()> {
let check_url = format!( if !embedded_example_exists(target) {
"https://api.github.com/repos/AiYo-Studio/emod-cli/contents/examples/{}",
target
);
let client = if cfg!(debug_assertions) {
HttpClient::new_with_proxy("http://127.0.0.1:1080")?
} else {
HttpClient::new()?
};
let resp = client.get(&check_url)?;
if !resp.status().is_success() {
return Err(crate::error::CliError::NotFound(format!( return Err(crate::error::CliError::NotFound(format!(
"示例模板 '{}' 不存在", "example target '{}' was not found in the built-in examples",
target target
))); )));
} }
@@ -58,21 +39,47 @@ fn check_example_exists(target: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn clone_and_copy_template( fn embedded_example_exists(target: &str) -> bool {
target: &str, let target = normalize_embedded_path(target);
temp_dir: &PathBuf, let prefix = format!("{}/", target);
local_dir: &PathBuf,
) -> Result<PathBuf> {
let _ = fs::remove_dir_all(format!("{}/tmp", temp_dir.display()));
let config = Config::load(); EMBEDDED_EXAMPLE_DIRS
let url = &config.repo_url; .iter()
git::clone_remote_project(url.to_string(), temp_dir)?; .any(|dir| *dir == target || dir.starts_with(&prefix))
|| EMBEDDED_EXAMPLE_FILES
.iter()
.any(|file| file.path.starts_with(&prefix))
}
let target_dir = PathBuf::from(format!("{}/tmp/examples/{}", temp_dir.display(), target)); fn copy_embedded_template(target: &str, local_dir: &PathBuf) -> Result<()> {
file::copy_folder(&target_dir, local_dir)?; let target = normalize_embedded_path(target);
let prefix = format!("{}/", target);
Ok(target_dir) for dir in EMBEDDED_EXAMPLE_DIRS {
if let Some(relative_path) = dir.strip_prefix(&prefix) {
if !relative_path.is_empty() {
fs::create_dir_all(local_dir.join(relative_path))?;
}
}
}
for file in EMBEDDED_EXAMPLE_FILES {
let Some(relative_path) = file.path.strip_prefix(&prefix) else {
continue;
};
let output_path = local_dir.join(relative_path);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(output_path, file.contents)?;
}
Ok(())
}
fn normalize_embedded_path(path: &str) -> String {
path.trim_matches(['/', '\\']).replace('\\', "/")
} }
fn initialize_project_with_template( fn initialize_project_with_template(
@@ -80,14 +87,10 @@ fn initialize_project_with_template(
local_dir: &PathBuf, local_dir: &PathBuf,
name: &str, name: &str,
) -> Result<()> { ) -> Result<()> {
let lower_name = format!( let lower_name = lower_first_char(name)?;
"{}{}",
name.chars().next().unwrap().to_lowercase(),
&name[1..]
);
println!("项目名称: {}", name); println!("Project name: {}", name);
println!("标识名称: {}", lower_name); println!("Lower project name: {}", lower_name);
let project_info = generate_project_info(name, &lower_name); let project_info = generate_project_info(name, &lower_name);
@@ -124,10 +127,30 @@ fn initialize_project_with_template(
); );
engine.process_directory(local_dir)?; engine.process_directory(local_dir)?;
remove_template_config(local_dir)?;
Ok(()) Ok(())
} }
fn remove_template_config(local_dir: &PathBuf) -> Result<()> {
let template_config = local_dir.join("template.toml");
if template_config.exists() {
fs::remove_file(template_config)?;
}
Ok(())
}
fn lower_first_char(name: &str) -> Result<String> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(crate::error::CliError::InvalidInput(
"project name cannot be empty".to_string(),
));
};
Ok(format!("{}{}", first.to_lowercase(), chars.as_str()))
}
fn generate_project_info(name: &str, lower_name: &str) -> ProjectInfo { fn generate_project_info(name: &str, lower_name: &str) -> ProjectInfo {
ProjectInfo { ProjectInfo {
name: name.to_string(), name: name.to_string(),

67
src/commands/init.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::fs;
use crate::commands::InitArgs;
use crate::entity;
use crate::error::{CliError, Result};
use crate::utils::file;
include!(concat!(env!("OUT_DIR"), "/embedded_examples.rs"));
pub fn execute(args: &InitArgs) {
if let Err(e) = run_init(args) {
eprintln!("❌ 项目初始化失败: {}", e);
return;
}
println!("🍀 项目初始化完成");
}
fn run_init(args: &InitArgs) -> Result<()> {
let project_dir = file::find_project_dir(&args.path)?;
let target = args.target.as_deref().unwrap_or("default");
let empty_dirs = lookup_empty_dirs(target)?;
let release_info = entity::get_current_release_info(&project_dir)?;
let behavior_pack = format!("behavior_pack_{}", release_info.behavior_identifier);
let resource_pack = format!("resource_pack_{}", release_info.resource_identifier);
let mut created = 0usize;
let mut skipped = 0usize;
for rel in empty_dirs {
let rewritten = rewrite_pack_path(rel, &behavior_pack, &resource_pack);
let abs = project_dir.join(&rewritten);
if abs.is_dir() {
skipped += 1;
continue;
}
fs::create_dir_all(&abs).map_err(|e| file::io_error("创建目录", &abs, e))?;
println!("📁 创建目录: {}", rewritten);
created += 1;
}
println!("✅ 新建 {} 个目录, 跳过 {} 个已存在目录", created, skipped);
Ok(())
}
fn lookup_empty_dirs(target: &str) -> Result<&'static [&'static str]> {
EMBEDDED_EXAMPLE_EMPTY_DIRS
.iter()
.find(|e| e.example == target)
.map(|e| e.dirs)
.ok_or_else(|| {
CliError::NotFound(format!(
"example target '{}' was not found in the built-in examples",
target
))
})
}
fn rewrite_pack_path(rel: &str, behavior: &str, resource: &str) -> String {
if let Some(rest) = rel.strip_prefix("behavior_pack/") {
format!("{}/{}", behavior, rest)
} else if let Some(rest) = rel.strip_prefix("resource_pack/") {
format!("{}/{}", resource, rest)
} else {
rel.to_string()
}
}

View File

@@ -1,7 +1,8 @@
use clap::{arg, Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand, arg};
pub mod components; pub mod components;
pub mod create; pub mod create;
pub mod init;
pub mod release; pub mod release;
#[derive(Parser)] #[derive(Parser)]
@@ -24,6 +25,8 @@ pub enum Commands {
Release(ReleaseArgs), Release(ReleaseArgs),
/// Create a new mod project /// Create a new mod project
Create(CreateArgs), Create(CreateArgs),
/// Initialize standard empty directories for an existing project
Init(InitArgs),
/// Create a new component /// Create a new component
Components(ComponentsArgs), Components(ComponentsArgs),
} }
@@ -34,8 +37,12 @@ pub struct ReleaseArgs {
#[arg(short, long)] #[arg(short, long)]
pub path: Option<String>, pub path: Option<String>,
/// The version of the project /// The version of the project
#[arg(short, long)] #[arg(short, long, conflicts_with = "pin")]
pub ver: Option<String>, pub ver: Option<String>,
/// Reuse the current version without auto-incrementing.
/// Useful when retrying after a failed release that already wrote new version files.
#[arg(short = 'P', long, conflicts_with = "ver")]
pub pin: bool,
} }
#[derive(Args)] #[derive(Args)]
@@ -48,6 +55,16 @@ pub struct CreateArgs {
pub target: Option<String>, pub target: Option<String>,
} }
#[derive(Args)]
pub struct InitArgs {
/// The path of the project (default: current directory)
#[arg(short, long)]
pub path: Option<String>,
/// Example target whose layout to apply
#[arg(short, long)]
pub target: Option<String>,
}
#[derive(Args)] #[derive(Args)]
pub struct ComponentsArgs { pub struct ComponentsArgs {
/// The path of the project /// The path of the project
@@ -64,5 +81,5 @@ pub struct ComponentsArgs {
pub texture: Option<String>, pub texture: Option<String>,
/// The item's identifier /// The item's identifier
#[arg(short, long)] #[arg(short, long)]
pub identifier: Option<String> pub identifier: Option<String>,
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
#[serde(default = "default_repo_url")]
pub repo_url: String,
}
fn default_repo_url() -> String {
"https://github.com/AiYo-Studio/emod-cli.git".to_string()
}
impl Default for Config {
fn default() -> Self {
Self {
repo_url: default_repo_url(),
}
}
}
impl Config {
pub fn load() -> Self {
let config_path = Self::config_path();
if let Ok(content) = fs::read_to_string(&config_path) {
if let Ok(config) = serde_json::from_str(&content) {
return config;
}
}
Self::default()
}
fn config_path() -> PathBuf {
let home = dirs::home_dir().expect("无法获取用户主目录");
home.join(".emod-cli.json")
}
}

View File

@@ -12,4 +12,4 @@ pub struct ReleaseInfo {
pub resource_version: Vec<u32>, pub resource_version: Vec<u32>,
pub behavior_identifier: String, pub behavior_identifier: String,
pub resource_identifier: String, pub resource_identifier: String,
} }

View File

@@ -1,13 +1,11 @@
use std::io;
use std::fmt; use std::fmt;
use std::io;
use std::num::ParseIntError; use std::num::ParseIntError;
#[derive(Debug)] #[derive(Debug)]
pub enum CliError { pub enum CliError {
Io(io::Error), Io(io::Error),
Json(serde_json::Error), Json(serde_json::Error),
Network(reqwest::Error),
Anyhow(anyhow::Error),
Zip(zip::result::ZipError), Zip(zip::result::ZipError),
Walkdir(walkdir::Error), Walkdir(walkdir::Error),
Parse(ParseIntError), Parse(ParseIntError),
@@ -20,17 +18,15 @@ pub enum CliError {
impl fmt::Display for CliError { impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
CliError::Io(e) => write!(f, "IO错误: {}", e), CliError::Io(e) => write!(f, "I/O error: {}", e),
CliError::Json(e) => write!(f, "JSON解析错误: {}", e), CliError::Json(e) => write!(f, "JSON parse error: {}", e),
CliError::Network(e) => write!(f, "网络错误: {}", e), CliError::Zip(e) => write!(f, "ZIP error: {}", e),
CliError::Anyhow(e) => write!(f, "{}", e), CliError::Walkdir(e) => write!(f, "directory traversal error: {}", e),
CliError::Zip(e) => write!(f, "压缩错误: {}", e), CliError::Parse(e) => write!(f, "parse error: {}", e),
CliError::Walkdir(e) => write!(f, "目录遍历错误: {}", e), CliError::Toml(e) => write!(f, "TOML parse error: {}", e),
CliError::Parse(e) => write!(f, "解析错误: {}", e), CliError::NotFound(msg) => write!(f, "not found: {}", msg),
CliError::Toml(e) => write!(f, "TOML解析错误: {}", e), CliError::InvalidData(msg) => write!(f, "invalid data: {}", msg),
CliError::NotFound(msg) => write!(f, "未找到: {}", msg), CliError::InvalidInput(msg) => write!(f, "invalid input: {}", msg),
CliError::InvalidData(msg) => write!(f, "无效数据: {}", msg),
CliError::InvalidInput(msg) => write!(f, "无效输入: {}", msg),
} }
} }
} }
@@ -49,18 +45,6 @@ impl From<serde_json::Error> for CliError {
} }
} }
impl From<reqwest::Error> for CliError {
fn from(err: reqwest::Error) -> Self {
CliError::Network(err)
}
}
impl From<anyhow::Error> for CliError {
fn from(err: anyhow::Error) -> Self {
CliError::Anyhow(err)
}
}
impl From<zip::result::ZipError> for CliError { impl From<zip::result::ZipError> for CliError {
fn from(err: zip::result::ZipError) -> Self { fn from(err: zip::result::ZipError) -> Self {
CliError::Zip(err) CliError::Zip(err)
@@ -85,4 +69,4 @@ impl From<toml::de::Error> for CliError {
} }
} }
pub type Result<T> = std::result::Result<T, CliError>; pub type Result<T> = std::result::Result<T, CliError>;

View File

@@ -1,29 +1,18 @@
mod commands; mod commands;
mod entity; mod entity;
mod utils;
mod error; mod error;
mod config;
mod template; mod template;
mod utils;
use crate::commands::{Cli, Commands}; use crate::commands::{Cli, Commands};
use clap::Parser; use clap::Parser;
use std::{env, fs, path::PathBuf};
fn main() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let temp_dir = check_temp_dir();
match &cli.command { match &cli.command {
Commands::Release(args) => commands::release::execute(args), Commands::Release(args) => commands::release::execute(args),
Commands::Create(args) => commands::create::execute(args, &temp_dir), Commands::Create(args) => commands::create::execute(args),
Commands::Init(args) => commands::init::execute(args),
Commands::Components(args) => commands::components::execute(args), Commands::Components(args) => commands::components::execute(args),
} }
} }
fn check_temp_dir() -> PathBuf {
let mut temp_dir = env::temp_dir();
temp_dir.push("emod-cli");
if let Err(e) = fs::create_dir_all(&temp_dir) {
eprintln!("Error: Failed to create temp directory: {}", e);
}
temp_dir
}

View File

@@ -1,8 +1,8 @@
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use regex::Regex;
use walkdir::WalkDir; use walkdir::WalkDir;
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -47,7 +47,7 @@ impl TemplateEngine {
let config_path = template_dir.join("template.toml"); let config_path = template_dir.join("template.toml");
let content = fs::read_to_string(&config_path)?; let content = fs::read_to_string(&config_path)?;
let config: TemplateConfig = toml::from_str(&content)?; let config: TemplateConfig = toml::from_str(&content)?;
Ok(Self { Ok(Self {
config, config,
variables: HashMap::new(), variables: HashMap::new(),
@@ -62,7 +62,7 @@ impl TemplateEngine {
for (key, var_config) in &self.config.variables { for (key, var_config) in &self.config.variables {
if var_config.required && !self.variables.contains_key(key) { if var_config.required && !self.variables.contains_key(key) {
return Err(crate::error::CliError::InvalidInput(format!( return Err(crate::error::CliError::InvalidInput(format!(
"缺少必需的变量: {} ({})", "missing required template variable: {} ({})",
key, var_config.description key, var_config.description
))); )));
} }
@@ -72,31 +72,19 @@ impl TemplateEngine {
pub fn process_directory(&self, dir: &Path) -> crate::error::Result<()> { pub fn process_directory(&self, dir: &Path) -> crate::error::Result<()> {
self.validate_variables()?; self.validate_variables()?;
self.replace_in_files(dir)?; self.replace_in_files(dir)?;
self.apply_renames(dir)?; self.apply_renames(dir)?;
self.verify_no_placeholders(dir)?; self.verify_no_placeholders(dir)?;
Ok(()) Ok(())
} }
fn replace_in_files(&self, dir: &Path) -> crate::error::Result<()> { fn replace_in_files(&self, dir: &Path) -> crate::error::Result<()> {
let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap(); let placeholder_regex = placeholder_regex();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path(); let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(ext) = path.extension().and_then(|s| s.to_str()) { if !path.is_file() || !self.should_process(path) {
if !self.config.process.file_extensions.contains(&ext.to_string()) {
continue;
}
} else {
continue; continue;
} }
@@ -105,8 +93,8 @@ impl TemplateEngine {
for cap in placeholder_regex.captures_iter(&content) { for cap in placeholder_regex.captures_iter(&content) {
let placeholder = &cap[0]; let placeholder = &cap[0];
let var_name = &cap[1]; let var_name = placeholder_name(&cap);
if let Some(value) = self.variables.get(var_name) { if let Some(value) = self.variables.get(var_name) {
updated = updated.replace(placeholder, value); updated = updated.replace(placeholder, value);
} }
@@ -114,7 +102,7 @@ impl TemplateEngine {
if updated != content { if updated != content {
fs::write(path, updated)?; fs::write(path, updated)?;
println!(" - 处理文件: {}", path.display()); println!(" - processed template file: {}", path.display());
} }
} }
@@ -122,9 +110,9 @@ impl TemplateEngine {
} }
fn apply_renames(&self, dir: &Path) -> crate::error::Result<()> { fn apply_renames(&self, dir: &Path) -> crate::error::Result<()> {
for rule in self.config.renames.iter().rev() { for rule in &self.config.renames {
let from_path = dir.join(&rule.from); let from_path = dir.join(&rule.from);
if !from_path.exists() { if !from_path.exists() {
continue; continue;
} }
@@ -137,20 +125,20 @@ impl TemplateEngine {
} }
fs::rename(&from_path, &to_path)?; fs::rename(&from_path, &to_path)?;
println!(" - 重命名: {} -> {}", rule.from, to_path_str); println!(" - renamed: {} -> {}", rule.from, to_path_str);
} }
Ok(()) Ok(())
} }
fn replace_placeholders(&self, text: &str) -> String { fn replace_placeholders(&self, text: &str) -> String {
let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap(); let placeholder_regex = placeholder_regex();
let mut result = text.to_string(); let mut result = text.to_string();
for cap in placeholder_regex.captures_iter(text) { for cap in placeholder_regex.captures_iter(text) {
let placeholder = &cap[0]; let placeholder = &cap[0];
let var_name = &cap[1]; let var_name = placeholder_name(&cap);
if let Some(value) = self.variables.get(var_name) { if let Some(value) = self.variables.get(var_name) {
result = result.replace(placeholder, value); result = result.replace(placeholder, value);
} }
@@ -160,39 +148,58 @@ impl TemplateEngine {
} }
fn verify_no_placeholders(&self, dir: &Path) -> crate::error::Result<()> { fn verify_no_placeholders(&self, dir: &Path) -> crate::error::Result<()> {
let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap(); let placeholder_regex = placeholder_regex();
let mut found_placeholders = Vec::new(); let mut found_placeholders = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path(); let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(ext) = path.extension().and_then(|s| s.to_str()) { if !path.is_file() || !self.should_process(path) {
if !self.config.process.file_extensions.contains(&ext.to_string()) {
continue;
}
} else {
continue; continue;
} }
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
for cap in placeholder_regex.captures_iter(&content) { for cap in placeholder_regex.captures_iter(&content) {
let var_name = &cap[1]; let var_name = placeholder_name(&cap);
if !self.config.variables.contains_key(var_name) {
continue;
}
found_placeholders.push(format!("{}:{}", path.display(), var_name)); found_placeholders.push(format!("{}:{}", path.display(), var_name));
} }
} }
if !found_placeholders.is_empty() { if !found_placeholders.is_empty() {
eprintln!("警告: 发现未替换的占位符:"); return Err(crate::error::CliError::InvalidData(format!(
for placeholder in found_placeholders { "unresolved template placeholders: {}",
eprintln!(" - {}", placeholder); found_placeholders.join(", ")
} )));
} }
Ok(()) Ok(())
} }
}
fn should_process(&self, path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.map(|ext| {
self.config
.process
.file_extensions
.iter()
.any(|configured| configured == ext)
})
.unwrap_or(false)
}
}
fn placeholder_regex() -> Regex {
Regex::new(r"\{\{(\w+)\}\}|__(\w+)__").unwrap()
}
fn placeholder_name<'a>(cap: &'a regex::Captures<'a>) -> &'a str {
cap.get(1)
.or_else(|| cap.get(2))
.expect("placeholder capture is missing")
.as_str()
}

View File

@@ -1,39 +1,21 @@
use crate::error::Result; use crate::error::{CliError, Result};
use serde_json::Value; use serde_json::Value;
use std::{fs, path::PathBuf}; use std::{
fs, io,
pub fn copy_folder(src: &PathBuf, dest: &PathBuf) -> Result<()> { path::{Path, PathBuf},
if !src.exists() || !src.is_dir() { };
return Err(crate::error::CliError::NotFound(format!(
"源目录不存在: {}",
src.display()
)));
}
if !dest.exists() {
fs::create_dir_all(dest)?;
}
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(src_path.file_name().unwrap());
if src_path.is_file() {
fs::copy(&src_path, &dest_path)?;
} else if src_path.is_dir() {
copy_folder(&src_path, &dest_path)?;
}
}
Ok(())
}
pub fn read_file_to_json(path: &PathBuf) -> Result<Value> { pub fn read_file_to_json(path: &PathBuf) -> Result<Value> {
let file = fs::read_to_string(path)?; let content = fs::read_to_string(path).map_err(|e| io_error("读取文件", path, e))?;
let json: Value = serde_json::from_str(&file)?; serde_json::from_str(&content)
Ok(json) .map_err(|e| CliError::InvalidData(format!("解析 JSON '{}' 失败: {}", path.display(), e)))
} }
pub fn write_json_to_file(path: &PathBuf, value: &Value) -> Result<()> { pub fn write_json_to_file(path: &PathBuf, value: &Value) -> Result<()> {
let content = serde_json::to_string_pretty(value)?; let content = serde_json::to_string_pretty(value).map_err(|e| {
fs::write(path, content)?; CliError::InvalidData(format!("序列化 JSON '{}' 失败: {}", path.display(), e))
})?;
fs::write(path, content).map_err(|e| io_error("写入文件", path, e))?;
Ok(()) Ok(())
} }
@@ -51,3 +33,12 @@ pub fn find_project_dir(path: &Option<String>) -> Result<PathBuf> {
let path = path.as_deref().unwrap_or("."); let path = path.as_deref().unwrap_or(".");
Ok(PathBuf::from(path)) Ok(PathBuf::from(path))
} }
/// Wraps an [`io::Error`] with the operation name and path so error messages
/// stop being anonymous "os error 3" strings.
pub fn io_error(op: &str, path: &Path, err: io::Error) -> CliError {
CliError::Io(io::Error::new(
err.kind(),
format!("{} '{}' 失败: {}", op, path.display(), err),
))
}

View File

@@ -1,11 +0,0 @@
use std::path::PathBuf;
use crate::error::Result;
pub fn clone_remote_project(url: String, temp_dir: &PathBuf) -> Result<()> {
std::process::Command::new("git")
.arg("clone")
.arg(url)
.arg(format!("{}/tmp", temp_dir.display()))
.output()?;
Ok(())
}

View File

@@ -1,26 +0,0 @@
use reqwest::blocking::Client;
use crate::error::Result;
pub struct HttpClient {
client: Client,
}
impl HttpClient {
pub fn new() -> Result<Self> {
let client = Client::builder().build()?;
Ok(Self { client })
}
pub fn new_with_proxy(proxy_url: &str) -> Result<Self> {
let proxy = reqwest::Proxy::all(proxy_url)?;
let client = Client::builder().proxy(proxy).build()?;
Ok(Self { client })
}
pub fn get(&self, url: &str) -> Result<reqwest::blocking::Response> {
Ok(self.client
.get(url)
.header("User-Agent", "emod-cli")
.send()?)
}
}

View File

@@ -1,3 +1 @@
pub mod git;
pub mod file; pub mod file;
pub mod http;