From 3a1999e115ca87892e49d75894892212f69cfe8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 15:41:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=BA=A2=E4=B9=A6=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=9D=A2=EF=BC=9A=E8=AF=BB=E3=80=81=E5=AD=98?= =?UTF-8?q?=EF=BC=88=E5=B8=A6=E5=86=B2=E7=AA=81=EF=BC=89=E3=80=81=E8=B7=91?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=B9=B6=E7=A1=AE=E8=AE=A4=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src-tauri/src/export/draft/xhs_minitool/build.rs:以既有 command.exec 边界运行项目声明的 build:xhs-minitool(复用 run_project_verification_with_commit_at,脚本白名单、expectedCommand 反漂移、沙箱与 300 秒超时都在那里),跑完确认 .export/xhs-minitool.zip 是非空普通文件,失败输出只回尾部 4000 字符。 - 新增 commands.rs:read_xhs_minitool_export / save_xhs_minitool_export_form / run_xhs_minitool_export_build 三个 per-target 命令;hasScript 每次现算,project_path 先过 validate_project_root,不拿项目写锁、不推进 revision。 - src-tauri/src/desktop.rs 把三个命令注册进 generate_handler!。 - xhs_minitool/mod.rs 收敛为模块声明,不再对外重导出内部类型。 - 实施计划把 XHSMiniToolExportRunResult 的字段名从 stderrTail 修正为 outputTail:输出是 stdout 与 stderr 合并清洗后的尾部。 - Rust 定向测试新增脚本缺失前置失败与输出截断两例。 --- .../src-tauri/src/desktop.rs | 6 + .../src/export/draft/xhs_minitool/build.rs | 117 +++++++++++++ .../src/export/draft/xhs_minitool/commands.rs | 61 +++++++ .../src/export/draft/xhs_minitool/dto.rs | 24 +++ .../src/export/draft/xhs_minitool/layout.rs | 3 - .../src/export/draft/xhs_minitool/mod.rs | 20 +-- .../src/export/draft/xhs_minitool/registry.rs | 156 +++++++++++++++++ .../src/export/draft/xhs_minitool/script.rs | 157 ++++++++++++++++++ .../src-tauri/src/export/registry.rs | 11 +- ...’】导出产物面板与小红书小工具导出-2026-10-05.md | 2 +- 10 files changed, 539 insertions(+), 18 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 8b5c3078d..5cdf5df04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -7,6 +7,9 @@ use asset_generation_tasks::*; use client_extensions::*; use environment_check::preflight_web_game_creation; use error_report::*; +use export::draft::xhs_minitool::commands::{ + read_xhs_minitool_export, run_xhs_minitool_export_build, save_xhs_minitool_export_form, +}; use game_distribution_publish::*; use plugin_host::{ call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, @@ -685,6 +688,9 @@ pub(super) fn run() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, + read_xhs_minitool_export, + save_xhs_minitool_export_form, + run_xhs_minitool_export_build, prepare_local_project_game_package, upload_local_project_game_package, list_local_project_export_packages, diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs new file mode 100644 index 000000000..7109c024a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs @@ -0,0 +1,117 @@ +//! 跑用户项目自己的导出脚本。 +//! +//! 宿主不碰 vite、不碰 `pack.mjs`、不解析 zip:它只是以既有 `command.exec` 的边界(脚本白名单、 +//! 反漂移 `expectedCommand`、沙箱、超时、输出清洗)运行项目声明的那条脚本,然后确认产物真的在。 +//! 中间目录叫什么、脚本内部怎么调 vite,都是 agent 与用户的事。 +//! +//! 与表单无关:表单不是构建输入,所以这里不校验表单,也不看 icon。 + +use std::path::Path; + +use crate::project::run_project_verification_with_commit_at; + +use super::dto::XHSMiniToolExportRunResult; +use super::error::{ArtifactMissing, CommandFailed, XHSMiniToolExportError}; +use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME}; +use super::script; + +/// 权限点位复用既有命令口径:这就是在跑项目命令,不新增 `GAME_CREATION_APP_COMMANDS` 条目。 +const PERMISSION_COMMAND_ID: &str = "command.exec"; +/// 与前端一致:构建可能真的要跑一遍 vite,给到 `project.verify` 的同一档上限(300 秒)。 +const TIMEOUT_SECONDS: u64 = 300; +/// 回给前端的输出尾部上限(字符)。失败时用户要看的是最后几行报错,不是整份构建日志。 +const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; + +pub(crate) async fn run(root: &Path) -> Result { + let Some(script) = script::find(root) else { + return Err(script::missing_error()); + }; + crate::project::enforce_project_permission_policy(root, PERMISSION_COMMAND_ID) + .map_err(XHSMiniToolExportError::unavailable)?; + + let result = run_project_verification_with_commit_at( + root, + SCRIPT_NAME, + &script.command, + TIMEOUT_SECONDS, + script.cwd_relative, + // 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。 + || Ok(()), + ) + .await + .map_err(XHSMiniToolExportError::unavailable)?; + + if result.timed_out || result.exit_code != Some(0) { + return Err(XHSMiniToolExportError::CommandFailed(CommandFailed { + exit_code: result.exit_code, + output_tail: tail(&result.output), + })); + } + + let path = artifact_path(root); + let produced = matches!( + std::fs::metadata(&path), + Ok(metadata) if metadata.is_file() && metadata.len() > 0 + ); + if !produced { + // 脚本"成功"却没产出:多半是 agent 把 zip 写到了别处,或 pack.mjs 的参数没对齐。 + return Err(XHSMiniToolExportError::ArtifactMissing(ArtifactMissing { + path: artifact_relative_path(), + })); + } + + Ok(XHSMiniToolExportRunResult { + exit_code: result.exit_code, + output_tail: String::new(), + }) +} + +/// 只留尾部若干字符,并注明省略了多少。整份构建日志塞进 IPC 没有意义,还会把界面拖住。 +fn tail(output: &str) -> String { + let trimmed = output.trim(); + let characters: Vec = trimmed.chars().collect(); + if characters.len() <= OUTPUT_TAIL_MAX_CHARS { + return trimmed.to_string(); + } + let skipped = characters.len() - OUTPUT_TAIL_MAX_CHARS; + let tail: String = characters[skipped..].iter().collect(); + format!("…(省略前 {skipped} 个字符)\n{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_short_output_as_is() { + assert_eq!(tail(" 构建失败\n"), "构建失败"); + } + + #[test] + fn keeps_only_the_tail_of_long_output() { + let output: String = "前".repeat(OUTPUT_TAIL_MAX_CHARS) + "后"; + let kept = tail(&output); + assert!(kept.ends_with('后')); + assert!(kept.contains("省略前 1 个字符")); + } + + #[test] + fn reports_a_missing_script_before_touching_the_project() { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-build-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + + let error = tauri::async_runtime::block_on(run(&root)) + .expect_err("no package.json means no script"); + assert!(matches!( + error, + XHSMiniToolExportError::BuildScriptMissing(_) + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs index 8b1378917..e8c9ccc64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs @@ -1 +1,62 @@ +//! 导出面板用到的三个 `#[tauri::command]`。 +//! +//! 每个命令只做一件事,全部走 typed 失败([`XHSMiniToolExportError`])。没有项目写锁、不推进 +//! revision:宿主在这里读写的,就是 `.export/` 下那一个注册表文件。 +//! +//! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。 +use std::path::{Path, PathBuf}; + +use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRunResult, XHSMiniToolExportState}; +use super::error::XHSMiniToolExportError; +use super::registry::{self, RegistrySnapshot}; +use super::validation::validate_form; +use super::{build, script}; + +/// 读现场:注册表(缺则补一份空表单)+ 脚本是否存在。 +#[tauri::command] +pub(crate) async fn read_xhs_minitool_export( + project_path: String, +) -> Result { + let root = project_root(&project_path)?; + Ok(state(&root, registry::load(&root)?)) +} + +/// 保存表单:先硬校验(首个错误即返回),再按 `base_hash` 判冲突。 +/// +/// 校验挡在保存之前是刻意的——平台字数与 icon 限制是硬约束,让它在用户输入时就被看见,比等到 +/// 人工上传被平台打回便宜。 +#[tauri::command] +pub(crate) async fn save_xhs_minitool_export_form( + project_path: String, + form: XHSMiniToolExportForm, + base_hash: String, +) -> Result { + let root = project_root(&project_path)?; + validate_form(&root, &form)?; + Ok(state(&root, registry::save(&root, &form, &base_hash)?)) +} + +/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。 +#[tauri::command] +pub(crate) async fn run_xhs_minitool_export_build( + project_path: String, +) -> Result { + let root = project_root(&project_path)?; + build::run(&root).await +} + +/// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。 +fn state(root: &Path, snapshot: RegistrySnapshot) -> XHSMiniToolExportState { + XHSMiniToolExportState { + form: snapshot.form, + has_script: script::find(root).is_some(), + content_hash: snapshot.content_hash, + } +} + +fn project_root(project_path: &str) -> Result { + let root = PathBuf::from(project_path); + crate::project::validate_project_root(&root).map_err(XHSMiniToolExportError::unavailable)?; + Ok(root) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs index 43c40ccb8..4d49d1dfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs @@ -18,6 +18,30 @@ pub(crate) struct XHSMiniToolExportForm { pub(crate) icon_path: String, } +impl XHSMiniToolExportForm { + /// 落盘形态:三个字段都去掉首尾空白。 + /// + /// 校验按去空白后的文本算(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白,否则 + /// 「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 + pub(crate) fn normalized(&self) -> Self { + Self { + name: self.name.trim().to_string(), + introduction: self.introduction.trim().to_string(), + icon_path: self.icon_path.trim().to_string(), + } + } +} + +/// 注册表文件形状:`.export/xhs-minitool.json`。 +/// +/// 只有 `form` 一个键——没有 status、没有已适配标记、没有 revision,那些要么是 `package.json` +/// 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct XHSMiniToolExportRegistry { + pub(crate) form: XHSMiniToolExportForm, +} + /// 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs index 9418df540..66a6748fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs @@ -7,9 +7,6 @@ use std::path::{Path, PathBuf}; use crate::export::EXPORT_WORK_RELATIVE_DIR; -/// 目标标识,用于文件名前缀与 prompt 上下文。 -pub(crate) const TARGET: &str = "xhs-minitool"; - /// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。 pub(crate) const SCRIPT_NAME: &str = "build:xhs-minitool"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs index cec4c49b5..6d43c4951 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs @@ -4,19 +4,17 @@ //! `xhs-minitool.json` 是注册表,`xhs-minitool.zip` 是交付给人工上传的产物,`xhs-minitool.*.mjs` //! 是 agent 首次适配时从 `vite-export-xhs-minitool` skill 显式拷进来的脚本(允许它改)。共享 //! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 +//! +//! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, +//! `script` 管 npm 脚本发现,`validation` 管表单硬约束,`build` 跑脚本并确认产物, +//! `commands` 是唯一对外的命令面。 +pub mod commands; + +mod build; mod dto; mod error; mod layout; +mod registry; +mod script; mod validation; - -pub(crate) use dto::{ - XHSMiniToolExportField, XHSMiniToolExportForm, XHSMiniToolExportRunResult, - XHSMiniToolExportState, -}; -pub(crate) use error::XHSMiniToolExportError; -pub(crate) use layout::{ - artifact_path, artifact_relative_path, export_dir, registry_path, ARTIFACT_FILE_NAME, - SCRIPT_NAME, TARGET, -}; -pub(crate) use validation::validate_form; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs new file mode 100644 index 000000000..3eb535d1e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs @@ -0,0 +1,156 @@ +//! 小红书注册表的读取与保存。 +//! +//! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 +//! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 + +use std::path::Path; + +use crate::export::{load_or_create, save as write_registry}; + +use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRegistry}; +use super::error::{SaveConflict, XHSMiniToolExportError}; +use super::layout::registry_path; + +/// 注册表现场:表单,加上**这份内容**的指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegistrySnapshot { + pub(crate) form: XHSMiniToolExportForm, + pub(crate) content_hash: String, +} + +/// 读注册表;文件不存在时补一份全空表单。解析失败原样上抛成 `registryMalformed`。 +pub(crate) fn load(root: &Path) -> Result { + let loaded = load_or_create(®istry_path(root), &XHSMiniToolExportRegistry::default())?; + Ok(RegistrySnapshot { + form: loaded.value.form, + content_hash: loaded.content_hash, + }) +} + +/// 保存表单。 +/// +/// `base_hash` 是调用方读到的指纹。文件没被换过就直接写;被换过但字段完全一致(只剩格式差异) +/// 就静默吸收、不写盘;字段真的不同则回 `saveConflict`,把文件里的值交给用户逐字段挑——**绝不** +/// 覆盖用户或 agent 的输入。 +pub(crate) fn save( + root: &Path, + form: &XHSMiniToolExportForm, + base_hash: &str, +) -> Result { + let path = registry_path(root); + let current = load_or_create(&path, &XHSMiniToolExportRegistry::default())?; + let form = form.normalized(); + if !current.created && current.content_hash != base_hash { + let current_form = current.value.form.normalized(); + if current_form != form { + return Err(XHSMiniToolExportError::SaveConflict(SaveConflict { + current: current_form, + content_hash: current.content_hash, + })); + } + return Ok(RegistrySnapshot { + form: current_form, + content_hash: current.content_hash, + }); + } + let content_hash = write_registry(&path, &XHSMiniToolExportRegistry { form: form.clone() })?; + Ok(RegistrySnapshot { form, content_hash }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + fn form(name: &str) -> XHSMiniToolExportForm { + XHSMiniToolExportForm { + name: name.to_string(), + introduction: "简介".to_string(), + icon_path: "assets/icon.png".to_string(), + } + } + + #[test] + fn creates_an_empty_registry_on_first_read() { + let root = fixture_project("create"); + + let snapshot = load(&root).expect("load"); + assert_eq!(snapshot.form, XHSMiniToolExportForm::default()); + assert!( + root.join(".export/xhs-minitool.json").is_file(), + "读取必须顺手补出空注册表" + ); + } + + #[test] + fn saves_and_normalizes_whitespace() { + let root = fixture_project("save"); + let base = load(&root).expect("load"); + + let mut value = form(" 小工具 "); + value.introduction = " 一句话简介 ".to_string(); + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!(saved.form.name, "小工具"); + assert_eq!(saved.form.introduction, "一句话简介"); + + let reread = load(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + assert_eq!(reread.content_hash, saved.content_hash); + } + + #[test] + fn absorbs_formatting_only_changes_without_rewriting() { + let root = fixture_project("format"); + let base = load(&root).expect("load"); + // agent 重排了文件:字段没变,指纹变了。 + let compact = "{\"form\":{\"name\":\"\",\"introduction\":\"\",\"iconPath\":\"\"}}"; + fs::write(root.join(".export/xhs-minitool.json"), compact).unwrap(); + + let saved = save(&root, &XHSMiniToolExportForm::default(), &base.content_hash) + .expect("formatting-only change is absorbed"); + // 吸收的是「字段相同」这个现实:不覆写别人的排版,但指纹要跟着文件走。 + assert_eq!( + fs::read_to_string(root.join(".export/xhs-minitool.json")).unwrap(), + compact + ); + assert_eq!(saved.content_hash, crate::export::content_hash(compact)); + assert_ne!(saved.content_hash, base.content_hash); + } + + #[test] + fn refuses_to_overwrite_a_real_change() { + let root = fixture_project("conflict"); + let base = load(&root).expect("load"); + let theirs = form("agent 写的"); + fs::write( + root.join(".export/xhs-minitool.json"), + serde_json::to_vec_pretty(&XHSMiniToolExportRegistry { + form: theirs.clone(), + }) + .unwrap(), + ) + .unwrap(); + + let error = save(&root, &form("用户写的"), &base.content_hash) + .expect_err("real change must not be overwritten"); + match error { + XHSMiniToolExportError::SaveConflict(payload) => { + assert_eq!(payload.current, theirs); + } + other => panic!("expected saveConflict, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs new file mode 100644 index 000000000..37cc4d20a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs @@ -0,0 +1,157 @@ +//! 找声明 `build:xhs-minitool` 的 npm 包。 +//! +//! 判据只有一个:`package.json` 的 `scripts` 里有没有这个名字。**不落盘、不缓存**——「已适配」 +//! 就是此刻这个问题的答案,用户手删脚本或换机器之后必须立刻反映出来。候选目录与发布流程 +//! (`resolve_publish_build_cwd`)同口径:项目根,或模板工程常用的 `game/` 子工程。 + +use std::path::Path; + +use super::error::{BuildScriptMissing, XHSMiniToolExportError}; +use super::layout::SCRIPT_NAME; + +/// npm 工作目录候选,按顺序取第一个声明了脚本的。 +pub(crate) const NPM_WORK_CANDIDATES: [&str; 2] = [".", "game"]; + +/// 命中的 npm 脚本。 +pub(crate) struct NpmScript { + /// 运行脚本时的工作目录(相对项目根)。 + pub(crate) cwd_relative: &'static str, + /// `package.json` 里声明的脚本文本。交给运行器做反漂移的 `expectedCommand`。 + pub(crate) command: String, + /// 相对项目根的 `package.json` 路径,报错与提示都用它。 + pub(crate) package_json_relative: String, +} + +/// 找到脚本就返回它,否则 `None`。读不了或不是合法 JSON 的 `package.json` 直接跳过,与发布 +/// 流程保持一致:这里不是校验工程结构的地方,找不到就是「还没适配」。 +pub(crate) fn find(root: &Path) -> Option { + for cwd in NPM_WORK_CANDIDATES { + let package_root = if cwd == "." { + root.to_path_buf() + } else { + match crate::project::resolve_local_project_path(root, cwd) { + Ok(path) => path, + Err(_) => continue, + } + }; + let package_path = package_root.join("package.json"); + let Ok(metadata) = std::fs::symlink_metadata(&package_path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&package_path) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + let Some(command) = package + .get("scripts") + .and_then(|scripts| scripts.get(SCRIPT_NAME)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + return Some(NpmScript { + cwd_relative: cwd, + command: command.to_string(), + package_json_relative: package_json_relative(cwd), + }); + } + None +} + +/// 找不到脚本时的 typed 失败:把找过的位置一起给出去,用户才知道去哪看。 +pub(crate) fn missing_error() -> XHSMiniToolExportError { + XHSMiniToolExportError::BuildScriptMissing(BuildScriptMissing { + script: SCRIPT_NAME.to_string(), + searched: NPM_WORK_CANDIDATES + .iter() + .map(|cwd| package_json_relative(cwd)) + .collect(), + }) +} + +fn package_json_relative(cwd: &str) -> String { + if cwd == "." { + "package.json".to_string() + } else { + format!("{cwd}/package.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-script-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + #[test] + fn finds_the_script_in_the_root_package() { + let root = fixture_project("root"); + fs::write( + root.join("package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"node pack.mjs\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "."); + assert_eq!(script.command, "node pack.mjs"); + assert_eq!(script.package_json_relative, "package.json"); + } + + #[test] + fn finds_the_script_in_the_game_subproject() { + let root = fixture_project("game"); + fs::create_dir_all(root.join("game")).unwrap(); + fs::write( + root.join("game/package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"vite build\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "game"); + assert_eq!(script.package_json_relative, "game/package.json"); + } + + #[test] + fn ignores_other_scripts_and_broken_packages() { + let root = fixture_project("missing"); + fs::write( + root.join("package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .unwrap(); + assert!(find(&root).is_none()); + + fs::write(root.join("package.json"), "{ not json").unwrap(); + assert!(find(&root).is_none()); + + let error = missing_error(); + match error { + XHSMiniToolExportError::BuildScriptMissing(payload) => { + assert_eq!(payload.script, SCRIPT_NAME); + assert_eq!(payload.searched, vec!["package.json", "game/package.json"]); + } + other => panic!("expected buildScriptMissing, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs index ea13958ee..7033e8c9c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -19,6 +19,8 @@ pub(crate) struct Loaded { pub(crate) value: T, /// 原始文件内容的指纹;保存时用它判断「读完之后有没有别人写过」。 pub(crate) content_hash: String, + /// 这次读取是不是刚补出来的空文件:刚补出来的文件没有可冲突的对象。 + pub(crate) created: bool, } /// 注册表文件级失败。目标模块负责把它映射成自己的 typed error。 @@ -62,12 +64,12 @@ pub(crate) fn load_or_create(path: &Path, empty: &T) -> Result, Reg where T: Serialize + DeserializeOwned, { - let text = match fs::read_to_string(path) { - Ok(text) => text, + let (text, created) = match fs::read_to_string(path) { + Ok(text) => (text, false), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let created = encode(empty)?; write_atomically(path, &created)?; - created + (created, true) } Err(error) => { return Err(RegistryError::Io { @@ -80,6 +82,7 @@ where Ok(Loaded { value, content_hash: content_hash(&text), + created, }) } @@ -181,6 +184,7 @@ mod tests { }; let first = load_or_create(&path, &empty).expect("create registry"); + assert!(first.created); assert_eq!(first.value, empty); assert_eq!( fs::read_to_string(&path).unwrap(), @@ -188,6 +192,7 @@ mod tests { ); let second = load_or_create(&path, &empty).expect("reread registry"); + assert!(!second.created); assert_eq!(second.content_hash, first.content_hash); } diff --git a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md index 5308bf120..2aa084927 100644 --- a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md +++ b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md @@ -10,7 +10,7 @@ - Rust 模块:`src-tauri/src/export/mod.rs`(`EXPORT_WORK_RELATIVE_DIR` 与共用注册表读写、规范序列化 hash 助手)、`src-tauri/src/export/draft/mod.rs`、`src-tauri/src/export/draft/xhs_minitool/mod.rs`(表单 / 状态 / 结果 / 错误 DTO 与常量)、`src-tauri/src/export/draft/xhs_minitool/commands.rs`(小红书专属 `#[tauri::command]`)。 - 现存草稿的归位:`export/command/`(空 `mod.rs`)是上一轮「共享命令目录」方案的残留,最终落点是 per-target `commands.rs`,实现时删除该目录与 `mod.rs` 里的 `pub mod command;`。同一轮把草稿常量改名落定:`xhx-minitool.zip` → `xhs-minitool.zip`、`xhx-minitool.json` → `xhs-minitool.json`、`build-xhx-minitool` → `build:xhs-minitool`;表单字段随 `#[serde(rename_all = "camelCase")]` 在 JSON / TS 侧为 `iconPath`(Rust 侧仍是 `icon_path`)。 -- Rust 命令(3 个,全部 per-target):`read_xhs_minitool_export` → `XHSMiniToolExportState { form, hasScript, contentHash }`;`save_xhs_minitool_export_form(project_path, form, base_hash)`;`run_xhs_minitool_export_build(project_path)` → `XHSMiniToolExportRunResult { exitCode, stderrTail }`。失败走 typed error `XHSMiniToolExportError`(ts-rs 导出);不新增 `GAME_CREATION_APP_COMMANDS` 条目,复用 `command.exec` 的 `enforce_project_permission_policy`。 +- Rust 命令(3 个,全部 per-target):`read_xhs_minitool_export` → `XHSMiniToolExportState { form, hasScript, contentHash }`;`save_xhs_minitool_export_form(project_path, form, base_hash)`;`run_xhs_minitool_export_build(project_path)` → `XHSMiniToolExportRunResult { exitCode, outputTail }`(输出清洗后的尾部;stdout 与 stderr 已合并,所以不叫 stderrTail)。失败走 typed error `XHSMiniToolExportError`(ts-rs 导出);不新增 `GAME_CREATION_APP_COMMANDS` 条目,复用 `command.exec` 的 `enforce_project_permission_policy`。 - 复用而非新造:运行脚本走 `command_exec` 的 `resolve_project_command_spec_at → prepare/stage/spawn`(或 `run_project_verification_with_commit_at`),cwd 沿用 `project/export.rs` 的 `resolve_publish_build_cwd` 口径;icon 与 zip 下载复用 `save_local_project_asset_file`;不拿项目写锁、不推进 revision。 - 前端:`src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx`(表单 + 产物 + 冲突面板)、新增 `state/useXhsMinitoolExport.ts`、新增 `tabs/xiaohongshu/adaptationPrompt.ts`(纯函数 + 契约常量)、新增 `src/view/project-development/export/generated/`(ts-rs 产物)。 - skill:`resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs`(新增 zip 目的地参数,默认 `.export/xhs-minitool.zip`)、`scripts/vite.config.xhs-minitool.mjs` 与 `SKILL.md` 的示例默认值对齐 `.export/` 布局;同步 `manifest.json` 指纹与 `skill_pack.rs` 的 `include_bytes!` 内容(文件数不变,仍 36 条)。