Add plugin metadata generation (#485)

This commit is contained in:
Gregory Schier
2026-06-29 12:31:49 -07:00
committed by GitHub
parent 18b983bfe5
commit 09adcda2d9
8 changed files with 401 additions and 3 deletions
+4
View File
@@ -481,6 +481,10 @@ pub enum PluginCommands {
/// Install a plugin from a local directory or from the registry /// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs), Install(InstallPluginArgs),
/// Generate plugin metadata for the registry
#[command(hide = true)]
Metadata(PluginPathArg),
/// Publish a Yaak plugin version to the plugin registry /// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg), Publish(PluginPathArg),
} }
+184 -2
View File
@@ -13,6 +13,7 @@ use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
type CommandResult<T = ()> = std::result::Result<T, String>; type CommandResult<T = ()> = std::result::Result<T, String>;
const KEYRING_USER: &str = "yaak"; 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment { 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 { async fn build(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?; let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?; ensure_plugin_build_inputs(&plugin_dir)?;
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
for warning in warnings { for warning in warnings {
ui::warning(&warning); ui::warning(&warning);
} }
generate_plugin_metadata(&plugin_dir)?;
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display())); ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
Ok(()) 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 { async fn dev(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?; let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?; ensure_plugin_build_inputs(&plugin_dir)?;
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
}); });
ui::info(&format!("Rebuilding plugin {display_path}")); 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)) => { WatcherEvent::Event(BundleEvent::Error(event)) => {
if event.error.diagnostics.is_empty() { if event.error.diagnostics.is_empty() {
ui::error("Plugin build failed"); ui::error("Plugin build failed");
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
for warning in warnings { for warning in warnings {
ui::warning(&warning); ui::warning(&warning);
} }
generate_plugin_metadata(&plugin_dir)?;
ui::info("Archiving plugin"); ui::info("Archiving plugin");
let archive = create_publish_archive(&plugin_dir)?; let archive = create_publish_archive(&plugin_dir)?;
@@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect()) 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::<u32>().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::<u32>().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 { fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
let build_dir = plugin_dir.join("build"); let build_dir = plugin_dir.join("build");
if build_dir.exists() { 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#"{ const TEMPLATE_TSCONFIG: &str = r#"{
"compilerOptions": { "compilerOptions": {
"target": "es2021", "target": "es2021",
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
#[cfg(test)] #[cfg(test)]
mod tests { 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::collections::HashSet;
use std::fs; use std::fs;
use std::io::Cursor; use std::io::Cursor;
@@ -659,6 +774,7 @@ mod tests {
.expect("write src/index.ts"); .expect("write src/index.ts");
fs::write(root.join("build/index.js"), "exports.plugin = {};\n") fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
.expect("write build/index.js"); .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"); fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
let archive = create_publish_archive(root).expect("create archive"); let archive = create_publish_archive(root).expect("create archive");
@@ -673,8 +789,74 @@ mod tests {
assert!(names.contains("README.md")); assert!(names.contains("README.md"));
assert!(names.contains("package.json")); assert!(names.contains("package.json"));
assert!(names.contains("package-lock.json")); assert!(names.contains("package-lock.json"));
assert!(names.contains("build/metadata.json"));
assert!(names.contains("src/index.ts")); assert!(names.contains("src/index.ts"));
assert!(names.contains("build/index.js")); assert!(names.contains("build/index.js"));
assert!(!names.contains("ignored/secret.txt")); 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()
);
}
} }
+1
View File
@@ -59,6 +59,7 @@ async fn main() {
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await, PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await, PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await, PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
PluginCommands::Install(install_args) => { PluginCommands::Install(install_args) => {
let mut context = CliContext::new(data_dir.clone(), app_id); let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await; context.init_plugins(CliExecutionContext::default()).await;
+18
View File
@@ -16970,6 +16970,7 @@
"ws": "^8.20.1" "ws": "^8.20.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13" "@types/ws": "^8.5.13"
} }
}, },
@@ -16991,6 +16992,23 @@
"undici-types": "~7.16.0" "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": { "packages/tailwind-config": {
"name": "@yaakapp-internal/tailwind-config", "name": "@yaakapp-internal/tailwind-config",
"version": "1.0.0", "version": "1.0.0",
+1
View File
@@ -0,0 +1 @@
24.11.1
+1
View File
@@ -9,6 +9,7 @@
"ws": "^8.20.1" "ws": "^8.20.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13" "@types/ws": "^8.5.13"
} }
} }
+190
View File
@@ -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<keyof PluginDefinition, string>,
"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<Record<PluginAPIKey, APITypeMetadata>>;
};
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<PluginDefinition, "init" | "dispose">] ===
"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<object>(),
): 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<string, MetadataItem> = {};
for (const [key, item] of Object.entries(objectValue)) {
const sanitized = sanitize(item, seen);
if (sanitized !== undefined) {
output[key] = sanitized;
}
}
seen.delete(objectValue);
return output;
}
+2 -1
View File
@@ -6,7 +6,8 @@ const Downloader = require("nodejs-file-downloader");
const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs"); const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
const { execSync } = require("node:child_process"); 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}` // `${process.platform}_${process.arch}`
const MAC_ARM = "darwin_arm64"; const MAC_ARM = "darwin_arm64";