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 new file mode 100644 index 000000000..43c40ccb8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs @@ -0,0 +1,54 @@ +//! 小红书小工具导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这三个值的唯一用途是让用户 +/// 复制粘贴到小红书平台的上传页。 +/// +/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 +/// 被静默忽略后带着半份表单继续走。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportForm { + pub(crate) name: String, + pub(crate) introduction: String, + /// 项目内相对路径。icon 文件与目标无关,多个导出目标可以共用同一个。 + pub(crate) icon_path: String, +} + +/// 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportField { + Name, + Introduction, + IconPath, +} + +/// 面板读到的现场状态。 +/// +/// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 +/// 永远从 npm 包的 `package.json` 现算,不落盘。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportState { + pub(crate) form: XHSMiniToolExportForm, + /// `scripts["build:xhs-minitool"]` 是否存在。 + pub(crate) has_script: bool, + /// 注册表文件的当前内容指纹。保存时原样带回做冲突判据;它不写进文件。 + pub(crate) content_hash: String, +} + +/// 一次成功导出的结果。产物路径是常量(`.export/xhs-minitool.zip`),不进 DTO。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportRunResult { + pub(crate) exit_code: Option, + /// 成功时为空;失败时是被截断的输出尾部(stdout 与 stderr 合并),够定位就行。 + pub(crate) output_tail: String, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs new file mode 100644 index 000000000..1505e8f16 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs @@ -0,0 +1,124 @@ +//! 命令边界的 typed 失败。 +//! +//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。可达集合是 +//! 固定的一张表:读注册表 → 校验表单 → 找 npm 脚本 → 跑脚本 → 查产物 → 存表单。首个错误即 +//! 返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 +//! +//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的 +//! TS 联合类型。 + +use serde::Serialize; +use ts_rs::TS; + +use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportError { + /// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。 + RegistryMalformed(RegistryMalformed), + /// 表单字段不合法。只回**首个**出错字段。 + FormInvalid(FormInvalid), + /// npm 包里没有 `build:xhs-minitool`;这就是「还没适配」的唯一现算判据。 + BuildScriptMissing(BuildScriptMissing), + /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 + SaveConflict(SaveConflict), + /// 脚本跑完没有正常收场(非零退出或超时)。 + CommandFailed(CommandFailed), + /// 脚本正常收场但产物不在。 + ArtifactMissing(ArtifactMissing), + /// 项目根不可用、权限策略拒绝、脚本无法启动或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct RegistryMalformed { + /// 解析器原文。它是引导语,不是给前端做判断的依据。 + pub(crate) cause: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct FormInvalid { + pub(crate) field: XHSMiniToolExportField, + pub(crate) reason: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct BuildScriptMissing { + /// 期望的脚本名(`build:xhs-minitool`)。 + pub(crate) script: String, + /// 宿主找过哪些 `package.json`,让用户知道去哪看。 + pub(crate) searched: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct SaveConflict { + /// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + pub(crate) current: XHSMiniToolExportForm, + /// 冲突时文件的指纹;用户选完之后带着它重试保存。 + pub(crate) content_hash: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct CommandFailed { + pub(crate) exit_code: Option, + pub(crate) output_tail: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ArtifactMissing { + /// 产物相对项目根的路径。 + pub(crate) path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ExportUnavailable { + pub(crate) cause: String, +} + +impl XHSMiniToolExportError { + pub(crate) fn form_invalid(field: XHSMiniToolExportField, reason: impl Into) -> Self { + Self::FormInvalid(FormInvalid { + field, + reason: reason.into(), + }) + } + + pub(crate) fn unavailable(cause: impl Into) -> Self { + Self::ExportUnavailable(ExportUnavailable { + cause: cause.into(), + }) + } +} + +impl From for XHSMiniToolExportError { + fn from(error: crate::export::RegistryError) -> Self { + match error { + crate::export::RegistryError::Malformed { cause, .. } => { + Self::RegistryMalformed(RegistryMalformed { cause }) + } + other => Self::ExportUnavailable(ExportUnavailable { + cause: other.to_string(), + }), + } + } +} 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 new file mode 100644 index 000000000..9418df540 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs @@ -0,0 +1,56 @@ +//! 小红书小工具在 `.export/` 下的固定名字与路径。 +//! +//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是 +//! **契约常量**,只在这里定义一次,改名字要三处一起改。 + +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"; + +/// 注册表文件名(`.export/` 下)。 +pub(crate) const REGISTRY_FILE_NAME: &str = "xhs-minitool.json"; + +/// 交付产物文件名(`.export/` 下),人工上传用。 +pub(crate) const ARTIFACT_FILE_NAME: &str = "xhs-minitool.zip"; + +pub(crate) fn export_dir(root: &Path) -> PathBuf { + root.join(EXPORT_WORK_RELATIVE_DIR) +} + +pub(crate) fn registry_path(root: &Path) -> PathBuf { + export_dir(root).join(REGISTRY_FILE_NAME) +} + +pub(crate) fn artifact_path(root: &Path) -> PathBuf { + export_dir(root).join(ARTIFACT_FILE_NAME) +} + +/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。 +pub(crate) fn artifact_relative_path() -> String { + format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn artifact_relative_path_stays_inside_the_export_directory() { + assert_eq!( + artifact_relative_path(), + format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") + ); + assert_eq!( + registry_path(Path::new("/tmp/project")), + Path::new("/tmp/project") + .join(".export") + .join(REGISTRY_FILE_NAME) + ); + } +} 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 1449482ff..cec4c49b5 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 @@ -1,17 +1,22 @@ -pub mod commands; +//! 小红书小工具导出目标。 +//! +//! 文件都堆在项目根的 `.export/`(见 [`crate::export::EXPORT_WORK_RELATIVE_DIR`]): +//! `xhs-minitool.json` 是注册表,`xhs-minitool.zip` 是交付给人工上传的产物,`xhs-minitool.*.mjs` +//! 是 agent 首次适配时从 `vite-export-xhs-minitool` skill 显式拷进来的脚本(允许它改)。共享 +//! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 -struct XHSMiniToolExportForm { - // max 14 unicode - name: String, - // max 14 unicode - introduction: String, - // png jpg jpeg, max 5M - icon_path: String, -} -const XHS_MINI_TOOL_CODE_ZIP_FILENAME: &str = "xhx-minitool.zip"; +mod dto; +mod error; +mod layout; +mod validation; -const XHS_MINI_TOOL_REGISTRY_FILENAME: &str = "xhx-minitool.json"; -const XHS_MINI_TOOL_EXPORT_VITE_SCRIPT: &str = "build-xhx-minitool"; -struct XHSMiniToolExportRegistry { - form: XHSMiniToolExportForm, -} +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/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs new file mode 100644 index 000000000..578adf63f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/validation.rs @@ -0,0 +1,225 @@ +//! 小红书上传表单的校验:**首个错误即返回**。 +//! +//! 字数上限来自小红书平台的上传表单,所以是宿主硬校验而不是软提示——写错了用户会被平台打回 +//! 去,代价比早一点拦下来大。icon 的扩展名与大小是本地文件事实,同样硬校验;非 png/jpg/jpeg +//! 目前直接拒绝,格式转换工具是后续事项(见下方 TODO)。 + +use std::path::Path; + +use super::dto::{XHSMiniToolExportField, XHSMiniToolExportForm}; +use super::error::XHSMiniToolExportError; + +/// 小红书平台对名称的字数上限(Unicode 字符数,不是字节数)。 +pub(crate) const NAME_MAX_CHARS: usize = 14; +/// 简介与名称同档。 +pub(crate) const INTRODUCTION_MAX_CHARS: usize = 14; +pub(crate) const ICON_MAX_BYTES: u64 = 5 * 1024 * 1024; +const ICON_EXTENSIONS: [&str; 3] = ["png", "jpg", "jpeg"]; + +pub(crate) fn validate_form( + root: &Path, + form: &XHSMiniToolExportForm, +) -> Result<(), XHSMiniToolExportError> { + validate_text( + XHSMiniToolExportField::Name, + "名称", + &form.name, + NAME_MAX_CHARS, + )?; + validate_text( + XHSMiniToolExportField::Introduction, + "简介", + &form.introduction, + INTRODUCTION_MAX_CHARS, + )?; + validate_icon(root, form) +} + +fn validate_text( + field: XHSMiniToolExportField, + label: &str, + value: &str, + max_chars: usize, +) -> Result<(), XHSMiniToolExportError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(XHSMiniToolExportError::form_invalid( + field, + format!("{label}不能为空"), + )); + } + let chars = trimmed.chars().count(); + if chars > max_chars { + return Err(XHSMiniToolExportError::form_invalid( + field, + format!("{label}最多 {max_chars} 个字,当前 {chars} 个字"), + )); + } + Ok(()) +} + +fn validate_icon(root: &Path, form: &XHSMiniToolExportForm) -> Result<(), XHSMiniToolExportError> { + let field = XHSMiniToolExportField::IconPath; + let relative = form.icon_path.trim(); + if relative.is_empty() { + return Err(XHSMiniToolExportError::form_invalid(field, "icon 不能为空")); + } + // 与通用项目读取同一套拒绝口径:越界、符号链接、Windows reparse point 都在这里挡掉。 + let path = crate::project::resolve_local_project_path(root, relative) + .map_err(|cause| XHSMiniToolExportError::form_invalid(field, cause))?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + XHSMiniToolExportError::form_invalid(field, format!("icon 不存在或不可读:{error}")) + })?; + if metadata.file_type().is_symlink() { + return Err(XHSMiniToolExportError::form_invalid( + field, + "icon 不能是符号链接", + )); + } + if !metadata.is_file() { + return Err(XHSMiniToolExportError::form_invalid( + field, + "icon 必须是普通文件", + )); + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !ICON_EXTENSIONS.contains(&extension.as_str()) { + // TODO: 提供格式转换工具,把平台不认的格式(webp、heic 等)转成 png/jpg/jpeg。 + return Err(XHSMiniToolExportError::form_invalid( + field, + format!("icon 只支持 png、jpg、jpeg,当前是 .{extension}"), + )); + } + if metadata.len() > ICON_MAX_BYTES { + return Err(XHSMiniToolExportError::form_invalid( + field, + format!( + "icon 不能超过 5 MiB,当前 {:.1} MiB", + metadata.len() as f64 / (1024.0 * 1024.0) + ), + )); + } + Ok(()) +} + +#[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-validation-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(root.join("assets")).expect("create fixture project"); + root + } + + fn form_with_icon(icon_path: &str) -> XHSMiniToolExportForm { + XHSMiniToolExportForm { + name: "小工具".to_string(), + introduction: "一句话简介".to_string(), + icon_path: icon_path.to_string(), + } + } + + #[test] + fn accepts_a_well_formed_form() { + let root = fixture_project("ok"); + fs::write(root.join("assets/icon.png"), b"png").unwrap(); + + validate_form(&root, &form_with_icon("assets/icon.png")).expect("valid form"); + } + + #[test] + fn counts_unicode_characters_not_bytes() { + let root = fixture_project("unicode"); + fs::write(root.join("assets/icon.png"), b"png").unwrap(); + let mut form = form_with_icon("assets/icon.png"); + form.name = "小".repeat(NAME_MAX_CHARS); + + validate_form(&root, &form).expect("exactly at the limit"); + form.name.push('多'); + let error = validate_form(&root, &form).expect_err("over the limit"); + assert!(matches!( + error, + XHSMiniToolExportError::FormInvalid(payload) + if payload.field == XHSMiniToolExportField::Name + )); + } + + #[test] + fn reports_the_first_invalid_field_only() { + let root = fixture_project("first"); + let form = XHSMiniToolExportForm { + name: String::new(), + introduction: String::new(), + icon_path: String::new(), + }; + + let error = validate_form(&root, &form).expect_err("empty form"); + assert!(matches!( + error, + XHSMiniToolExportError::FormInvalid(payload) + if payload.field == XHSMiniToolExportField::Name + )); + } + + #[test] + fn rejects_paths_escaping_the_project_and_symlinks() { + let root = fixture_project("escape"); + let outside = root.parent().unwrap().join("outside.png"); + fs::write(&outside, b"png").unwrap(); + + let error = validate_form(&root, &form_with_icon("../outside.png")) + .expect_err("escaping path must be rejected"); + assert!(matches!( + error, + XHSMiniToolExportError::FormInvalid(payload) + if payload.field == XHSMiniToolExportField::IconPath + )); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, root.join("assets/link.png")).unwrap(); + let error = validate_form(&root, &form_with_icon("assets/link.png")) + .expect_err("symlink must be rejected"); + assert!(matches!( + error, + XHSMiniToolExportError::FormInvalid(payload) + if payload.field == XHSMiniToolExportField::IconPath + )); + } + } + + #[test] + fn rejects_unsupported_extension_and_oversized_icon() { + let root = fixture_project("icon"); + fs::write(root.join("assets/icon.webp"), b"webp").unwrap(); + let error = validate_form(&root, &form_with_icon("assets/icon.webp")) + .expect_err("webp is not accepted yet"); + assert!(matches!( + error, + XHSMiniToolExportError::FormInvalid(payload) + if payload.field == XHSMiniToolExportField::IconPath + )); + + let big = root.join("assets/big.png"); + fs::write(&big, vec![0_u8; (ICON_MAX_BYTES + 1) as usize]).unwrap(); + let error = validate_form(&root, &form_with_icon("assets/big.png")).expect_err("too large"); + assert!(matches!( + error, + XHSMiniToolExportError::FormInvalid(payload) + if payload.field == XHSMiniToolExportField::IconPath + )); + } +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/ArtifactMissing.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/ArtifactMissing.ts new file mode 100644 index 000000000..29e862933 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/ArtifactMissing.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ArtifactMissing = { + /** + * 产物相对项目根的路径。 + */ + path: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/BuildScriptMissing.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/BuildScriptMissing.ts new file mode 100644 index 000000000..5cc557622 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/BuildScriptMissing.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BuildScriptMissing = { + /** + * 期望的脚本名(`build:xhs-minitool`)。 + */ + script: string; + /** + * 宿主找过哪些 `package.json`,让用户知道去哪看。 + */ + searched: Array; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/CommandFailed.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/CommandFailed.ts new file mode 100644 index 000000000..51afbc39a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/CommandFailed.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandFailed = { exitCode: number | null; outputTail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/ExportUnavailable.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/ExportUnavailable.ts new file mode 100644 index 000000000..0f88dae02 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/ExportUnavailable.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExportUnavailable = { cause: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts new file mode 100644 index 000000000..7deacc2b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/FormInvalid.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { XHSMiniToolExportField } from './XHSMiniToolExportField'; + +export type FormInvalid = { field: XHSMiniToolExportField; reason: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/RegistryMalformed.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/RegistryMalformed.ts new file mode 100644 index 000000000..974989382 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/RegistryMalformed.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RegistryMalformed = { + /** + * 解析器原文。它是引导语,不是给前端做判断的依据。 + */ + cause: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/SaveConflict.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/SaveConflict.ts new file mode 100644 index 000000000..1af09a678 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/SaveConflict.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { XHSMiniToolExportForm } from './XHSMiniToolExportForm'; + +export type SaveConflict = { + /** + * 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + */ + current: XHSMiniToolExportForm; + /** + * 冲突时文件的指纹;用户选完之后带着它重试保存。 + */ + contentHash: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportError.ts new file mode 100644 index 000000000..61ae92689 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportError.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ArtifactMissing } from './ArtifactMissing'; +import type { BuildScriptMissing } from './BuildScriptMissing'; +import type { CommandFailed } from './CommandFailed'; +import type { ExportUnavailable } from './ExportUnavailable'; +import type { FormInvalid } from './FormInvalid'; +import type { RegistryMalformed } from './RegistryMalformed'; +import type { SaveConflict } from './SaveConflict'; + +export type XHSMiniToolExportError = + | ({ type: 'registryMalformed' } & RegistryMalformed) + | ({ type: 'formInvalid' } & FormInvalid) + | ({ type: 'buildScriptMissing' } & BuildScriptMissing) + | ({ type: 'saveConflict' } & SaveConflict) + | ({ type: 'commandFailed' } & CommandFailed) + | ({ type: 'artifactMissing' } & ArtifactMissing) + | ({ type: 'exportUnavailable' } & ExportUnavailable); diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts new file mode 100644 index 000000000..990bf7806 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportField.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 表单里出错的字段。前端按它把原因挂到对应输入框,不解析文案。 + */ +export type XHSMiniToolExportField = 'name' | 'introduction' | 'iconPath'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportForm.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportForm.ts new file mode 100644 index 000000000..bf071ac10 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportForm.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这三个值的唯一用途是让用户 + * 复制粘贴到小红书平台的上传页。 + * + * `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 + * 被静默忽略后带着半份表单继续走。 + */ +export type XHSMiniToolExportForm = { + name: string; + introduction: string; + /** + * 项目内相对路径。icon 文件与目标无关,多个导出目标可以共用同一个。 + */ + iconPath: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportRunResult.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportRunResult.ts new file mode 100644 index 000000000..fbb6f1dfa --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportRunResult.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一次成功导出的结果。产物路径是常量(`.export/xhs-minitool.zip`),不进 DTO。 + */ +export type XHSMiniToolExportRunResult = { + exitCode: number | null; + /** + * 成功时为空;失败时是被截断的输出尾部(stdout 与 stderr 合并),够定位就行。 + */ + outputTail: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportState.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportState.ts new file mode 100644 index 000000000..e06977bd1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/XHSMiniToolExportState.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { XHSMiniToolExportForm } from './XHSMiniToolExportForm'; + +/** + * 面板读到的现场状态。 + * + * 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 + * 永远从 npm 包的 `package.json` 现算,不落盘。 + */ +export type XHSMiniToolExportState = { + form: XHSMiniToolExportForm; + /** + * `scripts["build:xhs-minitool"]` 是否存在。 + */ + hasScript: boolean; + /** + * 注册表文件的当前内容指纹。保存时原样带回做冲突判据;它不写进文件。 + */ + contentHash: string; +}; diff --git a/scripts/check-generated-bindings.mjs b/scripts/check-generated-bindings.mjs index b30a70a2f..6f89ce888 100644 --- a/scripts/check-generated-bindings.mjs +++ b/scripts/check-generated-bindings.mjs @@ -30,6 +30,8 @@ const AGC_CHAT_DIR = const AGC_SERVICES_DIR = 'apps/ai-game-creator-shell/src/services/generated'; const AGC_UI_EDITOR_DIR = 'apps/ai-game-creator-shell/src/features/ui-editor/types'; +const AGC_EXPORT_DIR = + 'apps/ai-game-creator-shell/src/view/project-development/export/generated'; /** 一套绑定的生成入口、覆盖目录与"怎么重新生成"的提示。 */ const targets = [ @@ -51,7 +53,7 @@ const targets = [ }, { label: 'ai-game-creator-shell', - dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR], + dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR, AGC_EXPORT_DIR], cargoArgs: [ 'test', '--locked', @@ -70,7 +72,7 @@ function snapshot(dir) { // "0 个文件"静默通过——`export_to` 路径被改名或生成文件被整体删除时,校验形同虚设。 throw new Error(`生成绑定目录不存在,无法校验:${dir}`); } - // 四个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录); + // 五个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录); // 不用 `recursive`,避免依赖 Node 18.17 / 20.1 才有的选项(旧版本会静默退化成只读顶层)。 const entries = fs.readdirSync(absolute); return new Map(