合并 Phaser 一键发布自动构建与打包
Project CI / AI game creator shell Rust crates (push) Successful in 1m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 5m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m53s
Project CI / Native shell tests (push) Successful in 7m28s
Project CI / Frontend tests (push) Successful in 3m38s
Project CI / Repository checks (push) Successful in 3m36s
Project CI / AI game creator shell web tests (push) Successful in 2m55s

This commit was merged in pull request #481.
This commit is contained in:
2026-09-22 21:03:57 +08:00
7 changed files with 426 additions and 15 deletions
@@ -5974,15 +5974,19 @@ pub(crate) fn create_local_project_checkpoint(
create_local_project_checkpoint_at(root)
}
/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。
///
/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时
/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。
#[tauri::command]
pub(crate) fn export_local_project_package(
pub(crate) async fn export_local_project_package(
project_path: String,
) -> Result<LocalProjectExportPackageResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let _lock = acquire_project_write_lock(root, "project.export_package")?;
advance_agent_runtime_project_revision_locked(root)?;
export_local_project_package_at(root)
export_local_project_package_for_publish_at(root).await
}
#[tauri::command]
@@ -136,6 +136,159 @@ pub(crate) fn export_local_project_package_at(
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤,
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
///
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。
pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result<Option<&'static str>, String> {
for cwd in [".", "game"] {
let package_root = if cwd == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd)?
};
let package_path = package_root.join("package.json");
let metadata = match fs::symlink_metadata(&package_path) {
Ok(metadata) => metadata,
Err(_) => continue,
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
let Ok(content) = fs::read_to_string(&package_path) else {
continue;
};
let Ok(package) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let declared = package
.get("scripts")
.and_then(|scripts| scripts.get("build"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if declared.is_some() {
return Ok(Some(cwd));
}
}
Ok(None)
}
/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。
pub(crate) fn read_publish_build_command(
root: &Path,
cwd_relative: &str,
) -> Result<String, String> {
let package_root = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd_relative)?
};
let package_path = package_root.join("package.json");
let content = fs::read_to_string(&package_path).map_err(|error| {
format!(
"读取 package.json 失败:{}: {error}",
package_path.display()
)
})?;
let package: serde_json::Value = serde_json::from_str(&content)
.map_err(|error| format!("解析 package.json 失败:{error}"))?;
package
.get("scripts")
.and_then(|scripts| scripts.get("build"))
.and_then(serde_json::Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "package.json 未定义 build 脚本".to_string())
}
/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。
fn publish_build_failure_tail(output: &str) -> String {
const MAX_CHARS: usize = 2_000;
let trimmed = output.trim();
let chars = trimmed.chars().count();
if chars <= MAX_CHARS {
return trimmed.to_string();
}
let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::<String>();
format!("{tail}")
}
/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PublishBuildPlan {
pub(crate) cwd_relative: &'static str,
pub(crate) command: String,
/// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。
pub(crate) needs_dependency_install: bool,
}
/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。
pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result<PublishBuildPlan, String> {
let Some(cwd_relative) = resolve_publish_build_cwd(root)? else {
return Err(
"项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试"
.to_string(),
);
};
let command = read_publish_build_command(root, cwd_relative)?;
let needs_dependency_install =
cwd_relative == "game" && !root.join("game").join("node_modules").is_dir();
Ok(PublishBuildPlan {
cwd_relative,
command,
needs_dependency_install,
})
}
/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。
///
/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包;
/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败
/// 返回带日志尾部的可操作错误,不回传本地路径。
pub(crate) async fn export_local_project_package_for_publish_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
if validate_project_game_entry(root).is_ok() {
return export_local_project_package_at(root);
}
let plan = resolve_publish_build_plan(root)?;
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
// 否则作者要点两次(先 bootstrap 再发布)。
if plan.needs_dependency_install {
let bootstrap =
crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?;
if bootstrap.status != "completed" {
return Err(format!(
"安装 game 依赖失败(npm install 未通过):\n{}",
publish_build_failure_tail(&bootstrap.output)
));
}
}
let built = crate::project::verification::run_project_verification_with_commit_at(
root,
"build",
&plan.command,
PUBLISH_BUILD_TIMEOUT_SECONDS,
plan.cwd_relative,
|| Ok(()),
)
.await?;
if built.status != "completed" {
return Err(format!(
"构建可玩版本失败(npm run build 未通过):\n{}",
publish_build_failure_tail(&built.output)
));
}
validate_project_game_entry(root)
.map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?;
export_local_project_package_at(root)
}
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
@@ -3935,6 +3935,165 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
fs::remove_dir_all(root).ok();
}
#[test]
fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-plan-game", "Phaser 工程").expect("project init");
// 脚手架是 game/ + vite buildPhaser 4 工程):缺依赖时必须先 install。
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
assert_eq!(plan.cwd_relative, "game");
assert_eq!(plan.command, "vite build");
assert!(
plan.needs_dependency_install,
"缺少 game/node_modules 时应先装依赖"
);
fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules");
let installed = resolve_publish_build_plan(&root).expect("解析构建计划");
assert!(
!installed.needs_dependency_install,
"已有依赖时不应重复 install"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn publish_build_plan_falls_back_to_root_npm_build() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-plan-root", "根工程构建").expect("project init");
// 去掉 game 子工程的 build,改用项目根 npm 工程构建。
fs::write(
root.join("game/package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "plan-root-game",
"private": true,
"scripts": { "check": "node -e \"process.exit(0)\"" }
}))
.expect("serialize game package json"),
)
.expect("write game package json");
fs::write(
root.join("package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "plan-root-fixture",
"private": true,
"scripts": { "build": "node build-root.mjs" }
}))
.expect("serialize root package json"),
)
.expect("write root package json");
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
assert_eq!(plan.cwd_relative, ".");
assert_eq!(plan.command, "node build-root.mjs");
assert!(!plan.needs_dependency_install);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn publish_export_runs_project_build_before_packaging() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-auto-build", "自动构建发布项目")
.expect("project init");
// 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。
fs::write(
root.join("package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "publish-auto-build-fixture",
"private": true,
"scripts": { "build": "node build-publish.mjs" }
}))
.expect("serialize package json"),
)
.expect("write package json");
fs::write(
root.join("build-publish.mjs"),
r#"import { mkdirSync, writeFileSync } from 'node:fs';
mkdirSync('dist/assets', { recursive: true });
writeFileSync('dist/index.html', '<!doctype html><html><head><meta charset="utf-8"><title>Auto Build</title><script src="assets/app.js"></script></head><body><h1>AUTO-BUILD</h1></body></html>');
writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";');
"#,
)
.expect("write build script");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let result = export_local_project_package_for_publish_at(&root)
.await
.expect("发布前构建并导出");
assert!(root.join("dist/index.html").is_file());
assert!(root.join("dist/assets/app.js").is_file());
assert!(result
.package_relative_path
.starts_with("exports/playtest-package-"));
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
assert!(
log.contains("project.verify build"),
"发布前应记录一次 project.verify build{log}"
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn publish_export_skips_build_when_playable_entry_exists() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-publish-skip", "已构建发布项目")
.expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let result = export_local_project_package_for_publish_at(&root)
.await
.expect("已有可玩入口时直接导出");
assert!(result.package_relative_path.ends_with(".zip"));
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
assert!(
!log.contains("project.verify build"),
"已有可玩入口时不应触发构建:{log}"
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn publish_export_reports_actionable_error_without_entry_or_build_script() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目")
.expect("project init");
// 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。
fs::write(
root.join("game/package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "publish-no-entry-fixture",
"private": true,
"scripts": { "check": "node -e \"process.exit(0)\"" }
}))
.expect("serialize package json"),
)
.expect("write package json");
let error = export_local_project_package_for_publish_at(&root)
.await
.expect_err("缺少入口且没有 build 脚本时必须失败关闭");
assert!(
error.contains("还没有可玩入口"),
"错误应说明缺少可玩入口:{error}"
);
assert!(
error.contains("build 脚本"),
"错误应指向 build 脚本:{error}"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();