合并 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
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:
@@ -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 build(Phaser 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();
|
||||
|
||||
Reference in New Issue
Block a user