From 30f006401a98509c195cabb88352ccdd05b4d049 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 3 Mar 2026 16:41:53 -0800 Subject: [PATCH] CLI plugin host: handle send/render HTTP requests (#415) Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 125 +- crates-cli/yaak-cli/Cargo.toml | 2 + crates-cli/yaak-cli/src/cli.rs | 44 +- .../yaak-cli/src/commands/cookie_jar.rs | 42 + .../yaak-cli/src/commands/environment.rs | 21 +- crates-cli/yaak-cli/src/commands/folder.rs | 20 +- crates-cli/yaak-cli/src/commands/mod.rs | 1 + crates-cli/yaak-cli/src/commands/plugin.rs | 128 +- crates-cli/yaak-cli/src/commands/request.rs | 54 +- crates-cli/yaak-cli/src/commands/send.rs | 76 +- crates-cli/yaak-cli/src/context.rs | 111 +- crates-cli/yaak-cli/src/main.rs | 244 +++- crates-cli/yaak-cli/src/plugin_events.rs | 1063 +++++++++++++++-- crates-cli/yaak-cli/src/utils/mod.rs | 1 + crates-cli/yaak-cli/src/utils/workspace.rs | 19 + crates/yaak-plugins/bindings/gen_events.ts | 160 +-- crates/yaak-plugins/bindings/gen_models.ts | 36 +- .../template-function-response/src/index.ts | 3 +- 18 files changed, 1800 insertions(+), 350 deletions(-) create mode 100644 crates-cli/yaak-cli/src/commands/cookie_jar.rs create mode 100644 crates-cli/yaak-cli/src/utils/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index 929908b2..5a06b414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1200,7 +1200,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1405,6 +1405,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.3" @@ -2294,6 +2319,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local 1.1.9", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -3164,6 +3198,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.11.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "tempfile", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "interfaces" version = "0.0.8" @@ -3756,6 +3808,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log 0.4.29", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -3851,6 +3915,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -3942,7 +4015,7 @@ dependencies = [ "kqueue", "libc", "log 0.4.29", - "mio", + "mio 1.0.4", "notify-types", "walkdir", "windows-sys 0.59.0", @@ -4501,7 +4574,7 @@ dependencies = [ "textwrap", "thiserror 2.0.17", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -6171,7 +6244,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", "yansi", ] @@ -6196,7 +6269,7 @@ dependencies = [ "kqueue", "libc", "log 0.4.29", - "mio", + "mio 1.0.4", "rolldown-notify-types", "walkdir", "windows-sys 0.61.2", @@ -7173,6 +7246,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -8068,7 +8162,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -8215,7 +8309,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.0.4", "pin-project-lite", "signal-hook-registry", "socket2 0.6.1", @@ -8785,6 +8879,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -9562,6 +9662,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -10141,6 +10250,7 @@ dependencies = [ name = "yaak-cli" version = "0.1.0" dependencies = [ + "arboard", "assert_cmd", "base64 0.22.1", "clap", @@ -10150,6 +10260,7 @@ dependencies = [ "futures", "hex", "include_dir", + "inquire", "keyring", "log 0.4.29", "oxc_resolver", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index a21f280c..4c62578e 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -9,12 +9,14 @@ name = "yaak" path = "src/main.rs" [dependencies] +arboard = "3" base64 = "0.22" clap = { version = "4", features = ["derive"] } console = "0.15" dirs = "6" env_logger = "0.11" futures = "0.3" +inquire = { version = "0.7", features = ["editor"] } hex = { workspace = true } include_dir = "0.7" keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] } diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index ec9a9b3c..bdd77252 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -21,6 +21,10 @@ pub struct Cli { #[arg(long, short, global = true)] pub environment: Option, + /// Cookie jar ID to use when sending requests + #[arg(long = "cookie-jar", global = true, value_name = "COOKIE_JAR_ID")] + pub cookie_jar: Option, + /// Enable verbose send output (events and streamed response body) #[arg(long, short, global = true)] pub verbose: bool, @@ -58,6 +62,9 @@ pub enum Commands { /// Send a request, folder, or workspace by ID Send(SendArgs), + /// Cookie jar commands + CookieJar(CookieJarArgs), + /// Workspace commands Workspace(WorkspaceArgs), @@ -85,6 +92,22 @@ pub struct SendArgs { pub fail_fast: bool, } +#[derive(Args)] +#[command(disable_help_subcommand = true)] +pub struct CookieJarArgs { + #[command(subcommand)] + pub command: CookieJarCommands, +} + +#[derive(Subcommand)] +pub enum CookieJarCommands { + /// List cookie jars in a workspace + List { + /// Workspace ID (optional when exactly one workspace exists) + workspace_id: Option, + }, +} + #[derive(Args)] #[command(disable_help_subcommand = true)] pub struct WorkspaceArgs { @@ -158,8 +181,8 @@ pub struct RequestArgs { pub enum RequestCommands { /// List requests in a workspace List { - /// Workspace ID - workspace_id: String, + /// Workspace ID (optional when exactly one workspace exists) + workspace_id: Option, }, /// Show a request as JSON @@ -267,8 +290,8 @@ pub struct FolderArgs { pub enum FolderCommands { /// List folders in a workspace List { - /// Workspace ID - workspace_id: String, + /// Workspace ID (optional when exactly one workspace exists) + workspace_id: Option, }, /// Show a folder as JSON @@ -324,8 +347,8 @@ pub struct EnvironmentArgs { pub enum EnvironmentCommands { /// List environments in a workspace List { - /// Workspace ID - workspace_id: String, + /// Workspace ID (optional when exactly one workspace exists) + workspace_id: Option, }, /// Output JSON schema for environment create/update payloads @@ -421,6 +444,9 @@ pub enum PluginCommands { /// Generate a "Hello World" Yaak plugin Generate(GenerateArgs), + /// Install a plugin from a local directory or from the registry + Install(InstallPluginArgs), + /// Publish a Yaak plugin version to the plugin registry Publish(PluginPathArg), } @@ -441,3 +467,9 @@ pub struct GenerateArgs { #[arg(long)] pub dir: Option, } + +#[derive(Args, Clone)] +pub struct InstallPluginArgs { + /// Local plugin directory path, or registry plugin spec (@org/plugin[@version]) + pub source: String, +} diff --git a/crates-cli/yaak-cli/src/commands/cookie_jar.rs b/crates-cli/yaak-cli/src/commands/cookie_jar.rs new file mode 100644 index 00000000..3494cde2 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/cookie_jar.rs @@ -0,0 +1,42 @@ +use crate::cli::{CookieJarArgs, CookieJarCommands}; +use crate::context::CliContext; +use crate::utils::workspace::resolve_workspace_id; + +type CommandResult = std::result::Result; + +pub fn run(ctx: &CliContext, args: CookieJarArgs) -> i32 { + let result = match args.command { + CookieJarCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()), + }; + + match result { + Ok(()) => 0, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } +} + +fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { + let workspace_id = resolve_workspace_id(ctx, workspace_id, "cookie-jar list")?; + let cookie_jars = ctx + .db() + .list_cookie_jars(&workspace_id) + .map_err(|e| format!("Failed to list cookie jars: {e}"))?; + + if cookie_jars.is_empty() { + println!("No cookie jars found in workspace {}", workspace_id); + } else { + for cookie_jar in cookie_jars { + println!( + "{} - {} ({} cookies)", + cookie_jar.id, + cookie_jar.name, + cookie_jar.cookies.len() + ); + } + } + + Ok(()) +} diff --git a/crates-cli/yaak-cli/src/commands/environment.rs b/crates-cli/yaak-cli/src/commands/environment.rs index 02bb31e9..2c060a92 100644 --- a/crates-cli/yaak-cli/src/commands/environment.rs +++ b/crates-cli/yaak-cli/src/commands/environment.rs @@ -6,6 +6,7 @@ use crate::utils::json::{ parse_required_json, require_id, validate_create_id, }; use crate::utils::schema::append_agent_hints; +use crate::utils::workspace::resolve_workspace_id; use schemars::schema_for; use yaak_models::models::Environment; use yaak_models::util::UpdateSource; @@ -14,7 +15,7 @@ type CommandResult = std::result::Result; pub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 { let result = match args.command { - EnvironmentCommands::List { workspace_id } => list(ctx, &workspace_id), + EnvironmentCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()), EnvironmentCommands::Schema { pretty } => schema(pretty), EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id), EnvironmentCommands::Create { workspace_id, name, json } => { @@ -45,10 +46,11 @@ fn schema(pretty: bool) -> CommandResult { Ok(()) } -fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult { +fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { + let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?; let environments = ctx .db() - .list_environments_ensure_base(workspace_id) + .list_environments_ensure_base(&workspace_id) .map_err(|e| format!("Failed to list environments: {e}"))?; if environments.is_empty() { @@ -92,8 +94,14 @@ fn create( validate_create_id(&payload, "environment")?; let mut environment: Environment = serde_json::from_value(payload) .map_err(|e| format!("Failed to parse environment create JSON: {e}"))?; + let fallback_workspace_id = + if workspace_id_arg.is_none() && environment.workspace_id.is_empty() { + Some(resolve_workspace_id(ctx, None, "environment create")?) + } else { + None + }; merge_workspace_id_arg( - workspace_id_arg.as_deref(), + workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()), &mut environment.workspace_id, "environment create", )?; @@ -111,9 +119,8 @@ fn create( return Ok(()); } - let workspace_id = workspace_id_arg.ok_or_else(|| { - "environment create requires workspace_id unless JSON payload is provided".to_string() - })?; + let workspace_id = + resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "environment create")?; let name = name.ok_or_else(|| { "environment create requires --name unless JSON payload is provided".to_string() })?; diff --git a/crates-cli/yaak-cli/src/commands/folder.rs b/crates-cli/yaak-cli/src/commands/folder.rs index a5f99092..926c76b8 100644 --- a/crates-cli/yaak-cli/src/commands/folder.rs +++ b/crates-cli/yaak-cli/src/commands/folder.rs @@ -5,6 +5,7 @@ use crate::utils::json::{ apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json, parse_required_json, require_id, validate_create_id, }; +use crate::utils::workspace::resolve_workspace_id; use yaak_models::models::Folder; use yaak_models::util::UpdateSource; @@ -12,7 +13,7 @@ type CommandResult = std::result::Result; pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 { let result = match args.command { - FolderCommands::List { workspace_id } => list(ctx, &workspace_id), + FolderCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()), FolderCommands::Show { folder_id } => show(ctx, &folder_id), FolderCommands::Create { workspace_id, name, json } => { create(ctx, workspace_id, name, json) @@ -30,9 +31,10 @@ pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 { } } -fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult { +fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { + let workspace_id = resolve_workspace_id(ctx, workspace_id, "folder list")?; let folders = - ctx.db().list_folders(workspace_id).map_err(|e| format!("Failed to list folders: {e}"))?; + ctx.db().list_folders(&workspace_id).map_err(|e| format!("Failed to list folders: {e}"))?; if folders.is_empty() { println!("No folders found in workspace {}", workspace_id); } else { @@ -72,8 +74,14 @@ fn create( validate_create_id(&payload, "folder")?; let mut folder: Folder = serde_json::from_value(payload) .map_err(|e| format!("Failed to parse folder create JSON: {e}"))?; + let fallback_workspace_id = if workspace_id_arg.is_none() && folder.workspace_id.is_empty() + { + Some(resolve_workspace_id(ctx, None, "folder create")?) + } else { + None + }; merge_workspace_id_arg( - workspace_id_arg.as_deref(), + workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()), &mut folder.workspace_id, "folder create", )?; @@ -87,9 +95,7 @@ fn create( return Ok(()); } - let workspace_id = workspace_id_arg.ok_or_else(|| { - "folder create requires workspace_id unless JSON payload is provided".to_string() - })?; + let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "folder create")?; let name = name.ok_or_else(|| { "folder create requires --name unless JSON payload is provided".to_string() })?; diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index b2a0bf23..dc52933a 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod cookie_jar; pub mod environment; pub mod folder; pub mod plugin; diff --git a/crates-cli/yaak-cli/src/commands/plugin.rs b/crates-cli/yaak-cli/src/commands/plugin.rs index 45403c79..d12f6f22 100644 --- a/crates-cli/yaak-cli/src/commands/plugin.rs +++ b/crates-cli/yaak-cli/src/commands/plugin.rs @@ -1,4 +1,5 @@ -use crate::cli::{GenerateArgs, PluginArgs, PluginCommands, PluginPathArg}; +use crate::cli::{GenerateArgs, InstallPluginArgs, PluginPathArg}; +use crate::context::CliContext; use crate::ui; use crate::utils::http; use keyring::Entry; @@ -15,6 +16,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::Mutex; use walkdir::WalkDir; +use yaak_api::{ApiClientKind, yaak_api_client}; +use yaak_models::models::{Plugin, PluginSource}; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::PluginContext; +use yaak_plugins::install::download_and_install; use zip::CompressionMethod; use zip::write::SimpleFileOptions; @@ -57,12 +63,13 @@ pub async fn run_build(args: PluginPathArg) -> i32 { } } -pub async fn run(args: PluginArgs) -> i32 { - match args.command { - PluginCommands::Build(args) => run_build(args).await, - PluginCommands::Dev(args) => run_dev(args).await, - PluginCommands::Generate(args) => run_generate(args).await, - PluginCommands::Publish(args) => run_publish(args).await, +pub async fn run_install(context: &CliContext, args: InstallPluginArgs) -> i32 { + match install(context, args).await { + Ok(()) => 0, + Err(error) => { + ui::error(&error); + 1 + } } } @@ -250,6 +257,113 @@ async fn publish(args: PluginPathArg) -> CommandResult { Ok(()) } +async fn install(context: &CliContext, args: InstallPluginArgs) -> CommandResult { + if args.source.starts_with('@') { + let (name, version) = + parse_registry_install_spec(args.source.as_str()).ok_or_else(|| { + "Invalid registry plugin spec. Expected format: @org/plugin or @org/plugin@version" + .to_string() + })?; + return install_from_registry(context, name, version).await; + } + + install_from_directory(context, args.source.as_str()).await +} + +async fn install_from_registry( + context: &CliContext, + name: String, + version: Option, +) -> CommandResult { + let current_version = crate::version::cli_version(); + let http_client = yaak_api_client(ApiClientKind::Cli, current_version) + .map_err(|err| format!("Failed to initialize API client: {err}"))?; + let installing_version = version.clone().unwrap_or_else(|| "latest".to_string()); + ui::info(&format!("Installing registry plugin {name}@{installing_version}")); + + let plugin_context = PluginContext::new(Some("cli".to_string()), None); + let installed = download_and_install( + context.plugin_manager(), + context.query_manager(), + &http_client, + &plugin_context, + name.as_str(), + version, + ) + .await + .map_err(|err| format!("Failed to install plugin: {err}"))?; + + ui::success(&format!("Installed plugin {}@{}", installed.name, installed.version)); + Ok(()) +} + +async fn install_from_directory(context: &CliContext, source: &str) -> CommandResult { + let plugin_dir = resolve_plugin_dir(Some(PathBuf::from(source)))?; + let plugin_dir_str = plugin_dir + .to_str() + .ok_or_else(|| { + format!("Plugin directory path is not valid UTF-8: {}", plugin_dir.display()) + })? + .to_string(); + ui::info(&format!("Installing plugin from directory {}", plugin_dir.display())); + + let plugin = context + .db() + .upsert_plugin( + &Plugin { + directory: plugin_dir_str, + url: None, + enabled: true, + source: PluginSource::Filesystem, + ..Default::default() + }, + &UpdateSource::Background, + ) + .map_err(|err| format!("Failed to save plugin in database: {err}"))?; + + let plugin_context = PluginContext::new(Some("cli".to_string()), None); + context + .plugin_manager() + .add_plugin(&plugin_context, &plugin) + .await + .map_err(|err| format!("Failed to load plugin runtime: {err}"))?; + + ui::success(&format!("Installed plugin from {}", plugin.directory)); + Ok(()) +} + +fn parse_registry_install_spec(source: &str) -> Option<(String, Option)> { + if !source.starts_with('@') || !source.contains('/') { + return None; + } + + let rest = source.get(1..)?; + let version_split = rest.rfind('@').map(|idx| idx + 1); + let (name, version) = match version_split { + Some(at_idx) => { + let (name, version) = source.split_at(at_idx); + let version = version.strip_prefix('@').unwrap_or_default(); + if version.is_empty() { + return None; + } + (name.to_string(), Some(version.to_string())) + } + None => (source.to_string(), None), + }; + + if !name.starts_with('@') { + return None; + } + + let without_scope = name.get(1..)?; + let (scope, plugin_name) = without_scope.split_once('/')?; + if scope.is_empty() || plugin_name.is_empty() { + return None; + } + + Some((name, version)) +} + #[derive(Deserialize)] struct PublishResponse { version: String, diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index 43d76f52..a14e42f0 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -6,6 +6,7 @@ use crate::utils::json::{ parse_required_json, require_id, validate_create_id, }; use crate::utils::schema::append_agent_hints; +use crate::utils::workspace::resolve_workspace_id; use schemars::schema_for; use serde_json::{Map, Value, json}; use std::collections::HashMap; @@ -24,13 +25,16 @@ pub async fn run( ctx: &CliContext, args: RequestArgs, environment: Option<&str>, + cookie_jar_id: Option<&str>, verbose: bool, ) -> i32 { let result = match args.command { - RequestCommands::List { workspace_id } => list(ctx, &workspace_id), + RequestCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()), RequestCommands::Show { request_id } => show(ctx, &request_id), RequestCommands::Send { request_id } => { - return match send_request_by_id(ctx, &request_id, environment, verbose).await { + return match send_request_by_id(ctx, &request_id, environment, cookie_jar_id, verbose) + .await + { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); @@ -63,10 +67,11 @@ pub async fn run( } } -fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult { +fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { + let workspace_id = resolve_workspace_id(ctx, workspace_id, "request list")?; let requests = ctx .db() - .list_http_requests(workspace_id) + .list_http_requests(&workspace_id) .map_err(|e| format!("Failed to list requests: {e}"))?; if requests.is_empty() { println!("No requests found in workspace {}", workspace_id); @@ -350,8 +355,14 @@ fn create( validate_create_id(&payload, "request")?; let mut request: HttpRequest = serde_json::from_value(payload) .map_err(|e| format!("Failed to parse request create JSON: {e}"))?; + let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty() + { + Some(resolve_workspace_id(ctx, None, "request create")?) + } else { + None + }; merge_workspace_id_arg( - workspace_id_arg.as_deref(), + workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()), &mut request.workspace_id, "request create", )?; @@ -365,9 +376,7 @@ fn create( return Ok(()); } - let workspace_id = workspace_id_arg.ok_or_else(|| { - "request create requires workspace_id unless JSON payload is provided".to_string() - })?; + let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?; let name = name.unwrap_or_default(); let url = url.unwrap_or_default(); let method = method.unwrap_or_else(|| "GET".to_string()); @@ -436,6 +445,7 @@ pub async fn send_request_by_id( ctx: &CliContext, request_id: &str, environment: Option<&str>, + cookie_jar_id: Option<&str>, verbose: bool, ) -> Result<(), String> { let request = @@ -447,6 +457,7 @@ pub async fn send_request_by_id( &http_request.id, &http_request.workspace_id, environment, + cookie_jar_id, verbose, ) .await @@ -465,9 +476,13 @@ async fn send_http_request_by_id( request_id: &str, workspace_id: &str, environment: Option<&str>, + cookie_jar_id: Option<&str>, verbose: bool, ) -> Result<(), String> { - let plugin_context = PluginContext::new(None, Some(workspace_id.to_string())); + let cookie_jar_id = resolve_cookie_jar_id(ctx, workspace_id, cookie_jar_id)?; + + let plugin_context = + PluginContext::new(Some("cli".to_string()), Some(workspace_id.to_string())); let (event_tx, mut event_rx) = mpsc::channel::(100); let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::>(); @@ -495,7 +510,7 @@ async fn send_http_request_by_id( request_id, environment_id: environment, update_source: UpdateSource::Sync, - cookie_jar_id: None, + cookie_jar_id, response_dir: &response_dir, emit_events_to: Some(event_tx), emit_response_body_chunks_to: Some(body_chunk_tx), @@ -512,3 +527,22 @@ async fn send_http_request_by_id( result.map_err(|e| e.to_string())?; Ok(()) } + +pub(crate) fn resolve_cookie_jar_id( + ctx: &CliContext, + workspace_id: &str, + explicit_cookie_jar_id: Option<&str>, +) -> Result, String> { + if let Some(cookie_jar_id) = explicit_cookie_jar_id { + return Ok(Some(cookie_jar_id.to_string())); + } + + let default_cookie_jar = ctx + .db() + .list_cookie_jars(workspace_id) + .map_err(|e| format!("Failed to list cookie jars: {e}"))? + .into_iter() + .min_by_key(|jar| jar.created_at) + .map(|jar| jar.id); + Ok(default_cookie_jar) +} diff --git a/crates-cli/yaak-cli/src/commands/send.rs b/crates-cli/yaak-cli/src/commands/send.rs index d885c45a..ba96235c 100644 --- a/crates-cli/yaak-cli/src/commands/send.rs +++ b/crates-cli/yaak-cli/src/commands/send.rs @@ -2,6 +2,7 @@ use crate::cli::SendArgs; use crate::commands::request; use crate::context::CliContext; use futures::future::join_all; +use yaak_models::queries::any_request::AnyRequest; enum ExecutionMode { Sequential, @@ -12,9 +13,10 @@ pub async fn run( ctx: &CliContext, args: SendArgs, environment: Option<&str>, + cookie_jar_id: Option<&str>, verbose: bool, ) -> i32 { - match send_target(ctx, args, environment, verbose).await { + match send_target(ctx, args, environment, cookie_jar_id, verbose).await { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); @@ -27,30 +29,70 @@ async fn send_target( ctx: &CliContext, args: SendArgs, environment: Option<&str>, + cookie_jar_id: Option<&str>, verbose: bool, ) -> Result<(), String> { let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential }; - if ctx.db().get_any_request(&args.id).is_ok() { - return request::send_request_by_id(ctx, &args.id, environment, verbose).await; + if let Ok(request) = ctx.db().get_any_request(&args.id) { + let workspace_id = match &request { + AnyRequest::HttpRequest(r) => r.workspace_id.clone(), + AnyRequest::GrpcRequest(r) => r.workspace_id.clone(), + AnyRequest::WebsocketRequest(r) => r.workspace_id.clone(), + }; + let resolved_cookie_jar_id = + request::resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar_id)?; + + return request::send_request_by_id( + ctx, + &args.id, + environment, + resolved_cookie_jar_id.as_deref(), + verbose, + ) + .await; } - if ctx.db().get_folder(&args.id).is_ok() { + if let Ok(folder) = ctx.db().get_folder(&args.id) { + let resolved_cookie_jar_id = + request::resolve_cookie_jar_id(ctx, &folder.workspace_id, cookie_jar_id)?; + let request_ids = collect_folder_request_ids(ctx, &args.id)?; if request_ids.is_empty() { println!("No requests found in folder {}", args.id); return Ok(()); } - return send_many(ctx, request_ids, mode, args.fail_fast, environment, verbose).await; + return send_many( + ctx, + request_ids, + mode, + args.fail_fast, + environment, + resolved_cookie_jar_id.as_deref(), + verbose, + ) + .await; } - if ctx.db().get_workspace(&args.id).is_ok() { + if let Ok(workspace) = ctx.db().get_workspace(&args.id) { + let resolved_cookie_jar_id = + request::resolve_cookie_jar_id(ctx, &workspace.id, cookie_jar_id)?; + let request_ids = collect_workspace_request_ids(ctx, &args.id)?; if request_ids.is_empty() { println!("No requests found in workspace {}", args.id); return Ok(()); } - return send_many(ctx, request_ids, mode, args.fail_fast, environment, verbose).await; + return send_many( + ctx, + request_ids, + mode, + args.fail_fast, + environment, + resolved_cookie_jar_id.as_deref(), + verbose, + ) + .await; } Err(format!("Could not resolve ID '{}' as request, folder, or workspace", args.id)) @@ -131,6 +173,7 @@ async fn send_many( mode: ExecutionMode, fail_fast: bool, environment: Option<&str>, + cookie_jar_id: Option<&str>, verbose: bool, ) -> Result<(), String> { let mut success_count = 0usize; @@ -139,7 +182,15 @@ async fn send_many( match mode { ExecutionMode::Sequential => { for request_id in request_ids { - match request::send_request_by_id(ctx, &request_id, environment, verbose).await { + match request::send_request_by_id( + ctx, + &request_id, + environment, + cookie_jar_id, + verbose, + ) + .await + { Ok(()) => success_count += 1, Err(error) => { failures.push((request_id, error)); @@ -156,7 +207,14 @@ async fn send_many( .map(|request_id| async move { ( request_id.clone(), - request::send_request_by_id(ctx, request_id, environment, verbose).await, + request::send_request_by_id( + ctx, + request_id, + environment, + cookie_jar_id, + verbose, + ) + .await, ) }) .collect::>(); diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index af3753f4..518b4767 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -18,6 +18,14 @@ const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!( static EMBEDDED_VENDORED_PLUGINS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app/vendored/plugins"); +#[derive(Clone, Debug, Default)] +pub struct CliExecutionContext { + pub request_id: Option, + pub workspace_id: Option, + pub environment_id: Option, + pub cookie_jar_id: Option, +} + pub struct CliContext { data_dir: PathBuf, query_manager: QueryManager, @@ -28,63 +36,72 @@ pub struct CliContext { } impl CliContext { - pub async fn initialize(data_dir: PathBuf, app_id: &str, with_plugins: bool) -> Self { + pub fn new(data_dir: PathBuf, app_id: &str) -> Self { let db_path = data_dir.join("db.sqlite"); let blob_path = data_dir.join("blobs.sqlite"); - - let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(&db_path, &blob_path) - .expect("Failed to initialize database"); - - let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); - - let plugin_manager = if with_plugins { - let vendored_plugin_dir = data_dir.join("vendored-plugins"); - let installed_plugin_dir = data_dir.join("installed-plugins"); - let node_bin_path = PathBuf::from("node"); - - prepare_embedded_vendored_plugins(&vendored_plugin_dir) - .expect("Failed to prepare bundled plugins"); - - let plugin_runtime_main = - std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { - prepare_embedded_plugin_runtime(&data_dir) - .expect("Failed to prepare embedded plugin runtime") - }); - - match PluginManager::new( - vendored_plugin_dir, - installed_plugin_dir, - node_bin_path, - plugin_runtime_main, - &query_manager, - &PluginContext::new_empty(), - false, - ) - .await - { - Ok(plugin_manager) => Some(Arc::new(plugin_manager)), + let (query_manager, blob_manager, _rx) = + match yaak_models::init_standalone(&db_path, &blob_path) { + Ok(v) => v, Err(err) => { - eprintln!("Warning: Failed to initialize plugins: {err}"); - None + eprintln!("Error: Failed to initialize database: {err}"); + std::process::exit(1); } - } - } else { - None - }; - - let plugin_event_bridge = if let Some(plugin_manager) = &plugin_manager { - Some(CliPluginEventBridge::start(plugin_manager.clone(), query_manager.clone()).await) - } else { - None - }; + }; + let encryption_manager = + Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); Self { data_dir, query_manager, blob_manager, encryption_manager, - plugin_manager, - plugin_event_bridge: Mutex::new(plugin_event_bridge), + plugin_manager: None, + plugin_event_bridge: Mutex::new(None), + } + } + + pub async fn init_plugins(&mut self, execution_context: CliExecutionContext) { + let vendored_plugin_dir = self.data_dir.join("vendored-plugins"); + let installed_plugin_dir = self.data_dir.join("installed-plugins"); + let node_bin_path = PathBuf::from("node"); + + prepare_embedded_vendored_plugins(&vendored_plugin_dir) + .expect("Failed to prepare bundled plugins"); + + let plugin_runtime_main = + std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { + prepare_embedded_plugin_runtime(&self.data_dir) + .expect("Failed to prepare embedded plugin runtime") + }); + + match PluginManager::new( + vendored_plugin_dir, + installed_plugin_dir, + node_bin_path, + plugin_runtime_main, + &self.query_manager, + &PluginContext::new_empty(), + false, + ) + .await + { + Ok(plugin_manager) => { + let plugin_manager = Arc::new(plugin_manager); + let plugin_event_bridge = CliPluginEventBridge::start( + plugin_manager.clone(), + self.query_manager.clone(), + self.blob_manager.clone(), + self.encryption_manager.clone(), + self.data_dir.clone(), + execution_context, + ) + .await; + self.plugin_manager = Some(plugin_manager); + *self.plugin_event_bridge.lock().await = Some(plugin_event_bridge); + } + Err(err) => { + eprintln!("Warning: Failed to initialize plugins: {err}"); + } } } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index 26a8fa23..33f79362 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -8,12 +8,13 @@ mod version; mod version_check; use clap::Parser; -use cli::{Cli, Commands, RequestCommands}; -use context::CliContext; +use cli::{Cli, Commands, PluginCommands, RequestCommands}; +use context::{CliContext, CliExecutionContext}; +use yaak_models::queries::any_request::AnyRequest; #[tokio::main] async fn main() { - let Cli { data_dir, environment, verbose, log, command } = Cli::parse(); + let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse(); if let Some(log_level) = log { match log_level { @@ -35,72 +36,209 @@ async fn main() { version_check::maybe_check_for_updates().await; - let needs_context = matches!( - &command, - Commands::Send(_) - | Commands::Workspace(_) - | Commands::Request(_) - | Commands::Folder(_) - | Commands::Environment(_) - ); - - let needs_plugins = matches!( - &command, - Commands::Send(_) - | Commands::Request(cli::RequestArgs { - command: RequestCommands::Send { .. } | RequestCommands::Schema { .. }, - }) - ); - - let context = if needs_context { - Some(CliContext::initialize(data_dir, app_id, needs_plugins).await) - } else { - None - }; - let exit_code = match command { Commands::Auth(args) => commands::auth::run(args).await, - Commands::Plugin(args) => commands::plugin::run(args).await, + Commands::Plugin(args) => match args.command { + PluginCommands::Build(args) => commands::plugin::run_build(args).await, + 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::Install(install_args) => { + let mut context = CliContext::new(data_dir.clone(), app_id); + context.init_plugins(CliExecutionContext::default()).await; + let exit_code = commands::plugin::run_install(&context, install_args).await; + context.shutdown().await; + exit_code + } + }, Commands::Build(args) => commands::plugin::run_build(args).await, Commands::Dev(args) => commands::plugin::run_dev(args).await, Commands::Generate(args) => commands::plugin::run_generate(args).await, Commands::Publish(args) => commands::plugin::run_publish(args).await, Commands::Send(args) => { - commands::send::run( - context.as_ref().expect("context initialized for send"), - args, + let mut context = CliContext::new(data_dir.clone(), app_id); + match resolve_send_execution_context( + &context, + &args.id, environment.as_deref(), - verbose, - ) - .await + cookie_jar.as_deref(), + ) { + Ok(execution_context) => { + context.init_plugins(execution_context).await; + let exit_code = commands::send::run( + &context, + args, + environment.as_deref(), + cookie_jar.as_deref(), + verbose, + ) + .await; + context.shutdown().await; + exit_code + } + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } + } + Commands::CookieJar(args) => { + let context = CliContext::new(data_dir.clone(), app_id); + let exit_code = commands::cookie_jar::run(&context, args); + context.shutdown().await; + exit_code + } + Commands::Workspace(args) => { + let context = CliContext::new(data_dir.clone(), app_id); + let exit_code = commands::workspace::run(&context, args); + context.shutdown().await; + exit_code } - Commands::Workspace(args) => commands::workspace::run( - context.as_ref().expect("context initialized for workspace"), - args, - ), Commands::Request(args) => { - commands::request::run( - context.as_ref().expect("context initialized for request"), - args, - environment.as_deref(), - verbose, - ) - .await + let mut context = CliContext::new(data_dir.clone(), app_id); + let execution_context_result = match &args.command { + RequestCommands::Send { request_id } => resolve_request_execution_context( + &context, + request_id, + environment.as_deref(), + cookie_jar.as_deref(), + ), + _ => Ok(CliExecutionContext::default()), + }; + match execution_context_result { + Ok(execution_context) => { + let with_plugins = matches!( + &args.command, + RequestCommands::Send { .. } | RequestCommands::Schema { .. } + ); + if with_plugins { + context.init_plugins(execution_context).await; + } + let exit_code = commands::request::run( + &context, + args, + environment.as_deref(), + cookie_jar.as_deref(), + verbose, + ) + .await; + context.shutdown().await; + exit_code + } + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } } Commands::Folder(args) => { - commands::folder::run(context.as_ref().expect("context initialized for folder"), args) + let context = CliContext::new(data_dir.clone(), app_id); + let exit_code = commands::folder::run(&context, args); + context.shutdown().await; + exit_code + } + Commands::Environment(args) => { + let context = CliContext::new(data_dir.clone(), app_id); + let exit_code = commands::environment::run(&context, args); + context.shutdown().await; + exit_code } - Commands::Environment(args) => commands::environment::run( - context.as_ref().expect("context initialized for environment"), - args, - ), }; - if let Some(context) = &context { - context.shutdown().await; - } - if exit_code != 0 { std::process::exit(exit_code); } } + +fn resolve_send_execution_context( + context: &CliContext, + id: &str, + environment: Option<&str>, + explicit_cookie_jar_id: Option<&str>, +) -> Result { + if let Ok(request) = context.db().get_any_request(id) { + let (request_id, workspace_id) = match request { + AnyRequest::HttpRequest(r) => (Some(r.id), r.workspace_id), + AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id), + AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id), + }; + let cookie_jar_id = + resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?; + return Ok(CliExecutionContext { + request_id, + workspace_id: Some(workspace_id), + environment_id: environment.map(str::to_string), + cookie_jar_id, + }); + } + + if let Ok(folder) = context.db().get_folder(id) { + let cookie_jar_id = + resolve_cookie_jar_id(context, &folder.workspace_id, explicit_cookie_jar_id)?; + return Ok(CliExecutionContext { + request_id: None, + workspace_id: Some(folder.workspace_id), + environment_id: environment.map(str::to_string), + cookie_jar_id, + }); + } + + if let Ok(workspace) = context.db().get_workspace(id) { + let cookie_jar_id = + resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?; + return Ok(CliExecutionContext { + request_id: None, + workspace_id: Some(workspace.id), + environment_id: environment.map(str::to_string), + cookie_jar_id, + }); + } + + Err(format!("Could not resolve ID '{}' as request, folder, or workspace", id)) +} + +fn resolve_request_execution_context( + context: &CliContext, + request_id: &str, + environment: Option<&str>, + explicit_cookie_jar_id: Option<&str>, +) -> Result { + let request = context + .db() + .get_any_request(request_id) + .map_err(|e| format!("Failed to get request: {e}"))?; + + let workspace_id = match request { + AnyRequest::HttpRequest(r) => r.workspace_id, + AnyRequest::GrpcRequest(r) => r.workspace_id, + AnyRequest::WebsocketRequest(r) => r.workspace_id, + }; + let cookie_jar_id = + resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?; + + Ok(CliExecutionContext { + request_id: Some(request_id.to_string()), + workspace_id: Some(workspace_id), + environment_id: environment.map(str::to_string), + cookie_jar_id, + }) +} + +fn resolve_cookie_jar_id( + context: &CliContext, + workspace_id: &str, + explicit_cookie_jar_id: Option<&str>, +) -> Result, String> { + if let Some(cookie_jar_id) = explicit_cookie_jar_id { + return Ok(Some(cookie_jar_id.to_string())); + } + + let default_cookie_jar = context + .db() + .list_cookie_jars(workspace_id) + .map_err(|e| format!("Failed to list cookie jars: {e}"))? + .into_iter() + .min_by_key(|jar| jar.created_at) + .map(|jar| jar.id); + Ok(default_cookie_jar) +} diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index d9bb2037..1e7f9f36 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -1,25 +1,70 @@ +use crate::context::CliExecutionContext; +use arboard::Clipboard; +use console::Term; +use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::io::IsTerminal; +use std::path::PathBuf; use std::sync::Arc; use tokio::task::JoinHandle; use yaak::plugin_events::{ GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event, }; +use yaak::render::render_http_request; +use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; +use yaak_crypto::manager::EncryptionManager; +use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader}; +use yaak_models::queries::any_request::AnyRequest; use yaak_models::query_manager::QueryManager; +use yaak_models::render::make_vars_hashmap; +use yaak_models::util::UpdateSource; use yaak_plugins::events::{ - EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse, - WorkspaceInfo, + EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent, + InternalEventPayload, JsonPrimitive, ListCookieNamesResponse, ListOpenWorkspacesResponse, + PluginContext, PromptFormRequest, PromptFormResponse, PromptTextRequest, PromptTextResponse, + RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse, + TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo, }; use yaak_plugins::manager::PluginManager; +use yaak_plugins::template_callback::PluginTemplateCallback; +use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; pub struct CliPluginEventBridge { rx_id: String, task: JoinHandle<()>, } +struct CliHostContext { + query_manager: QueryManager, + blob_manager: BlobManager, + plugin_manager: Arc, + encryption_manager: Arc, + response_dir: PathBuf, + execution_context: CliExecutionContext, +} + impl CliPluginEventBridge { - pub async fn start(plugin_manager: Arc, query_manager: QueryManager) -> Self { + pub async fn start( + plugin_manager: Arc, + query_manager: QueryManager, + blob_manager: BlobManager, + encryption_manager: Arc, + data_dir: PathBuf, + execution_context: CliExecutionContext, + ) -> Self { let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let rx_id_for_task = rx_id.clone(); let pm = plugin_manager.clone(); + let host_context = Arc::new(CliHostContext { + query_manager, + blob_manager, + plugin_manager, + encryption_manager, + response_dir: data_dir.join("responses"), + execution_context, + }); let task = tokio::spawn(async move { while let Some(event) = rx.recv().await { @@ -37,15 +82,24 @@ impl CliPluginEventBridge { continue; }; - let plugin_name = plugin_handle.info().name; - let Some(reply_payload) = build_plugin_reply(&query_manager, &event, &plugin_name) - else { - continue; - }; + let pm = pm.clone(); + let host_context = host_context.clone(); - if let Err(err) = pm.reply(&event, &reply_payload).await { - eprintln!("Warning: Failed replying to plugin event: {err}"); - } + // Avoid deadlocks for nested plugin-host requests (for example, template functions + // that trigger additional host requests during render) by handling each event in + // its own task. + tokio::spawn(async move { + let plugin_name = plugin_handle.info().name; + let Some(reply_payload) = + build_plugin_reply(host_context.as_ref(), &event, &plugin_name).await + else { + return; + }; + + if let Err(err) = pm.reply(&event, &reply_payload).await { + eprintln!("Warning: Failed replying to plugin event: {err}"); + } + }); } pm.unsubscribe(&rx_id_for_task).await; @@ -61,18 +115,19 @@ impl CliPluginEventBridge { } } -fn build_plugin_reply( - query_manager: &QueryManager, +async fn build_plugin_reply( + host_context: &CliHostContext, event: &InternalEvent, plugin_name: &str, ) -> Option { + let execution_context = &host_context.execution_context; + let shared_workspace_id = + event.context.workspace_id.as_deref().or(execution_context.workspace_id.as_deref()); + match handle_shared_plugin_event( - query_manager, + &host_context.query_manager, &event.payload, - SharedPluginEventContext { - plugin_name, - workspace_id: event.context.workspace_id.as_deref(), - }, + SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id }, ) { GroupedPluginEvent::Handled(payload) => payload, GroupedPluginEvent::ToHandle(host_request) => match host_request { @@ -86,7 +141,7 @@ fn build_plugin_reply( Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})) } HostRequest::ListOpenWorkspaces(_) => { - let workspaces = match query_manager.connect().list_workspaces() { + let workspaces = match host_context.query_manager.connect().list_workspaces() { Ok(workspaces) => workspaces .into_iter() .map(|w| WorkspaceInfo { id: w.id.clone(), name: w.name, label: w.id }) @@ -101,112 +156,900 @@ fn build_plugin_reply( workspaces, })) } - req => Some(InternalEventPayload::ErrorResponse(ErrorResponse { - error: format!("Unsupported plugin request in CLI: {}", req.type_name()), - })), + HostRequest::SendHttpRequest(send_http_request_request) => { + let mut http_request = send_http_request_request.http_request.clone(); + if http_request.workspace_id.is_empty() { + let Some(workspace_id) = event + .context + .workspace_id + .clone() + .or_else(|| execution_context.workspace_id.clone()) + else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to send HTTP requests in CLI" + .to_string(), + })); + }; + http_request.workspace_id = workspace_id; + } + + let cookie_jar_id = + if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() { + Some(cookie_jar_id) + } else { + match host_context + .query_manager + .connect() + .list_cookie_jars(http_request.workspace_id.as_str()) + { + Ok(cookie_jars) => cookie_jars + .into_iter() + .min_by_key(|jar| jar.created_at) + .map(|jar| jar.id), + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to list cookie jars in CLI: {err}"), + })); + } + } + }; + let plugin_context = PluginContext { + workspace_id: Some(http_request.workspace_id.clone()), + ..event.context.clone() + }; + + match send_http_request_with_plugins(SendHttpRequestWithPluginsParams { + query_manager: &host_context.query_manager, + blob_manager: &host_context.blob_manager, + request: http_request, + environment_id: execution_context.environment_id.as_deref(), + update_source: UpdateSource::Plugin, + cookie_jar_id, + response_dir: &host_context.response_dir, + emit_events_to: None, + emit_response_body_chunks_to: None, + existing_response: None, + plugin_manager: host_context.plugin_manager.clone(), + encryption_manager: host_context.encryption_manager.clone(), + plugin_context: &plugin_context, + cancelled_rx: None, + connection_manager: None, + }) + .await + { + Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse( + SendHttpRequestResponse { http_response: result.response }, + )), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to send HTTP request in CLI: {err}"), + })), + } + } + HostRequest::RenderGrpcRequest(render_grpc_request_request) => { + let mut grpc_request = render_grpc_request_request.grpc_request.clone(); + if grpc_request.workspace_id.is_empty() { + let Some(workspace_id) = event + .context + .workspace_id + .clone() + .or_else(|| execution_context.workspace_id.clone()) + else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to render gRPC requests in CLI" + .to_string(), + })); + }; + grpc_request.workspace_id = workspace_id; + } + + let plugin_context = PluginContext { + workspace_id: Some(grpc_request.workspace_id.clone()), + ..event.context.clone() + }; + + let environment_chain = + match host_context.query_manager.connect().resolve_environments( + &grpc_request.workspace_id, + grpc_request.folder_id.as_deref(), + execution_context.environment_id.as_deref(), + ) { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; + + let template_callback = PluginTemplateCallback::new( + host_context.plugin_manager.clone(), + host_context.encryption_manager.clone(), + &plugin_context, + render_grpc_request_request.purpose.clone(), + ); + let render_options = RenderOptions::throw(); + + match render_grpc_request_for_cli( + &grpc_request, + environment_chain, + &template_callback, + &render_options, + ) + .await + { + Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse( + RenderGrpcRequestResponse { grpc_request }, + )), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to render gRPC request in CLI: {err}"), + })), + } + } + HostRequest::RenderHttpRequest(render_http_request_request) => { + let mut http_request = render_http_request_request.http_request.clone(); + if http_request.workspace_id.is_empty() { + let Some(workspace_id) = event + .context + .workspace_id + .clone() + .or_else(|| execution_context.workspace_id.clone()) + else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to render HTTP requests in CLI" + .to_string(), + })); + }; + http_request.workspace_id = workspace_id; + } + + let plugin_context = PluginContext { + workspace_id: Some(http_request.workspace_id.clone()), + ..event.context.clone() + }; + + let environment_chain = + match host_context.query_manager.connect().resolve_environments( + &http_request.workspace_id, + http_request.folder_id.as_deref(), + execution_context.environment_id.as_deref(), + ) { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; + + let template_callback = PluginTemplateCallback::new( + host_context.plugin_manager.clone(), + host_context.encryption_manager.clone(), + &plugin_context, + render_http_request_request.purpose.clone(), + ); + let render_options = RenderOptions::throw(); + + match render_http_request( + &http_request, + environment_chain, + &template_callback, + &render_options, + ) + .await + { + Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse( + RenderHttpRequestResponse { http_request }, + )), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to render HTTP request in CLI: {err}"), + })), + } + } + HostRequest::TemplateRender(template_render_request) => { + let Some(workspace_id) = event + .context + .workspace_id + .clone() + .or_else(|| execution_context.workspace_id.clone()) + else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to render templates in CLI".to_string(), + })); + }; + + let plugin_context = PluginContext { + workspace_id: Some(workspace_id.clone()), + ..event.context.clone() + }; + + let folder_id = execution_context.request_id.as_ref().and_then(|rid| { + match host_context.query_manager.connect().get_any_request(rid) { + Ok(AnyRequest::HttpRequest(r)) => r.folder_id, + Ok(AnyRequest::GrpcRequest(r)) => r.folder_id, + Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id, + Err(_) => None, + } + }); + + let environment_chain = + match host_context.query_manager.connect().resolve_environments( + &workspace_id, + folder_id.as_deref(), + execution_context.environment_id.as_deref(), + ) { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; + + let template_callback = PluginTemplateCallback::new( + host_context.plugin_manager.clone(), + host_context.encryption_manager.clone(), + &plugin_context, + template_render_request.purpose.clone(), + ); + let render_options = RenderOptions::throw(); + + match render_json_value_for_cli( + template_render_request.data.clone(), + environment_chain, + &template_callback, + &render_options, + ) + .await + { + Ok(data) => { + Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { + data, + })) + } + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to render template data in CLI: {err}"), + })), + } + } + HostRequest::OpenExternalUrl(open_external_url_request) => { + match webbrowser::open(open_external_url_request.url.as_str()) { + Ok(_) => Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to open external URL in CLI: {err}"), + })), + } + } + HostRequest::CopyText(req) => match copy_text_to_clipboard(req.text.as_str()) { + Ok(()) => Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})), + Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to copy text in CLI: {error}"), + })), + }, + HostRequest::PromptText(req) => match prompt_text_for_cli(req) { + Ok(value) => { + Some(InternalEventPayload::PromptTextResponse(PromptTextResponse { value })) + } + Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to prompt text in CLI: {error}"), + })), + }, + HostRequest::PromptForm(req) => match prompt_form_for_cli(req) { + Ok(values) => Some(InternalEventPayload::PromptFormResponse(PromptFormResponse { + values, + done: Some(true), + })), + Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to prompt form in CLI: {error}"), + })), + }, + HostRequest::OpenWindow(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: open_window_request".to_string(), + })) + } + HostRequest::CloseWindow(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: close_window_request".to_string(), + })) + } + HostRequest::ListCookieNames(_) => { + let Some(cookie_jar_id) = execution_context.cookie_jar_id.as_deref() else { + return Some(InternalEventPayload::ListCookieNamesResponse( + ListCookieNamesResponse { names: Vec::new() }, + )); + }; + + let cookie_jar = + match host_context.query_manager.connect().get_cookie_jar(cookie_jar_id) { + Ok(cookie_jar) => cookie_jar, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to load cookie jar in CLI: {err}"), + })); + } + }; + + let names = cookie_jar + .cookies + .into_iter() + .filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name)) + .collect(); + + Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse { + names, + })) + } + HostRequest::GetCookieValue(req) => { + let Some(cookie_jar_id) = execution_context.cookie_jar_id.as_deref() else { + return Some(InternalEventPayload::GetCookieValueResponse( + GetCookieValueResponse { value: None }, + )); + }; + + let cookie_jar = + match host_context.query_manager.connect().get_cookie_jar(cookie_jar_id) { + Ok(cookie_jar) => cookie_jar, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to load cookie jar in CLI: {err}"), + })); + } + }; + + let value = cookie_jar.cookies.into_iter().find_map(|c| { + let (name, value) = parse_cookie_name_value(&c.raw_cookie)?; + if name == req.name { Some(value) } else { None } + }); + Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value })) + } + HostRequest::WindowInfo(req) => { + Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse { + label: req.label.clone(), + request_id: execution_context.request_id.clone(), + workspace_id: execution_context + .workspace_id + .clone() + .or_else(|| event.context.workspace_id.clone()), + environment_id: execution_context.environment_id.clone(), + })) + } + HostRequest::OtherRequest(payload) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Unsupported plugin request in CLI: {}", payload.type_name()), + })) + } }, } } -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - use yaak_plugins::events::{GetKeyValueRequest, PluginContext, WindowInfoRequest}; +async fn render_json_value_for_cli( + value: Value, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + render_json_value_raw(value, vars, cb, opt).await +} - fn query_manager_for_test() -> (QueryManager, TempDir) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("db.sqlite"); - let blob_path = temp_dir.path().join("blobs.sqlite"); - let (query_manager, _blob_manager, _rx) = - yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB"); - (query_manager, temp_dir) +async fn render_grpc_request_for_cli( + grpc_request: &GrpcRequest, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + + let mut metadata = Vec::new(); + for p in grpc_request.metadata.clone() { + if !p.enabled { + continue; + } + metadata.push(HttpRequestHeader { + enabled: p.enabled, + name: parse_and_render(p.name.as_str(), vars, cb, opt).await?, + value: parse_and_render(p.value.as_str(), vars, cb, opt).await?, + id: p.id, + }) } - fn event(payload: InternalEventPayload) -> InternalEvent { - InternalEvent { - id: "evt_1".to_string(), - plugin_ref_id: "plugin_ref_1".to_string(), - plugin_name: "@yaak/test-plugin".to_string(), - reply_id: None, - context: PluginContext::new_empty(), - payload, - } - } - - #[test] - fn key_value_requests_round_trip() { - let (query_manager, _temp_dir) = query_manager_for_test(); - let plugin_name = "@yaak/test-plugin"; - - let get_missing = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest { - key: "missing".to_string(), - })), - plugin_name, - ); - match get_missing { - Some(InternalEventPayload::GetKeyValueResponse(r)) => assert_eq!(r.value, None), - other => panic!("unexpected payload for missing get: {other:?}"), - } - - let set = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::SetKeyValueRequest( - yaak_plugins::events::SetKeyValueRequest { - key: "token".to_string(), - value: "{\"access_token\":\"abc\"}".to_string(), - }, - )), - plugin_name, - ); - assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_)))); - - let get_present = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest { - key: "token".to_string(), - })), - plugin_name, - ); - match get_present { - Some(InternalEventPayload::GetKeyValueResponse(r)) => { - assert_eq!(r.value, Some("{\"access_token\":\"abc\"}".to_string())) + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + match grpc_request.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; } - other => panic!("unexpected payload for present get: {other:?}"), + Some(Value::String(tmpl)) => { + disabled = parse_and_render(tmpl.as_str(), vars, cb, opt) + .await + .unwrap_or_default() + .is_empty(); + } + _ => {} } + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (k, v) in grpc_request.authentication.clone() { + if k == "disabled" { + auth.insert(k, Value::Bool(false)); + } else { + auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?); + } + } + } + auth + }; - let delete = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::DeleteKeyValueRequest( - yaak_plugins::events::DeleteKeyValueRequest { key: "token".to_string() }, - )), - plugin_name, - ); - match delete { - Some(InternalEventPayload::DeleteKeyValueResponse(r)) => assert!(r.deleted), - other => panic!("unexpected payload for delete: {other:?}"), + let url = parse_and_render(grpc_request.url.as_str(), vars, cb, opt).await?; + + Ok(GrpcRequest { url, metadata, authentication, ..grpc_request.to_owned() }) +} + +fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> { + let first_part = raw_cookie.split(';').next()?.trim(); + let (name, value) = first_part.split_once('=')?; + Some((name.trim().to_string(), value.to_string())) +} + +fn copy_text_to_clipboard(text: &str) -> Result<(), String> { + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + clipboard.set_text(text.to_string()).map_err(|e| e.to_string()) +} + +fn prompt_text_for_cli(req: &PromptTextRequest) -> Result, String> { + if !std::io::stdin().is_terminal() { + return Err("cannot prompt in non-interactive mode".to_string()); + } + + let term = Term::stdout(); + if let Some(description) = &req.description { + if !description.is_empty() { + term.write_line(description.as_str()).map_err(|e| e.to_string())?; } } - #[test] - fn unsupported_request_gets_error_reply() { - let (query_manager, _temp_dir) = query_manager_for_test(); - let payload = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::WindowInfoRequest(WindowInfoRequest { - label: "main".to_string(), - })), - "@yaak/test-plugin", - ); + let label = if req.label.is_empty() { req.id.as_str() } else { req.label.as_str() }; + let value = if req.password.unwrap_or(false) { + prompt_password_with_inquire( + label, + req.default_value.clone(), + req.required.unwrap_or(false), + )? + } else { + prompt_text_with_inquire( + label, + req.default_value.clone(), + req.placeholder.clone(), + req.required.unwrap_or(false), + )? + }; - match payload { - Some(InternalEventPayload::ErrorResponse(err)) => { - assert!(err.error.contains("Unsupported plugin request in CLI")); - assert!(err.error.contains("window_info_request")); + match value { + PromptValue::Cancelled => Ok(None), + PromptValue::Value(v) => Ok(v), + } +} + +fn prompt_form_for_cli( + req: &PromptFormRequest, +) -> Result>, String> { + if !std::io::stdin().is_terminal() { + return Err("cannot prompt in non-interactive mode".to_string()); + } + + let term = Term::stdout(); + if let Some(description) = &req.description { + if !description.is_empty() { + term.write_line(description.as_str()).map_err(|e| e.to_string())?; + } + } + + let mut values = HashMap::new(); + for input in &req.inputs { + if prompt_form_input_for_cli(input, &mut values)? == PromptOutcome::Cancelled { + return Ok(None); + } + } + Ok(Some(values)) +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum PromptOutcome { + Continue, + Cancelled, +} + +fn prompt_form_input_for_cli( + input: &FormInput, + values: &mut HashMap, +) -> Result { + match input { + FormInput::Text(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); } - other => panic!("unexpected payload for unsupported request: {other:?}"), + + let label = prompt_label_for_base(&input.base); + let required = !input.base.optional.unwrap_or(false); + let value = if input.password.unwrap_or(false) { + prompt_password_with_inquire( + label.as_str(), + input.base.default_value.clone(), + required, + )? + } else { + prompt_text_with_inquire( + label.as_str(), + input.base.default_value.clone(), + input.placeholder.clone(), + required, + )? + }; + + match value { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::String(v)); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::Editor(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + + let label = prompt_label_for_base(&input.base); + let required = !input.base.optional.unwrap_or(false); + let value = prompt_editor_with_inquire( + label.as_str(), + input.base.default_value.clone(), + required, + )?; + match value { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::String(v)); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::Select(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + + let label = prompt_label_for_base(&input.base); + let options = input.options.iter().map(|o| o.value.clone()).collect::>(); + let value = prompt_select_with_inquire( + label.as_str(), + options, + input.base.default_value.clone(), + !input.base.optional.unwrap_or(false), + )?; + match value { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::String(v)); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::Checkbox(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + + let label = prompt_label_for_base(&input.base); + let default = input + .base + .default_value + .as_deref() + .map(|v| matches!(v, "1" | "true" | "yes" | "on")) + .unwrap_or(false); + + match prompt_confirm_with_inquire(label.as_str(), default)? { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::Boolean(v == "true")); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::File(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + + let label = prompt_label_for_base(&input.base); + let value = prompt_text_with_inquire( + label.as_str(), + input.base.default_value.clone(), + Some("Path".to_string()), + !input.base.optional.unwrap_or(false), + )?; + match value { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::String(v)); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::HttpRequest(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + let label = prompt_label_for_base(&input.base); + let value = prompt_text_with_inquire( + label.as_str(), + input.base.default_value.clone(), + Some("Request ID".to_string()), + !input.base.optional.unwrap_or(false), + )?; + match value { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::String(v)); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::KeyValue(input) => { + if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + let label = prompt_label_for_base(&input.base); + let value = prompt_text_with_inquire( + label.as_str(), + input.base.default_value.clone(), + Some("JSON string".to_string()), + !input.base.optional.unwrap_or(false), + )?; + match value { + PromptValue::Cancelled => Ok(PromptOutcome::Cancelled), + PromptValue::Value(Some(v)) => { + values.insert(input.base.name.clone(), JsonPrimitive::String(v)); + Ok(PromptOutcome::Continue) + } + PromptValue::Value(None) => Ok(PromptOutcome::Continue), + } + } + FormInput::Accordion(input) => { + if input.hidden.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + if let Some(inputs) = &input.inputs { + for nested in inputs { + if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled { + return Ok(PromptOutcome::Cancelled); + } + } + } + Ok(PromptOutcome::Continue) + } + FormInput::HStack(input) => { + if input.hidden.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + if let Some(inputs) = &input.inputs { + for nested in inputs { + if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled { + return Ok(PromptOutcome::Cancelled); + } + } + } + Ok(PromptOutcome::Continue) + } + FormInput::Banner(input) => { + if input.hidden.unwrap_or(false) { + return Ok(PromptOutcome::Continue); + } + if let Some(inputs) = &input.inputs { + for nested in inputs { + if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled { + return Ok(PromptOutcome::Cancelled); + } + } + } + Ok(PromptOutcome::Continue) + } + FormInput::Markdown(input) => { + if !input.hidden.unwrap_or(false) && !input.content.trim().is_empty() { + let term = Term::stdout(); + term.write_line(input.content.as_str()).map_err(|e| e.to_string())?; + } + Ok(PromptOutcome::Continue) } } } + +enum PromptValue { + Value(Option), + Cancelled, +} + +fn prompt_text_with_inquire( + label: &str, + default_value: Option, + placeholder: Option, + required: bool, +) -> Result { + let default_value = default_value.and_then(|v| { + let trimmed = v.trim(); + if trimmed.is_empty() { None } else { Some(v) } + }); + + loop { + let message = prompt_message(label); + let mut prompt = Text::new(message.as_str()); + if let Some(v) = default_value.as_deref() { + prompt = prompt.with_default(v); + } + if let Some(v) = placeholder.as_deref() { + if !v.trim().is_empty() { + prompt = prompt.with_placeholder(v); + } + } + let result = prompt.prompt(); + match result { + Ok(v) => { + let v = v.trim().to_string(); + if v.is_empty() { + if let Some(default) = default_value.clone() { + if !default.trim().is_empty() { + return Ok(PromptValue::Value(Some(default))); + } + } + if required { + continue; + } + return Ok(PromptValue::Value(None)); + } + return Ok(PromptValue::Value(Some(v))); + } + Err(inquire::InquireError::OperationCanceled) + | Err(inquire::InquireError::OperationInterrupted) => { + return Ok(PromptValue::Cancelled); + } + Err(err) => return Err(err.to_string()), + } + } +} + +fn prompt_password_with_inquire( + label: &str, + default_value: Option, + required: bool, +) -> Result { + let default_value = default_value.and_then(|v| { + let trimmed = v.trim(); + if trimmed.is_empty() { None } else { Some(v) } + }); + + loop { + let message = prompt_message(label); + let mut prompt = Password::new(message.as_str()).without_confirmation(); + prompt = prompt.with_display_mode(PasswordDisplayMode::Masked); + if default_value.as_ref().is_some_and(|v| !v.trim().is_empty()) { + prompt = prompt.with_help_message("Leave blank to use the default value"); + } + let result = prompt.prompt(); + match result { + Ok(v) => { + let v = v.trim().to_string(); + if v.is_empty() { + if let Some(default) = default_value.clone() { + if !default.trim().is_empty() { + return Ok(PromptValue::Value(Some(default))); + } + } + if required { + continue; + } + return Ok(PromptValue::Value(None)); + } + return Ok(PromptValue::Value(Some(v))); + } + Err(inquire::InquireError::OperationCanceled) + | Err(inquire::InquireError::OperationInterrupted) => { + return Ok(PromptValue::Cancelled); + } + Err(err) => return Err(err.to_string()), + } + } +} + +fn prompt_editor_with_inquire( + label: &str, + default_value: Option, + required: bool, +) -> Result { + loop { + let message = prompt_message(label); + let mut prompt = Editor::new(message.as_str()); + if let Some(v) = default_value.as_deref() { + prompt = prompt.with_predefined_text(v); + } + let result = prompt.prompt(); + match result { + Ok(v) => { + let v = v.trim().to_string(); + if v.is_empty() { + if required { + continue; + } + return Ok(PromptValue::Value(None)); + } + return Ok(PromptValue::Value(Some(v))); + } + Err(inquire::InquireError::OperationCanceled) + | Err(inquire::InquireError::OperationInterrupted) => { + return Ok(PromptValue::Cancelled); + } + Err(err) => return Err(err.to_string()), + } + } +} + +fn prompt_select_with_inquire( + label: &str, + options: Vec, + default_value: Option, + required: bool, +) -> Result { + if options.is_empty() { + if required { + return Err(format!("Select input '{label}' has no options")); + } + return Ok(PromptValue::Value(None)); + } + + let index = default_value + .as_ref() + .and_then(|d| options.iter().position(|o| o == d)) + .unwrap_or_default(); + + let message = prompt_message(label); + let mut prompt = Select::new(message.as_str(), options); + if default_value.is_some() { + prompt = prompt.with_starting_cursor(index); + } + match prompt.prompt() { + Ok(v) => Ok(PromptValue::Value(Some(v))), + Err(inquire::InquireError::OperationCanceled) + | Err(inquire::InquireError::OperationInterrupted) => Ok(PromptValue::Cancelled), + Err(err) => Err(err.to_string()), + } +} + +fn prompt_confirm_with_inquire(label: &str, default: bool) -> Result { + let message = prompt_message(label); + match Confirm::new(message.as_str()).with_default(default).prompt() { + Ok(v) => Ok(PromptValue::Value(Some(if v { "true" } else { "false" }.to_string()))), + Err(inquire::InquireError::OperationCanceled) + | Err(inquire::InquireError::OperationInterrupted) => Ok(PromptValue::Cancelled), + Err(err) => Err(err.to_string()), + } +} + +fn prompt_message(label: &str) -> String { + format!("{label}:") +} + +fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String { + if let Some(label) = &base.label { + if !label.is_empty() { + return label.clone(); + } + } + base.name.clone() +} diff --git a/crates-cli/yaak-cli/src/utils/mod.rs b/crates-cli/yaak-cli/src/utils/mod.rs index 0707af26..f8932a62 100644 --- a/crates-cli/yaak-cli/src/utils/mod.rs +++ b/crates-cli/yaak-cli/src/utils/mod.rs @@ -2,3 +2,4 @@ pub mod confirm; pub mod http; pub mod json; pub mod schema; +pub mod workspace; diff --git a/crates-cli/yaak-cli/src/utils/workspace.rs b/crates-cli/yaak-cli/src/utils/workspace.rs new file mode 100644 index 00000000..a1f54693 --- /dev/null +++ b/crates-cli/yaak-cli/src/utils/workspace.rs @@ -0,0 +1,19 @@ +use crate::context::CliContext; + +pub fn resolve_workspace_id( + ctx: &CliContext, + workspace_id: Option<&str>, + command_name: &str, +) -> Result { + if let Some(workspace_id) = workspace_id { + return Ok(workspace_id.to_string()); + } + + let workspaces = + ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?; + match workspaces.as_slice() { + [] => Err(format!("No workspaces found. {command_name} requires a workspace ID.")), + [workspace] => Ok(workspace.id.clone()), + _ => Err(format!("Multiple workspaces found. {command_name} requires a workspace ID.")), + } +} diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index 57310aaa..ba130b7b 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId: export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array, }; -export type CallHttpAuthenticationResponse = { +export type CallHttpAuthenticationResponse = { /** * HTTP headers to add to the request. Existing headers will be replaced, while * new headers will be added. */ -setHeaders?: Array, +setHeaders?: Array, /** * Query parameters to add to the request. Existing params will be replaced, while * new params will be added. @@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, }; export type ExportHttpRequestResponse = { content: string, }; -export type FileFilter = { name: string, +export type FileFilter = { name: string, /** * File extensions to require */ @@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array, hid export type FormInputBanner = { inputs?: Array, hidden?: boolean, color?: Color, }; -export type FormInputBase = { +export type FormInputBase = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputCheckbox = { +export type FormInputCheckbox = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputEditor = { +export type FormInputEditor = { /** * Placeholder for the text input */ -placeholder?: string | null, +placeholder?: string | null, /** * Don't show the editor gutter (line numbers, folds, etc.) */ -hideGutter?: boolean, +hideGutter?: boolean, /** * Language for syntax highlighting */ -language?: EditorLanguage, readOnly?: boolean, +language?: EditorLanguage, readOnly?: boolean, /** * Fixed number of visible rows */ -rows?: number, completionOptions?: Array, +rows?: number, completionOptions?: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputFile = { +export type FormInputFile = { /** * The title of the file selection window */ -title: string, +title: string, /** * Allow selecting multiple files */ -multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array, +multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ @@ -250,63 +250,63 @@ description?: string, }; export type FormInputHStack = { inputs?: Array, hidden?: boolean, }; -export type FormInputHttpRequest = { +export type FormInputHttpRequest = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ description?: string, }; -export type FormInputKeyValue = { +export type FormInputKeyValue = { /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ @@ -314,36 +314,36 @@ description?: string, }; export type FormInputMarkdown = { content: string, hidden?: boolean, }; -export type FormInputSelect = { +export type FormInputSelect = { /** * The options that will be available in the select input */ -options: Array, +options: Array, /** * The name of the input. The value will be stored at this object attribute in the resulting data */ -name: string, +name: string, /** * Whether this input is visible for the given configuration. Use this to * make branching forms. */ -hidden?: boolean, +hidden?: boolean, /** * Whether the user must fill in the argument */ -optional?: boolean, +optional?: boolean, /** * The label of the input */ -label?: string, +label?: string, /** * Visually hide the label of the input */ -hideLabel?: boolean, +hideLabel?: boolean, /** * The default value */ -defaultValue?: string, disabled?: boolean, +defaultValue?: string, disabled?: boolean, /** * Longer description of the input, likely shown in a tooltip */ @@ -351,44 +351,44 @@ description?: string, }; export type FormInputSelectOption = { label: string, value: string, }; -export type FormInputText = { +export type FormInputText = { /** * Placeholder for the text input */ -placeholder?: string | null, +placeholder?: string | null, /** * Placeholder for the text input */ -password?: boolean, +password?: boolean, /** * Whether to allow newlines in the input, like a