From 09adcda2d97ddfd227202c486f2b00200784f472 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 29 Jun 2026 12:31:49 -0700 Subject: [PATCH] Add plugin metadata generation (#485) --- crates-cli/yaak-cli/src/cli.rs | 4 + crates-cli/yaak-cli/src/commands/plugin.rs | 186 +++++++++++++++++++- crates-cli/yaak-cli/src/main.rs | 1 + package-lock.json | 18 ++ packages/plugin-runtime/.node-version | 1 + packages/plugin-runtime/package.json | 1 + packages/plugin-runtime/src/metadata.ts | 190 +++++++++++++++++++++ scripts/vendor-node.cjs | 3 +- 8 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-runtime/.node-version create mode 100644 packages/plugin-runtime/src/metadata.ts diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index c226160e..a1417760 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -481,6 +481,10 @@ pub enum PluginCommands { /// Install a plugin from a local directory or from the registry Install(InstallPluginArgs), + /// Generate plugin metadata for the registry + #[command(hide = true)] + Metadata(PluginPathArg), + /// Publish a Yaak plugin version to the plugin registry Publish(PluginPathArg), } diff --git a/crates-cli/yaak-cli/src/commands/plugin.rs b/crates-cli/yaak-cli/src/commands/plugin.rs index d12f6f22..2eb1038c 100644 --- a/crates-cli/yaak-cli/src/commands/plugin.rs +++ b/crates-cli/yaak-cli/src/commands/plugin.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use std::fs; use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::Arc; use tokio::sync::Mutex; use walkdir::WalkDir; @@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions; type CommandResult = std::result::Result; const KEYRING_USER: &str = "yaak"; +const METADATA_NODE_BIN: &str = "node"; +const PLUGIN_RUNTIME_NODE_VERSION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packages/plugin-runtime/.node-version" +)); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Environment { @@ -103,6 +109,16 @@ pub async fn run_publish(args: PluginPathArg) -> i32 { } } +pub async fn run_metadata(args: PluginPathArg) -> i32 { + match metadata(args) { + Ok(()) => 0, + Err(error) => { + ui::error(&error); + 1 + } + } +} + async fn build(args: PluginPathArg) -> CommandResult { let plugin_dir = resolve_plugin_dir(args.path)?; ensure_plugin_build_inputs(&plugin_dir)?; @@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult { for warning in warnings { ui::warning(&warning); } + generate_plugin_metadata(&plugin_dir)?; ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display())); Ok(()) } +fn metadata(args: PluginPathArg) -> CommandResult { + let plugin_dir = resolve_plugin_dir(args.path)?; + generate_plugin_metadata(&plugin_dir)?; + ui::success(&format!( + "Generated plugin metadata at {}", + plugin_dir.join("build/metadata.json").display() + )); + Ok(()) +} + async fn dev(args: PluginPathArg) -> CommandResult { let plugin_dir = resolve_plugin_dir(args.path)?; ensure_plugin_build_inputs(&plugin_dir)?; @@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult { }); ui::info(&format!("Rebuilding plugin {display_path}")); } - WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {} + WatcherEvent::Event(BundleEvent::BundleEnd(_)) => { + match generate_plugin_metadata(&watch_root) { + Ok(()) => ui::success(&format!( + "Generated plugin metadata at {}", + watch_root.join("build/metadata.json").display() + )), + Err(error) => ui::error(&error), + } + } WatcherEvent::Event(BundleEvent::Error(event)) => { if event.error.diagnostics.is_empty() { ui::error("Plugin build failed"); @@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult { for warning in warnings { ui::warning(&warning); } + generate_plugin_metadata(&plugin_dir)?; ui::info("Archiving plugin"); let archive = create_publish_archive(&plugin_dir)?; @@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult> { Ok(output.warnings.into_iter().map(|w| w.to_string()).collect()) } +fn generate_plugin_metadata(plugin_dir: &Path) -> CommandResult { + let entry_path = plugin_dir.join("build/index.js"); + if !entry_path.is_file() { + return Err("build/index.js does not exist. Run `yaak plugin build` first.".to_string()); + } + + ensure_metadata_node_version()?; + + let metadata_path = plugin_dir.join("build/metadata.json"); + let output = Command::new(METADATA_NODE_BIN) + .arg("-e") + .arg(METADATA_SCRIPT) + .arg(entry_path.canonicalize().map_err(|e| { + format!("Failed to resolve plugin entrypoint {}: {e}", entry_path.display()) + })?) + .arg(&metadata_path) + .current_dir(plugin_dir) + .output() + .map_err(|e| format!("Failed to run Node.js to generate plugin metadata: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("Node.js exited with status {}", output.status) + } else { + stderr + }; + return Err(format!("Failed to generate plugin metadata: {message}")); + } + + Ok(()) +} + +fn ensure_metadata_node_version() -> CommandResult { + let minimum_major = PLUGIN_RUNTIME_NODE_VERSION + .trim() + .trim_start_matches('v') + .split('.') + .next() + .and_then(|part| part.parse::().ok()) + .ok_or_else(|| { + format!( + "Invalid plugin runtime Node.js version {:?} in packages/plugin-runtime/.node-version", + PLUGIN_RUNTIME_NODE_VERSION.trim() + ) + })?; + let output = Command::new(METADATA_NODE_BIN) + .arg("--version") + .output() + .map_err(|e| format!("Node.js {minimum_major} or newer is required: {e}"))?; + + if !output.status.success() { + return Err(format!( + "`{METADATA_NODE_BIN} --version` failed with status {}", + output.status + )); + } + + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let major = version + .trim_start_matches('v') + .split('.') + .next() + .and_then(|part| part.parse::().ok()) + .ok_or_else(|| format!("Could not parse Node.js version {version:?}"))?; + + if major >= minimum_major { + return Ok(()); + } + + Err(format!("Node.js {minimum_major} or newer is required. Found {version}.")) +} + fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult { let build_dir = plugin_dir.join("build"); if build_dir.exists() { @@ -578,6 +687,11 @@ const TEMPLATE_PACKAGE_JSON: &str = r#"{ } "#; +const METADATA_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packages/plugin-runtime/src/metadata.ts" +)); + const TEMPLATE_TSCONFIG: &str = r#"{ "compilerOptions": { "target": "es2021", @@ -636,7 +750,8 @@ describe("Example Plugin", () => { #[cfg(test)] mod tests { - use super::create_publish_archive; + use super::{create_publish_archive, generate_plugin_metadata}; + use serde_json::Value; use std::collections::HashSet; use std::fs; use std::io::Cursor; @@ -659,6 +774,7 @@ mod tests { .expect("write src/index.ts"); fs::write(root.join("build/index.js"), "exports.plugin = {};\n") .expect("write build/index.js"); + fs::write(root.join("build/metadata.json"), "{}\n").expect("write build/metadata.json"); fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file"); let archive = create_publish_archive(root).expect("create archive"); @@ -673,8 +789,74 @@ mod tests { assert!(names.contains("README.md")); assert!(names.contains("package.json")); assert!(names.contains("package-lock.json")); + assert!(names.contains("build/metadata.json")); assert!(names.contains("src/index.ts")); assert!(names.contains("build/index.js")); assert!(!names.contains("ignored/secret.txt")); } + + #[test] + fn generate_plugin_metadata_detects_api_types() { + 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("build/index.js"), + r##" +exports.plugin = { + themes: [{ + id: "midnight", + label: "Midnight", + dark: true, + base: { surface: "#000000", text: "#ffffff" }, + }], + templateFunctions: [{ + name: "signature", + description: "Create a signature", + args: [{ type: "text", name: "secret", dynamic() {} }], + onRender() {}, + }], + workspaceActions: [{ + label: "Sync workspace", + icon: "info", + onSelect() {}, + }], + folderActions: [{ + label: "Export folder", + icon: "copy", + onSelect() {}, + }], + async init() {}, +}; +"##, + ) + .expect("write build/index.js"); + + generate_plugin_metadata(root).expect("generate metadata"); + + let contents = fs::read_to_string(root.join("build/metadata.json")).expect("read metadata"); + let metadata: Value = serde_json::from_str(&contents).expect("metadata json"); + let api_types = metadata["apiTypes"].as_array().expect("apiTypes array"); + + for expected in [ + "folderActions", + "templateFunctions", + "themes", + "workspaceActions", + "lifecycle", + ] { + assert!( + api_types.iter().any(|value| value.as_str() == Some(expected)), + "missing api type {expected}: {api_types:?}" + ); + } + + assert_eq!(metadata["apis"]["themes"]["items"][0]["id"], "midnight"); + assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace"); + assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init"); + assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null()); + assert!( + metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null() + ); + } } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index eda769d5..d2630e11 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -59,6 +59,7 @@ async fn main() { PluginCommands::Dev(args) => commands::plugin::run_dev(args).await, PluginCommands::Generate(args) => commands::plugin::run_generate(args).await, PluginCommands::Publish(args) => commands::plugin::run_publish(args).await, + PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await, PluginCommands::Install(install_args) => { let mut context = CliContext::new(data_dir.clone(), app_id); context.init_plugins(CliExecutionContext::default()).await; diff --git a/package-lock.json b/package-lock.json index 5ae9c1b7..af21ba5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16970,6 +16970,7 @@ "ws": "^8.20.1" }, "devDependencies": { + "@types/node": "^24.0.13", "@types/ws": "^8.5.13" } }, @@ -16991,6 +16992,23 @@ "undici-types": "~7.16.0" } }, + "packages/plugin-runtime/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "packages/plugin-runtime/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "packages/tailwind-config": { "name": "@yaakapp-internal/tailwind-config", "version": "1.0.0", diff --git a/packages/plugin-runtime/.node-version b/packages/plugin-runtime/.node-version new file mode 100644 index 00000000..9e2934aa --- /dev/null +++ b/packages/plugin-runtime/.node-version @@ -0,0 +1 @@ +24.11.1 diff --git a/packages/plugin-runtime/package.json b/packages/plugin-runtime/package.json index 6176f9a1..13480c36 100644 --- a/packages/plugin-runtime/package.json +++ b/packages/plugin-runtime/package.json @@ -9,6 +9,7 @@ "ws": "^8.20.1" }, "devDependencies": { + "@types/node": "^24.0.13", "@types/ws": "^8.5.13" } } diff --git a/packages/plugin-runtime/src/metadata.ts b/packages/plugin-runtime/src/metadata.ts new file mode 100644 index 00000000..eef2ea3f --- /dev/null +++ b/packages/plugin-runtime/src/metadata.ts @@ -0,0 +1,190 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import type { PluginDefinition } from "@yaakapp/api"; + +type PluginFeatureKey = Exclude< + Extract, + "init" | "dispose" +>; +type PluginAPIKey = PluginFeatureKey | "lifecycle"; + +type MetadataDefinition = { + key: PluginFeatureKey; + label: string; + array: boolean; +}; + +type MetadataItem = + | string + | number + | boolean + | null + | MetadataItem[] + | { [key: string]: MetadataItem }; + +type APITypeMetadata = { + label: string; + source: string; + count: number; + items: MetadataItem[]; +}; + +type PluginMetadata = { + schemaVersion: 1; + apiTypes: PluginAPIKey[]; + apis: Partial>; +}; + +const definitions: MetadataDefinition[] = [ + { + key: "authentication", + label: "Authentication", + array: false, + }, + { key: "filter", label: "Filter", array: false }, + { + key: "folderActions", + label: "Folder Action", + array: true, + }, + { + key: "grpcRequestActions", + label: "gRPC Request Action", + array: true, + }, + { + key: "httpRequestActions", + label: "HTTP Request Action", + array: true, + }, + { key: "importer", label: "Importer", array: false }, + { + key: "templateFunctions", + label: "Template Tag", + array: true, + }, + { key: "themes", label: "Theme", array: true }, + { + key: "websocketRequestActions", + label: "WebSocket Request Action", + array: true, + }, + { + key: "workspaceActions", + label: "Workspace Action", + array: true, + }, +]; + +export function generatePluginMetadata( + plugin: PluginDefinition, +): PluginMetadata { + const metadata: PluginMetadata = { + schemaVersion: 1, + apiTypes: [], + apis: {}, + }; + + for (const definition of definitions) { + const value = plugin[definition.key]; + const items = definition.array ? value : value ? [value] : []; + + if (!Array.isArray(items) || items.length === 0) { + continue; + } + + metadata.apiTypes.push(definition.key); + metadata.apis[definition.key] = { + label: definition.label, + source: definition.key, + count: items.length, + items: sanitize(items) as MetadataItem[], + }; + } + + const lifecycleHooks = ["init", "dispose"].filter( + (key) => + typeof plugin[key as keyof Pick] === + "function", + ); + if (lifecycleHooks.length > 0) { + metadata.apiTypes.push("lifecycle"); + metadata.apis.lifecycle = { + label: "Lifecycle Hook", + source: lifecycleHooks.join(","), + count: lifecycleHooks.length, + items: lifecycleHooks.map((name) => ({ name })), + }; + } + + return metadata; +} + +const entryPath = process.argv[1]; +const outputPath = process.argv[2]; + +if (!entryPath) { + throw new Error("Missing plugin entrypoint path"); +} +if (!outputPath) { + throw new Error("Missing plugin metadata output path"); +} + +const require = createRequire(path.join(process.cwd(), "plugin-metadata.js")); +const moduleExports = require(path.resolve(entryPath)) as PluginDefinition & { + plugin?: PluginDefinition; + default?: PluginDefinition; +}; +const plugin = moduleExports.plugin ?? moduleExports.default ?? moduleExports; + +if (!plugin || typeof plugin !== "object") { + throw new Error("Plugin entrypoint must export a plugin object"); +} + +const metadata = generatePluginMetadata(plugin); +fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`); + +function sanitize( + value: unknown, + seen = new WeakSet(), +): MetadataItem | undefined { + if (value === null) return null; + + switch (typeof value) { + case "boolean": + case "number": + case "string": + return value; + case "bigint": + return value.toString(); + case "function": + case "symbol": + case "undefined": + return undefined; + } + + const objectValue = value as object; + if (seen.has(objectValue)) { + return "[Circular]"; + } + + seen.add(objectValue); + + if (Array.isArray(value)) { + const output = value.map((item) => sanitize(item, seen) ?? null); + seen.delete(objectValue); + return output; + } + + const output: Record = {}; + for (const [key, item] of Object.entries(objectValue)) { + const sanitized = sanitize(item, seen); + if (sanitized !== undefined) { + output[key] = sanitized; + } + } + + seen.delete(objectValue); + return output; +} diff --git a/scripts/vendor-node.cjs b/scripts/vendor-node.cjs index 8b11f055..1f8a7bc6 100644 --- a/scripts/vendor-node.cjs +++ b/scripts/vendor-node.cjs @@ -6,7 +6,8 @@ const Downloader = require("nodejs-file-downloader"); const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs"); const { execSync } = require("node:child_process"); -const NODE_VERSION = "v24.11.1"; +const nodeVersionFile = path.join(__dirname, "..", "packages", "plugin-runtime", ".node-version"); +const NODE_VERSION = `v${fs.readFileSync(nodeVersionFile, "utf8").trim().replace(/^v/, "")}`; // `${process.platform}_${process.arch}` const MAC_ARM = "darwin_arm64";