小红书小工具的表单 DTO、typed 失败、路径常量与首错校验
- 新增 src-tauri/src/export/draft/xhs_minitool/dto.rs:XHSMiniToolExportForm / Field / State / RunResult 的线上形状;表单 deny_unknown_fields,TS 由 ts-rs 生成。 - 新增 error.rs:命令边界 typed 失败七变体(registryMalformed、formInvalid、buildScriptMissing、saveConflict、commandFailed、artifactMissing、exportUnavailable),带载荷变体持具名结构体,保证前端拿到可判别联合类型。 - 新增 layout.rs:.export/ 下 xhs-minitool.json 与 xhs-minitool.zip 的契约常量、npm 脚本名 build:xhs-minitool 与路径推导。 - 新增 validation.rs:名称与简介按 Unicode 字符数不超过 14 且非空,icon 必须是项目内普通文件、非符号链接、png/jpg/jpeg 且不超过 5 MiB,首个错误即返回。 - 改写 xhs_minitool/mod.rs 为模块声明与重导出,删除上一轮 xhx-* 草案常量与 xhx-minitool 拼写。 - 生成 src/view/project-development/export/generated/ 下 12 份 ts-rs 绑定。 - scripts/check-generated-bindings.mjs 把 export/generated 纳入 AGC 目标的覆盖目录。
This commit is contained in:
@@ -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<i32>,
|
||||
/// 成功时为空;失败时是被截断的输出尾部(stdout 与 stderr 合并),够定位就行。
|
||||
pub(crate) output_tail: String,
|
||||
}
|
||||
@@ -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<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 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<i32>,
|
||||
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<String>) -> Self {
|
||||
Self::FormInvalid(FormInvalid {
|
||||
field,
|
||||
reason: reason.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable(cause: impl Into<String>) -> Self {
|
||||
Self::ExportUnavailable(ExportUnavailable {
|
||||
cause: cause.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::export::RegistryError> 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
+8
@@ -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;
|
||||
};
|
||||
+12
@@ -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<string>;
|
||||
};
|
||||
+3
@@ -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 };
|
||||
+3
@@ -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 };
|
||||
+4
@@ -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 };
|
||||
+8
@@ -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;
|
||||
};
|
||||
+13
@@ -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;
|
||||
};
|
||||
+17
@@ -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);
|
||||
+6
@@ -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';
|
||||
+17
@@ -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;
|
||||
};
|
||||
+12
@@ -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;
|
||||
};
|
||||
+20
@@ -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;
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user