小红书导出命令面:读、存(带冲突)、跑脚本并确认产物
- 新增 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 定向测试新增脚本缺失前置失败与输出截断两例。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<XHSMiniToolExportRunResult, XHSMiniToolExportError> {
|
||||
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<char> = 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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<XHSMiniToolExportState, XHSMiniToolExportError> {
|
||||
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<XHSMiniToolExportState, XHSMiniToolExportError> {
|
||||
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<XHSMiniToolExportRunResult, XHSMiniToolExportError> {
|
||||
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<PathBuf, XHSMiniToolExportError> {
|
||||
let root = PathBuf::from(project_path);
|
||||
crate::project::validate_project_root(&root).map_err(XHSMiniToolExportError::unavailable)?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RegistrySnapshot, XHSMiniToolExportError> {
|
||||
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<RegistrySnapshot, XHSMiniToolExportError> {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NpmScript> {
|
||||
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::<serde_json::Value>(&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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ pub(crate) struct Loaded<T> {
|
||||
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<T>(path: &Path, empty: &T) -> Result<Loaded<T>, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 条)。
|
||||
|
||||
Reference in New Issue
Block a user