diff --git a/crates-cli/yaak-cli/src/commands/plugin.rs b/crates-cli/yaak-cli/src/commands/plugin.rs index c565a5e3..951042c5 100644 --- a/crates-cli/yaak-cli/src/commands/plugin.rs +++ b/crates-cli/yaak-cli/src/commands/plugin.rs @@ -181,7 +181,11 @@ async fn dev(args: PluginPathArg) -> CommandResult { ui::info(&format!("Rebuilding plugin {display_path}")); } WatcherEvent::Event(BundleEvent::BundleEnd(_)) => { - match generate_plugin_metadata(&watch_root) { + // Assets are staged on every rebuild, so a changed asset or + // declaration is picked up without restarting. + let result = copy_build_assets(&watch_root) + .and_then(|()| generate_plugin_metadata(&watch_root)); + match result { Ok(()) => ui::success(&format!( "Generated plugin metadata at {}", watch_root.join("build/metadata.json").display() @@ -408,6 +412,7 @@ struct PublishResponse { async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult> { prepare_build_output_dir(plugin_dir)?; + copy_build_assets(plugin_dir)?; let mut bundler = Bundler::new(bundler_options(plugin_dir, false)) .map_err(|err| format!("Failed to initialize Rolldown: {err}"))?; let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?; @@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult { .map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display())) } +#[derive(Deserialize, Default)] +struct PluginManifest { + #[serde(default)] + yaak: PluginManifestConfig, +} + +#[derive(Deserialize, Default)] +struct PluginManifestConfig { + /// Files to place beside the bundle, as paths relative to the plugin + /// directory. Publishing ships everything in `build/`, so these travel with + /// the plugin. + #[serde(default, rename = "buildAssets")] + build_assets: Vec, +} + +/// Copy the plugin's declared assets into `build/`. +/// +/// This runs after the directory is cleared and before the bundle is written, +/// because a bundle may read an asset from its own directory at import time and +/// metadata generation imports the bundle. +fn copy_build_assets(plugin_dir: &Path) -> CommandResult { + let manifest_path = plugin_dir.join("package.json"); + let manifest: PluginManifest = serde_json::from_str( + &fs::read_to_string(&manifest_path) + .map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?, + ) + .map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?; + + let build_dir = plugin_dir.join("build"); + let mut names = HashSet::new(); + for asset in manifest.yaak.build_assets { + let src = plugin_dir.join(&asset); + let name = src + .file_name() + .ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?; + // A copy that later gets overwritten would pass the build and fail on + // load, so anything the build itself writes, or a second asset with + // the same name, is rejected up front. Names are compared without + // case, because a plugin is installed on case-insensitive filesystems + // wherever it was built. + let key = name.to_string_lossy().to_lowercase(); + if key == "index.js" || key == "metadata.json" { + return Err(format!("Build asset {asset} would be overwritten by the build output")); + } + if !names.insert(key) { + return Err(format!("Two build assets share the name {}", name.display())); + } + if !src.is_file() { + return Err(format!("Build asset does not exist: {}", src.display())); + } + fs::copy(&src, build_dir.join(name)) + .map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?; + } + + Ok(()) +} + fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions { BundlerOptions { input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]), @@ -750,7 +812,10 @@ describe("Example Plugin", () => { #[cfg(test)] mod tests { - use super::{create_publish_archive, generate_plugin_metadata}; + use super::{ + copy_build_assets, create_publish_archive, generate_plugin_metadata, + prepare_build_output_dir, + }; use serde_json::Value; use std::collections::HashSet; use std::fs; @@ -795,6 +860,100 @@ mod tests { assert!(!names.contains("ignored/secret.txt")); } + #[test] + fn prepare_build_output_dir_clears_stale_output() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + let build = root.join("build"); + fs::create_dir_all(&build).expect("create build"); + fs::write(build.join("index.js"), "stale").expect("write index.js"); + fs::write(build.join("left-behind.js"), "stale").expect("write extra"); + + prepare_build_output_dir(root).expect("prepare build dir"); + + // Publishing ships everything under build/, so nothing may survive. + assert!(build.is_dir()); + assert_eq!(fs::read_dir(&build).expect("read build").count(), 0); + } + + #[test] + fn copy_build_assets_places_declared_files_beside_the_bundle() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + fs::create_dir_all(root.join("build")).expect("create build"); + fs::create_dir_all(root.join("vendor")).expect("create vendor"); + fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset"); + fs::write( + root.join("package.json"), + r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#, + ) + .expect("write package.json"); + + copy_build_assets(root).expect("copy assets"); + + assert_eq!( + fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"), + "asset" + ); + } + + #[test] + fn copy_build_assets_is_a_noop_without_declarations() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + fs::create_dir_all(root.join("build")).expect("create build"); + fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json"); + + copy_build_assets(root).expect("copy assets"); + + assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0); + } + + #[test] + fn copy_build_assets_rejects_names_the_build_writes() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + fs::create_dir_all(root.join("build")).expect("create build"); + fs::write(root.join("index.js"), "asset").expect("write asset"); + fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#) + .expect("write package.json"); + + let err = copy_build_assets(root).expect_err("reserved name should fail"); + assert!(err.contains("overwritten by the build output"), "unexpected error: {err}"); + } + + #[test] + fn copy_build_assets_rejects_duplicate_names() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + fs::create_dir_all(root.join("build")).expect("create build"); + fs::create_dir_all(root.join("a")).expect("create a"); + fs::create_dir_all(root.join("b")).expect("create b"); + // Differ only by case: one file on macOS and Windows. + fs::write(root.join("a/core.wasm"), "one").expect("write a"); + fs::write(root.join("b/Core.wasm"), "two").expect("write b"); + fs::write( + root.join("package.json"), + r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#, + ) + .expect("write package.json"); + + let err = copy_build_assets(root).expect_err("duplicate name should fail"); + assert!(err.contains("share the name"), "unexpected error: {err}"); + } + + #[test] + fn copy_build_assets_fails_on_a_missing_asset() { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + fs::create_dir_all(root.join("build")).expect("create build"); + fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#) + .expect("write package.json"); + + let err = copy_build_assets(root).expect_err("missing asset should fail"); + assert!(err.contains("Build asset does not exist"), "unexpected error: {err}"); + } + #[test] fn generate_plugin_metadata_detects_api_types() { let dir = TempDir::new().expect("temp dir"); diff --git a/package-lock.json b/package-lock.json index 9c4b7ccc..7cf130af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,7 +86,7 @@ "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@types/babel__core": "^7.20.5", "@vitejs/plugin-react": "^6.0.1", - "@yaakapp/cli": "latest", + "@yaakapp/cli": "0.5.1", "babel-plugin-react-compiler": "^1.0.0", "dotenv-cli": "^11.0.0", "nodejs-file-downloader": "^4.13.0", diff --git a/package.json b/package.json index 2717a224..4035c0fe 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@types/babel__core": "^7.20.5", "@vitejs/plugin-react": "^6.0.1", - "@yaakapp/cli": "latest", + "@yaakapp/cli": "0.5.1", "babel-plugin-react-compiler": "^1.0.0", "dotenv-cli": "^11.0.0", "nodejs-file-downloader": "^4.13.0",