mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-02 19:30:05 +01:00
Compare commits
7 Commits
pr-413
...
yaak-cli-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf0473b20 | ||
|
|
876b7ef454 | ||
|
|
96e8572758 | ||
|
|
f302dc39a2 | ||
|
|
2ca51125a4 | ||
|
|
2d99e26f19 | ||
|
|
da1e04d99e |
@@ -1,35 +0,0 @@
|
||||
# Worktree Management Skill
|
||||
|
||||
## Creating Worktrees
|
||||
|
||||
When creating git worktrees for this project, ALWAYS use the path format:
|
||||
```
|
||||
../yaak-worktrees/<NAME>
|
||||
```
|
||||
|
||||
For example:
|
||||
- `git worktree add ../yaak-worktrees/feature-auth`
|
||||
- `git worktree add ../yaak-worktrees/bugfix-login`
|
||||
- `git worktree add ../yaak-worktrees/refactor-api`
|
||||
|
||||
## What Happens Automatically
|
||||
|
||||
The post-checkout hook will automatically:
|
||||
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
|
||||
2. Copy gitignored editor config folders (.zed, .idea, etc.)
|
||||
3. Run `npm install && npm run bootstrap`
|
||||
|
||||
## Deleting Worktrees
|
||||
|
||||
```bash
|
||||
git worktree remove ../yaak-worktrees/<NAME>
|
||||
```
|
||||
|
||||
## Port Assignments
|
||||
|
||||
- Main worktree: 1420 (Vite), 64343 (MCP)
|
||||
- First worktree: 1421, 64344
|
||||
- Second worktree: 1422, 64345
|
||||
- etc.
|
||||
|
||||
Each worktree can run `npm run app-dev` simultaneously without conflicts.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: worktree-management
|
||||
description: Manage Yaak git worktrees using the standard ../yaak-worktrees/<NAME> layout, including creation, removal, and expected automatic setup behavior and port assignments.
|
||||
---
|
||||
|
||||
# Worktree Management
|
||||
|
||||
Use the Yaak-standard worktree path layout and lifecycle commands.
|
||||
|
||||
## Path Convention
|
||||
|
||||
Always create worktrees under:
|
||||
|
||||
`../yaak-worktrees/<NAME>`
|
||||
|
||||
Examples:
|
||||
- `git worktree add ../yaak-worktrees/feature-auth`
|
||||
- `git worktree add ../yaak-worktrees/bugfix-login`
|
||||
- `git worktree add ../yaak-worktrees/refactor-api`
|
||||
|
||||
## Automatic Setup After Checkout
|
||||
|
||||
Project git hooks automatically:
|
||||
1. Create `.env.local` with unique `YAAK_DEV_PORT` and `YAAK_PLUGIN_MCP_SERVER_PORT`
|
||||
2. Copy gitignored editor config folders
|
||||
3. Run `npm install && npm run bootstrap`
|
||||
|
||||
## Remove Worktree
|
||||
|
||||
`git worktree remove ../yaak-worktrees/<NAME>`
|
||||
|
||||
## Port Pattern
|
||||
|
||||
- Main worktree: Vite `1420`, MCP `64343`
|
||||
- First extra worktree: `1421`, `64344`
|
||||
- Second extra worktree: `1422`, `64345`
|
||||
- Continue incrementally for additional worktrees
|
||||
@@ -1 +1,2 @@
|
||||
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
|
||||
- Do not commit, push, or tag without explicit approval
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -10166,6 +10166,7 @@ dependencies = [
|
||||
"walkdir",
|
||||
"webbrowser",
|
||||
"yaak",
|
||||
"yaak-api",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
|
||||
@@ -32,6 +32,7 @@ walkdir = "2"
|
||||
webbrowser = "1"
|
||||
zip = "4"
|
||||
yaak = { workspace = true }
|
||||
yaak-api = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
@@ -47,6 +47,14 @@ pub enum Commands {
|
||||
#[command(hide = true)]
|
||||
Dev(PluginPathArg),
|
||||
|
||||
/// Backward-compatible alias for `plugin generate`
|
||||
#[command(hide = true)]
|
||||
Generate(GenerateArgs),
|
||||
|
||||
/// Backward-compatible alias for `plugin publish`
|
||||
#[command(hide = true)]
|
||||
Publish(PluginPathArg),
|
||||
|
||||
/// Send a request, folder, or workspace by ID
|
||||
Send(SendArgs),
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use crate::utils::http;
|
||||
use keyring::Entry;
|
||||
use rand::Rng;
|
||||
use rolldown::{
|
||||
Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat, Platform,
|
||||
WatchOption, Watcher,
|
||||
BundleEvent, Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat,
|
||||
Platform, WatchOption, Watcher, WatcherEvent,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
@@ -114,12 +114,53 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||
|
||||
ui::info(&format!("Watching plugin {}...", plugin_dir.display()));
|
||||
ui::info("Press Ctrl-C to stop");
|
||||
|
||||
let bundler = Bundler::new(bundler_options(&plugin_dir, true))
|
||||
.map_err(|err| format!("Failed to initialize Rolldown watcher: {err}"))?;
|
||||
let watcher = Watcher::new(vec![Arc::new(Mutex::new(bundler))], None)
|
||||
.map_err(|err| format!("Failed to start Rolldown watcher: {err}"))?;
|
||||
let emitter = watcher.emitter();
|
||||
let watch_root = plugin_dir.clone();
|
||||
let _event_logger = tokio::spawn(async move {
|
||||
loop {
|
||||
let event = {
|
||||
let rx = emitter.rx.lock().await;
|
||||
rx.recv()
|
||||
};
|
||||
|
||||
let Ok(event) = event else {
|
||||
break;
|
||||
};
|
||||
|
||||
match event {
|
||||
WatcherEvent::Change(change) => {
|
||||
let changed_path = Path::new(change.path.as_str());
|
||||
let display_path = changed_path
|
||||
.strip_prefix(&watch_root)
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
changed_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}
|
||||
WatcherEvent::Event(BundleEvent::Error(event)) => {
|
||||
if event.error.diagnostics.is_empty() {
|
||||
ui::error("Plugin build failed");
|
||||
} else {
|
||||
for diagnostic in event.error.diagnostics {
|
||||
ui::error(&diagnostic.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
WatcherEvent::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watcher.start().await;
|
||||
Ok(())
|
||||
|
||||
@@ -31,18 +31,13 @@ pub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {
|
||||
}
|
||||
|
||||
fn schema(pretty: bool) -> CommandResult {
|
||||
let mut schema =
|
||||
serde_json::to_value(schema_for!(Workspace)).map_err(|e| format!(
|
||||
"Failed to serialize workspace schema: {e}"
|
||||
))?;
|
||||
let mut schema = serde_json::to_value(schema_for!(Workspace))
|
||||
.map_err(|e| format!("Failed to serialize workspace schema: {e}"))?;
|
||||
append_agent_hints(&mut schema);
|
||||
|
||||
let output = if pretty {
|
||||
serde_json::to_string_pretty(&schema)
|
||||
} else {
|
||||
serde_json::to_string(&schema)
|
||||
}
|
||||
.map_err(|e| format!("Failed to format workspace schema JSON: {e}"))?;
|
||||
let output =
|
||||
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
|
||||
.map_err(|e| format!("Failed to format workspace schema JSON: {e}"))?;
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod plugin_events;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod version;
|
||||
mod version_check;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands, RequestCommands};
|
||||
@@ -32,6 +33,8 @@ async fn main() {
|
||||
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
||||
});
|
||||
|
||||
version_check::maybe_check_for_updates().await;
|
||||
|
||||
let needs_context = matches!(
|
||||
&command,
|
||||
Commands::Send(_)
|
||||
@@ -60,6 +63,8 @@ async fn main() {
|
||||
Commands::Plugin(args) => commands::plugin::run(args).await,
|
||||
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"),
|
||||
|
||||
@@ -17,6 +17,14 @@ pub fn warning(message: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning_stderr(message: &str) {
|
||||
if io::stderr().is_terminal() {
|
||||
eprintln!("{:<8} {}", style("WARNING").yellow().bold(), style(message).yellow());
|
||||
} else {
|
||||
eprintln!("WARNING {message}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success(message: &str) {
|
||||
if io::stdout().is_terminal() {
|
||||
println!("{:<8} {}", style("SUCCESS").green().bold(), style(message).green());
|
||||
|
||||
227
crates-cli/yaak-cli/src/version_check.rs
Normal file
227
crates-cli/yaak-cli/src/version_check.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use crate::ui;
|
||||
use crate::version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
|
||||
const CACHE_FILE_NAME: &str = "cli-version-check.json";
|
||||
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct VersionCheckResponse {
|
||||
outdated: bool,
|
||||
latest_version: Option<String>,
|
||||
upgrade_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct CacheRecord {
|
||||
checked_at_epoch_secs: u64,
|
||||
response: VersionCheckResponse,
|
||||
last_warned_at_epoch_secs: Option<u64>,
|
||||
last_warned_version: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CacheRecord {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
checked_at_epoch_secs: 0,
|
||||
response: VersionCheckResponse::default(),
|
||||
last_warned_at_epoch_secs: None,
|
||||
last_warned_version: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VersionCheckRequest<'a> {
|
||||
current_version: &'a str,
|
||||
channel: String,
|
||||
install_source: String,
|
||||
platform: &'a str,
|
||||
arch: &'a str,
|
||||
}
|
||||
|
||||
pub async fn maybe_check_for_updates() {
|
||||
if should_skip_check() {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = unix_epoch_secs();
|
||||
let cache_path = cache_path();
|
||||
let cached = read_cache(&cache_path);
|
||||
|
||||
if let Some(cache) = cached.as_ref().filter(|c| !is_expired(c.checked_at_epoch_secs, now)) {
|
||||
let mut record = cache.clone();
|
||||
maybe_warn_outdated(&mut record, now);
|
||||
write_cache(&cache_path, &record);
|
||||
return;
|
||||
}
|
||||
|
||||
let fresh = fetch_version_check().await;
|
||||
match fresh {
|
||||
Some(response) => {
|
||||
let mut record = CacheRecord {
|
||||
checked_at_epoch_secs: now,
|
||||
response: response.clone(),
|
||||
last_warned_at_epoch_secs: cached
|
||||
.as_ref()
|
||||
.and_then(|c| c.last_warned_at_epoch_secs),
|
||||
last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),
|
||||
};
|
||||
maybe_warn_outdated(&mut record, now);
|
||||
write_cache(&cache_path, &record);
|
||||
}
|
||||
None => {
|
||||
let fallback = cached.as_ref().map(|cache| cache.response.clone()).unwrap_or_default();
|
||||
let mut record = CacheRecord {
|
||||
checked_at_epoch_secs: now,
|
||||
response: fallback,
|
||||
last_warned_at_epoch_secs: cached
|
||||
.as_ref()
|
||||
.and_then(|c| c.last_warned_at_epoch_secs),
|
||||
last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),
|
||||
};
|
||||
maybe_warn_outdated(&mut record, now);
|
||||
write_cache(&cache_path, &record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_check() -> bool {
|
||||
if std::env::var("YAAK_CLI_NO_UPDATE_CHECK")
|
||||
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if std::env::var("CI").is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
!std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
async fn fetch_version_check() -> Option<VersionCheckResponse> {
|
||||
let api_url = format!("{}/api/v1/cli/version-check", api_base_url());
|
||||
let current_version = version::cli_version();
|
||||
let payload = VersionCheckRequest {
|
||||
current_version,
|
||||
channel: release_channel(current_version),
|
||||
install_source: install_source(),
|
||||
platform: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
};
|
||||
|
||||
let client = yaak_api_client(ApiClientKind::Cli, current_version).ok()?;
|
||||
let request = client.post(api_url).json(&payload);
|
||||
|
||||
let response = tokio::time::timeout(REQUEST_TIMEOUT, request.send()).await.ok()?.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
tokio::time::timeout(REQUEST_TIMEOUT, response.json::<VersionCheckResponse>()).await.ok()?.ok()
|
||||
}
|
||||
|
||||
fn release_channel(version: &str) -> String {
|
||||
version
|
||||
.split_once('-')
|
||||
.and_then(|(_, suffix)| suffix.split('.').next())
|
||||
.unwrap_or("stable")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn install_source() -> String {
|
||||
std::env::var("YAAK_CLI_INSTALL_SOURCE")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "source".to_string())
|
||||
}
|
||||
|
||||
fn api_base_url() -> &'static str {
|
||||
match std::env::var("ENVIRONMENT").ok().as_deref() {
|
||||
Some("staging") => "https://todo.yaak.app",
|
||||
Some("development") => "http://localhost:9444",
|
||||
_ => "https://api.yaak.app",
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_warn_outdated(record: &mut CacheRecord, now: u64) {
|
||||
if !record.response.outdated {
|
||||
return;
|
||||
}
|
||||
|
||||
let latest =
|
||||
record.response.latest_version.clone().unwrap_or_else(|| "a newer release".to_string());
|
||||
let warn_suppressed = record.last_warned_version.as_deref() == Some(latest.as_str())
|
||||
&& record.last_warned_at_epoch_secs.is_some_and(|t| !is_expired(t, now));
|
||||
if warn_suppressed {
|
||||
return;
|
||||
}
|
||||
|
||||
let hint = record.response.upgrade_hint.clone().unwrap_or_else(default_upgrade_hint);
|
||||
ui::warning_stderr(&format!("A newer Yaak CLI version is available ({latest}). {hint}"));
|
||||
record.last_warned_version = Some(latest);
|
||||
record.last_warned_at_epoch_secs = Some(now);
|
||||
}
|
||||
|
||||
fn default_upgrade_hint() -> String {
|
||||
if install_source() == "npm" {
|
||||
let channel = release_channel(version::cli_version());
|
||||
if channel == "stable" {
|
||||
return "Run `npm install -g @yaakapp/cli@latest` to update.".to_string();
|
||||
}
|
||||
return format!("Run `npm install -g @yaakapp/cli@{channel}` to update.");
|
||||
}
|
||||
|
||||
"Update your Yaak CLI installation to the latest release.".to_string()
|
||||
}
|
||||
|
||||
fn cache_path() -> PathBuf {
|
||||
std::env::temp_dir().join("yaak-cli").join(format!("{}-{CACHE_FILE_NAME}", environment_name()))
|
||||
}
|
||||
|
||||
fn environment_name() -> &'static str {
|
||||
match std::env::var("ENVIRONMENT").ok().as_deref() {
|
||||
Some("staging") => "staging",
|
||||
Some("development") => "development",
|
||||
_ => "production",
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cache(path: &Path) -> Option<CacheRecord> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<CacheRecord>(&contents).ok()
|
||||
}
|
||||
|
||||
fn write_cache(path: &Path, record: &CacheRecord) {
|
||||
let Some(parent) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
if fs::create_dir_all(parent).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(json) = serde_json::to_string(record) else {
|
||||
return;
|
||||
};
|
||||
let _ = fs::write(path, json);
|
||||
}
|
||||
|
||||
fn is_expired(checked_at_epoch_secs: u64, now: u64) -> bool {
|
||||
now.saturating_sub(checked_at_epoch_secs) >= CHECK_INTERVAL_SECS
|
||||
}
|
||||
|
||||
fn unix_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||
.as_secs()
|
||||
}
|
||||
@@ -70,6 +70,8 @@ fn workspace_schema_outputs_json_schema() {
|
||||
.stdout(contains("\"type\":\"object\""))
|
||||
.stdout(contains("\"x-yaak-agent-hints\""))
|
||||
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
|
||||
.stdout(contains("\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\""))
|
||||
.stdout(contains(
|
||||
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
|
||||
))
|
||||
.stdout(contains("\"name\""));
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
|
||||
Workspace, WorkspaceMeta,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
||||
use yaak_plugins::events::{
|
||||
@@ -1345,29 +1345,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_install_plugin<R: Runtime>(
|
||||
directory: &str,
|
||||
url: Option<String>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<Plugin> {
|
||||
let plugin = app_handle.db().upsert_plugin(
|
||||
&Plugin { directory: directory.into(), url, enabled: true, ..Default::default() },
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
plugin_manager
|
||||
.add_plugin(
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
&plugin,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
@@ -1652,7 +1629,6 @@ pub fn run() {
|
||||
cmd_workspace_actions,
|
||||
cmd_folder_actions,
|
||||
cmd_import_data,
|
||||
cmd_install_plugin,
|
||||
cmd_metadata,
|
||||
cmd_new_child_window,
|
||||
cmd_new_main_window,
|
||||
@@ -1721,6 +1697,7 @@ pub fn run() {
|
||||
git_ext::cmd_git_rm_remote,
|
||||
//
|
||||
// Plugin commands
|
||||
plugins_ext::cmd_plugins_install_from_directory,
|
||||
plugins_ext::cmd_plugins_search,
|
||||
plugins_ext::cmd_plugins_install,
|
||||
plugins_ext::cmd_plugins_uninstall,
|
||||
|
||||
@@ -15,6 +15,7 @@ use yaak_models::error::Result;
|
||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
@@ -255,23 +256,32 @@ pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_workspace_models<R: Runtime>(
|
||||
pub(crate) async fn models_workspace_models<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: Option<&str>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<String> {
|
||||
let db = window.db();
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the settings
|
||||
l.push(db.get_settings().into());
|
||||
// Add the global models
|
||||
{
|
||||
let db = window.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
// Add global models
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_plugins()?.into_iter().map(Into::into).collect());
|
||||
let plugins = {
|
||||
let db = window.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = workspace_id {
|
||||
let db = window.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||
use ts_rs::TS;
|
||||
use yaak_api::yaak_api_client;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_common::platform::get_os_str;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
@@ -102,7 +102,7 @@ impl YaakNotifier {
|
||||
|
||||
let launch_info = get_or_upsert_launch_info(app_handle);
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let req = yaak_api_client(&app_version)?
|
||||
let req = yaak_api_client(ApiClientKind::App, &app_version)?
|
||||
.request(Method::GET, "https://notify.yaak.app/notifications")
|
||||
.query(&[
|
||||
("version", &launch_info.current_version),
|
||||
|
||||
@@ -19,13 +19,13 @@ use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::models::{AnyModel, HttpResponse, Plugin};
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::events::{
|
||||
Color, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, GetCookieValueResponse, Icon,
|
||||
InternalEvent, InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
|
||||
Color, EmptyPayload, ErrorResponse, GetCookieValueResponse, Icon, InternalEvent,
|
||||
InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
|
||||
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
||||
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
||||
WorkspaceInfo,
|
||||
@@ -118,7 +118,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
||||
message: format!("Reloaded plugin {}@{}", info.name, info.version),
|
||||
icon: Some(Icon::Info),
|
||||
timeout: Some(3000),
|
||||
timeout: Some(5000),
|
||||
..Default::default()
|
||||
}),
|
||||
None,
|
||||
@@ -190,71 +190,6 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
HostRequest::FindHttpResponses(req) => {
|
||||
let http_responses = app_handle
|
||||
.db()
|
||||
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
|
||||
.unwrap_or_default();
|
||||
Ok(Some(InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
|
||||
http_responses,
|
||||
})))
|
||||
}
|
||||
HostRequest::UpsertModel(req) => {
|
||||
use AnyModel::*;
|
||||
let model = match &req.model {
|
||||
HttpRequest(m) => {
|
||||
HttpRequest(app_handle.db().upsert_http_request(m, &UpdateSource::Plugin)?)
|
||||
}
|
||||
GrpcRequest(m) => {
|
||||
GrpcRequest(app_handle.db().upsert_grpc_request(m, &UpdateSource::Plugin)?)
|
||||
}
|
||||
WebsocketRequest(m) => WebsocketRequest(
|
||||
app_handle.db().upsert_websocket_request(m, &UpdateSource::Plugin)?,
|
||||
),
|
||||
Folder(m) => Folder(app_handle.db().upsert_folder(m, &UpdateSource::Plugin)?),
|
||||
Environment(m) => {
|
||||
Environment(app_handle.db().upsert_environment(m, &UpdateSource::Plugin)?)
|
||||
}
|
||||
Workspace(m) => {
|
||||
Workspace(app_handle.db().upsert_workspace(m, &UpdateSource::Plugin)?)
|
||||
}
|
||||
_ => {
|
||||
return Err(PluginErr("Upsert not supported for this model type".into()).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(InternalEventPayload::UpsertModelResponse(
|
||||
yaak_plugins::events::UpsertModelResponse { model },
|
||||
)))
|
||||
}
|
||||
HostRequest::DeleteModel(req) => {
|
||||
let model = match req.model.as_str() {
|
||||
"http_request" => AnyModel::HttpRequest(
|
||||
app_handle.db().delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
|
||||
),
|
||||
"grpc_request" => AnyModel::GrpcRequest(
|
||||
app_handle.db().delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)?,
|
||||
),
|
||||
"websocket_request" => AnyModel::WebsocketRequest(
|
||||
app_handle
|
||||
.db()
|
||||
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)?,
|
||||
),
|
||||
"folder" => AnyModel::Folder(
|
||||
app_handle.db().delete_folder_by_id(&req.id, &UpdateSource::Plugin)?,
|
||||
),
|
||||
"environment" => AnyModel::Environment(
|
||||
app_handle.db().delete_environment_by_id(&req.id, &UpdateSource::Plugin)?,
|
||||
),
|
||||
_ => {
|
||||
return Err(PluginErr("Delete not supported for this model type".into()).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(InternalEventPayload::DeleteModelResponse(
|
||||
yaak_plugins::events::DeleteModelResponse { model },
|
||||
)))
|
||||
}
|
||||
HostRequest::RenderGrpcRequest(req) => {
|
||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ use tauri::{
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use ts_rs::TS;
|
||||
use yaak_api::yaak_api_client;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::models::{Plugin, PluginSource};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::api::{
|
||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||
search_plugins,
|
||||
@@ -72,7 +73,7 @@ impl PluginUpdater {
|
||||
info!("Checking for plugin updates");
|
||||
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(&app_version)?;
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = window.app_handle().db().list_plugins()?;
|
||||
let updates = check_plugin_updates(&http_client, plugins.clone()).await?;
|
||||
|
||||
@@ -137,7 +138,7 @@ pub async fn cmd_plugins_search<R: Runtime>(
|
||||
query: &str,
|
||||
) -> Result<PluginSearchResponse> {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(&app_version)?;
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
Ok(search_plugins(&http_client, query).await?)
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
||||
) -> Result<()> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(&app_version)?;
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
download_and_install(
|
||||
@@ -164,6 +165,28 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
directory: &str,
|
||||
) -> Result<Plugin> {
|
||||
let plugin = window.db().upsert_plugin(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
plugin_id: &str,
|
||||
@@ -180,7 +203,7 @@ pub async fn cmd_plugins_updates<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(&app_version)?;
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
Ok(check_plugin_updates(&http_client, plugins).await?)
|
||||
}
|
||||
@@ -190,7 +213,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<Vec<PluginNameVersion>> {
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(&app_version)?;
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = window.db().list_plugins()?;
|
||||
|
||||
// Get list of available updates (already filtered to only registry plugins)
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use yaak_api::yaak_api_client;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||
use yaak_plugins::install::download_and_install;
|
||||
@@ -47,7 +47,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let query_manager = app_handle.db_manager();
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(&app_version)?;
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugin_context = window.plugin_context();
|
||||
let pv = download_and_install(
|
||||
plugin_manager,
|
||||
@@ -88,7 +88,8 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
}
|
||||
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let resp = yaak_api_client(&app_version)?.get(file_url).send().await?;
|
||||
let resp =
|
||||
yaak_api_client(ApiClientKind::App, &app_version)?.get(file_url).send().await?;
|
||||
let json = resp.bytes().await?;
|
||||
let p = app_handle
|
||||
.path()
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::ops::Add;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
||||
use ts_rs::TS;
|
||||
use yaak_api::yaak_api_client;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_common::platform::get_os_str;
|
||||
use yaak_models::db_context::DbContext;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
@@ -119,7 +119,7 @@ pub async fn activate_license<R: Runtime>(
|
||||
) -> Result<()> {
|
||||
info!("Activating license {}", license_key);
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let client = yaak_api_client(&app_version)?;
|
||||
let client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let payload = ActivateLicenseRequestPayload {
|
||||
license_key: license_key.to_string(),
|
||||
app_platform: get_os_str().to_string(),
|
||||
@@ -157,7 +157,7 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
|
||||
let activation_id = get_activation_id(app_handle).await;
|
||||
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let client = yaak_api_client(&app_version)?;
|
||||
let client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let path = format!("/licenses/activations/{}/deactivate", activation_id);
|
||||
let payload =
|
||||
DeactivateLicenseRequestPayload { app_platform: get_os_str().to_string(), app_version };
|
||||
@@ -203,7 +203,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
|
||||
(true, _) => {
|
||||
info!("Checking license activation");
|
||||
// A license has been activated, so let's check the license server
|
||||
let client = yaak_api_client(&payload.app_version)?;
|
||||
let client = yaak_api_client(ApiClientKind::App, &payload.app_version)?;
|
||||
let path = format!("/licenses/activations/{activation_id}/check-v2");
|
||||
let response = client.post(build_url(&path)).json(&payload).send().await?;
|
||||
|
||||
|
||||
@@ -8,14 +8,24 @@ use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use std::time::Duration;
|
||||
use yaak_common::platform::{get_ua_arch, get_ua_platform};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ApiClientKind {
|
||||
App,
|
||||
Cli,
|
||||
}
|
||||
|
||||
/// Build a reqwest Client configured for Yaak's own API calls.
|
||||
///
|
||||
/// Includes a custom User-Agent, JSON accept header, 20s timeout, gzip,
|
||||
/// and automatic OS-level proxy detection via sysproxy.
|
||||
pub fn yaak_api_client(version: &str) -> Result<Client> {
|
||||
pub fn yaak_api_client(kind: ApiClientKind, version: &str) -> Result<Client> {
|
||||
let platform = get_ua_platform();
|
||||
let arch = get_ua_arch();
|
||||
let ua = format!("Yaak/{version} ({platform}; {arch})");
|
||||
let product = match kind {
|
||||
ApiClientKind::App => "Yaak",
|
||||
ApiClientKind::Cli => "YaakCli",
|
||||
};
|
||||
let ua = format!("{product}/{version} ({platform}; {arch})");
|
||||
|
||||
let mut default_headers = HeaderMap::new();
|
||||
default_headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());
|
||||
|
||||
4
crates/yaak-models/bindings/gen_models.ts
generated
4
crates/yaak-models/bindings/gen_models.ts
generated
@@ -67,7 +67,9 @@ export type ParentAuthentication = { authentication: Record<string, any>, authen
|
||||
|
||||
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
|
||||
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
ALTER TABLE plugins
|
||||
ADD COLUMN source TEXT DEFAULT 'filesystem' NOT NULL;
|
||||
|
||||
-- Existing registry installs have a URL; classify them first.
|
||||
UPDATE plugins
|
||||
SET source = 'registry'
|
||||
WHERE url IS NOT NULL;
|
||||
|
||||
-- Best-effort bundled backfill for legacy rows.
|
||||
UPDATE plugins
|
||||
SET source = 'bundled'
|
||||
WHERE source = 'filesystem'
|
||||
AND (
|
||||
-- Normalize separators so this also works for Windows paths.
|
||||
replace(directory, '\', '/') LIKE '%/vendored/plugins/%'
|
||||
OR replace(directory, '\', '/') LIKE '%/vendored-plugins/%'
|
||||
);
|
||||
|
||||
-- Keep one row per exact directory before adding uniqueness.
|
||||
-- Tie-break by recency.
|
||||
WITH ranked AS (SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY directory
|
||||
ORDER BY updated_at DESC,
|
||||
created_at DESC
|
||||
) AS row_num
|
||||
FROM plugins)
|
||||
DELETE
|
||||
FROM plugins
|
||||
WHERE id IN (SELECT id FROM ranked WHERE row_num > 1);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_directory_unique
|
||||
ON plugins (directory);
|
||||
@@ -2074,6 +2074,46 @@ pub struct Plugin {
|
||||
pub directory: String,
|
||||
pub enabled: bool,
|
||||
pub url: Option<String>,
|
||||
pub source: PluginSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum PluginSource {
|
||||
Bundled,
|
||||
Filesystem,
|
||||
Registry,
|
||||
}
|
||||
|
||||
impl FromStr for PluginSource {
|
||||
type Err = crate::error::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
match s {
|
||||
"bundled" => Ok(Self::Bundled),
|
||||
"filesystem" => Ok(Self::Filesystem),
|
||||
"registry" => Ok(Self::Registry),
|
||||
_ => Ok(Self::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PluginSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = match self {
|
||||
PluginSource::Bundled => "bundled".to_string(),
|
||||
PluginSource::Filesystem => "filesystem".to_string(),
|
||||
PluginSource::Registry => "registry".to_string(),
|
||||
};
|
||||
write!(f, "{}", str)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginSource {
|
||||
fn default() -> Self {
|
||||
Self::Filesystem
|
||||
}
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for Plugin {
|
||||
@@ -2109,6 +2149,7 @@ impl UpsertModelInfo for Plugin {
|
||||
(Directory, self.directory.into()),
|
||||
(Url, self.url.into()),
|
||||
(Enabled, self.enabled.into()),
|
||||
(Source, self.source.to_string().into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2119,6 +2160,7 @@ impl UpsertModelInfo for Plugin {
|
||||
PluginIden::Directory,
|
||||
PluginIden::Url,
|
||||
PluginIden::Enabled,
|
||||
PluginIden::Source,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2135,6 +2177,7 @@ impl UpsertModelInfo for Plugin {
|
||||
url: row.get("url")?,
|
||||
directory: row.get("directory")?,
|
||||
enabled: row.get("enabled")?,
|
||||
source: PluginSource::from_str(row.get::<_, String>("source")?.as_str()).unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ impl<'a> DbContext<'a> {
|
||||
}
|
||||
|
||||
pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
|
||||
self.upsert(plugin, source)
|
||||
let mut plugin_to_upsert = plugin.clone();
|
||||
if let Some(existing) = self.get_plugin_by_directory(&plugin.directory) {
|
||||
plugin_to_upsert.id = existing.id;
|
||||
}
|
||||
self.upsert(&plugin_to_upsert, source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,7 @@ export async function checkPluginUpdates() {
|
||||
export async function updateAllPlugins() {
|
||||
return invoke<PluginNameVersion[]>('cmd_plugins_update_all', {});
|
||||
}
|
||||
|
||||
export async function installPluginFromDirectory(directory: string) {
|
||||
return invoke<void>('cmd_plugins_install_from_directory', { directory });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::models::{Plugin, PluginSource};
|
||||
|
||||
/// Get plugin info from the registry.
|
||||
pub async fn get_plugin(
|
||||
@@ -58,7 +58,7 @@ pub async fn check_plugin_updates(
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
let name_versions: Vec<PluginNameVersion> = plugins
|
||||
.into_iter()
|
||||
.filter(|p| p.url.is_some()) // Only check plugins with URLs (from registry)
|
||||
.filter(|p| matches!(p.source, PluginSource::Registry)) // Only check registry-installed plugins
|
||||
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
|
||||
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
|
||||
Err(e) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ use log::info;
|
||||
use std::fs::{create_dir_all, remove_dir_all};
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::models::{Plugin, PluginSource};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
@@ -78,6 +78,7 @@ pub async fn download_and_install(
|
||||
directory: plugin_dir_str.clone(),
|
||||
enabled: true,
|
||||
url: Some(plugin_version.url.clone()),
|
||||
source: PluginSource::Registry,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
|
||||
@@ -21,9 +21,10 @@ use crate::events::{
|
||||
use crate::native_template_functions::{template_function_keyring, template_function_secure};
|
||||
use crate::nodejs::start_nodejs_plugin_runtime;
|
||||
use crate::plugin_handle::PluginHandle;
|
||||
use crate::plugin_meta::get_plugin_meta;
|
||||
use crate::server_ws::PluginRuntimeServerWebsocket;
|
||||
use log::{error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -33,7 +34,7 @@ use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
||||
use tokio::time::{Instant, timeout};
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::models::{Plugin, PluginSource};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{UpdateSource, generate_id};
|
||||
use yaak_templates::error::Error::RenderError;
|
||||
@@ -162,13 +163,14 @@ impl PluginManager {
|
||||
|
||||
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
||||
let db = query_manager.connect();
|
||||
for dir in bundled_dirs {
|
||||
if db.get_plugin_by_directory(&dir).is_none() {
|
||||
for dir in &bundled_dirs {
|
||||
if db.get_plugin_by_directory(dir).is_none() {
|
||||
db.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: dir,
|
||||
directory: dir.clone(),
|
||||
enabled: true,
|
||||
url: None,
|
||||
source: PluginSource::Bundled,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
@@ -213,6 +215,57 @@ impl PluginManager {
|
||||
read_plugins_dir(&plugins_dir).await
|
||||
}
|
||||
|
||||
pub async fn resolve_plugins_for_runtime_from_db(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
let bundled_dirs = match self.list_bundled_plugin_dirs().await {
|
||||
Ok(dirs) => dirs,
|
||||
Err(err) => {
|
||||
warn!("Failed to read bundled plugin dirs for resolution: {err:?}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
self.resolve_plugins_for_runtime(plugins, bundled_dirs)
|
||||
}
|
||||
|
||||
/// Resolve the plugin set for the current runtime instance.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Drop bundled rows that are not present in this instance's bundled directory list.
|
||||
/// - Deduplicate by plugin metadata name (fallback to directory key when metadata is unreadable).
|
||||
/// - Prefer sources in this order: filesystem > registry > bundled.
|
||||
/// - For same-source conflicts, prefer the most recently installed row (`created_at`).
|
||||
fn resolve_plugins_for_runtime(
|
||||
&self,
|
||||
plugins: Vec<Plugin>,
|
||||
bundled_dirs: Vec<String>,
|
||||
) -> Vec<Plugin> {
|
||||
let bundled_dir_set: HashSet<String> = bundled_dirs.into_iter().collect();
|
||||
let mut selected: HashMap<String, Plugin> = HashMap::new();
|
||||
|
||||
for plugin in plugins {
|
||||
if matches!(plugin.source, PluginSource::Bundled)
|
||||
&& !bundled_dir_set.contains(&plugin.directory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = match get_plugin_meta(Path::new(&plugin.directory)) {
|
||||
Ok(meta) => meta.name,
|
||||
Err(_) => format!("__dir__{}", plugin.directory),
|
||||
};
|
||||
|
||||
match selected.get(&key) {
|
||||
Some(existing) if !prefer_plugin(&plugin, existing) => {}
|
||||
_ => {
|
||||
selected.insert(key, plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut resolved = selected.into_values().collect::<Vec<_>>();
|
||||
resolved.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
resolved
|
||||
}
|
||||
|
||||
pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
|
||||
let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?;
|
||||
self.remove_plugin(plugin_context, &plugin).await
|
||||
@@ -287,7 +340,8 @@ impl PluginManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize all plugins from the provided list.
|
||||
/// Initialize all plugins from the provided DB list.
|
||||
/// Plugin candidates are resolved for this runtime instance before initialization.
|
||||
/// Returns a list of (plugin_directory, error_message) for any plugins that failed to initialize.
|
||||
pub async fn initialize_all_plugins(
|
||||
&self,
|
||||
@@ -297,15 +351,18 @@ impl PluginManager {
|
||||
info!("Initializing all plugins");
|
||||
let start = Instant::now();
|
||||
let mut errors = Vec::new();
|
||||
let plugins = self.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
|
||||
// Rebuild runtime handles from scratch to avoid stale/duplicate handles.
|
||||
let existing_handles = { self.plugin_handles.lock().await.clone() };
|
||||
for plugin_handle in existing_handles {
|
||||
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
|
||||
error!("Failed to remove plugin {} {e:?}", plugin_handle.dir);
|
||||
errors.push((plugin_handle.dir.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
for plugin in plugins {
|
||||
// First remove the plugin if it exists and is enabled
|
||||
if let Some(plugin_handle) = self.get_plugin_by_dir(&plugin.directory).await {
|
||||
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
|
||||
error!("Failed to remove plugin {} {e:?}", plugin.directory);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.add_plugin(plugin_context, &plugin).await {
|
||||
warn!("Failed to add plugin {} {e:?}", plugin.directory);
|
||||
errors.push((plugin.directory.clone(), e.to_string()));
|
||||
@@ -1063,6 +1120,24 @@ impl PluginManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn source_priority(source: &PluginSource) -> i32 {
|
||||
match source {
|
||||
PluginSource::Filesystem => 3,
|
||||
PluginSource::Registry => 2,
|
||||
PluginSource::Bundled => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {
|
||||
let candidate_priority = source_priority(&candidate.source);
|
||||
let existing_priority = source_priority(&existing.source);
|
||||
if candidate_priority != existing_priority {
|
||||
return candidate_priority > existing_priority;
|
||||
}
|
||||
|
||||
candidate.created_at > existing.created_at
|
||||
}
|
||||
|
||||
async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
|
||||
let mut result = read_dir(dir).await?;
|
||||
let mut dirs: Vec<String> = vec![];
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
||||
DeleteModelRequest, ErrorResponse, FindHttpResponsesRequest, GetCookieValueRequest,
|
||||
GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse,
|
||||
InternalEventPayload, ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse,
|
||||
ListHttpRequestsRequest, ListHttpRequestsResponse, ListOpenWorkspacesRequest,
|
||||
OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest, PromptTextRequest,
|
||||
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
|
||||
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
|
||||
WindowInfoRequest,
|
||||
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
||||
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
||||
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
|
||||
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
|
||||
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
|
||||
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
|
||||
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
|
||||
};
|
||||
|
||||
pub struct SharedPluginEventContext<'a> {
|
||||
@@ -37,6 +39,9 @@ pub enum SharedRequest<'a> {
|
||||
GetHttpRequestById(&'a GetHttpRequestByIdRequest),
|
||||
ListFolders(&'a ListFoldersRequest),
|
||||
ListHttpRequests(&'a ListHttpRequestsRequest),
|
||||
FindHttpResponses(&'a FindHttpResponsesRequest),
|
||||
UpsertModel(&'a UpsertModelRequest),
|
||||
DeleteModel(&'a DeleteModelRequest),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -45,9 +50,6 @@ pub enum HostRequest<'a> {
|
||||
CopyText(&'a CopyTextRequest),
|
||||
PromptText(&'a PromptTextRequest),
|
||||
PromptForm(&'a PromptFormRequest),
|
||||
FindHttpResponses(&'a FindHttpResponsesRequest),
|
||||
UpsertModel(&'a UpsertModelRequest),
|
||||
DeleteModel(&'a DeleteModelRequest),
|
||||
RenderGrpcRequest(&'a RenderGrpcRequestRequest),
|
||||
RenderHttpRequest(&'a RenderHttpRequestRequest),
|
||||
TemplateRender(&'a TemplateRenderRequest),
|
||||
@@ -71,9 +73,6 @@ impl HostRequest<'_> {
|
||||
HostRequest::CopyText(_) => "copy_text_request".to_string(),
|
||||
HostRequest::PromptText(_) => "prompt_text_request".to_string(),
|
||||
HostRequest::PromptForm(_) => "prompt_form_request".to_string(),
|
||||
HostRequest::FindHttpResponses(_) => "find_http_responses_request".to_string(),
|
||||
HostRequest::UpsertModel(_) => "upsert_model_request".to_string(),
|
||||
HostRequest::DeleteModel(_) => "delete_model_request".to_string(),
|
||||
HostRequest::RenderGrpcRequest(_) => "render_grpc_request_request".to_string(),
|
||||
HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(),
|
||||
HostRequest::TemplateRender(_) => "template_render_request".to_string(),
|
||||
@@ -135,13 +134,13 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
||||
GroupedPluginRequest::Host(HostRequest::PromptForm(req))
|
||||
}
|
||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||
GroupedPluginRequest::Host(HostRequest::FindHttpResponses(req))
|
||||
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
|
||||
}
|
||||
InternalEventPayload::UpsertModelRequest(req) => {
|
||||
GroupedPluginRequest::Host(HostRequest::UpsertModel(req))
|
||||
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
|
||||
}
|
||||
InternalEventPayload::DeleteModelRequest(req) => {
|
||||
GroupedPluginRequest::Host(HostRequest::DeleteModel(req))
|
||||
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
|
||||
}
|
||||
InternalEventPayload::RenderGrpcRequestRequest(req) => {
|
||||
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
|
||||
@@ -275,17 +274,175 @@ fn build_shared_reply(
|
||||
http_requests,
|
||||
})
|
||||
}
|
||||
SharedRequest::FindHttpResponses(req) => {
|
||||
let http_responses = query_manager
|
||||
.connect()
|
||||
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
|
||||
.unwrap_or_default();
|
||||
InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
|
||||
http_responses,
|
||||
})
|
||||
}
|
||||
SharedRequest::UpsertModel(req) => {
|
||||
use AnyModel::*;
|
||||
|
||||
let model = match &req.model {
|
||||
HttpRequest(m) => {
|
||||
match query_manager.connect().upsert_http_request(m, &UpdateSource::Plugin) {
|
||||
Ok(model) => HttpRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to upsert HTTP request: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
GrpcRequest(m) => {
|
||||
match query_manager.connect().upsert_grpc_request(m, &UpdateSource::Plugin) {
|
||||
Ok(model) => GrpcRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to upsert gRPC request: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
WebsocketRequest(m) => {
|
||||
match query_manager.connect().upsert_websocket_request(m, &UpdateSource::Plugin)
|
||||
{
|
||||
Ok(model) => WebsocketRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to upsert WebSocket request: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Folder(m) => {
|
||||
match query_manager.connect().upsert_folder(m, &UpdateSource::Plugin) {
|
||||
Ok(model) => Folder(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to upsert folder: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Environment(m) => {
|
||||
match query_manager.connect().upsert_environment(m, &UpdateSource::Plugin) {
|
||||
Ok(model) => Environment(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to upsert environment: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Workspace(m) => {
|
||||
match query_manager.connect().upsert_workspace(m, &UpdateSource::Plugin) {
|
||||
Ok(model) => Workspace(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to upsert workspace: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "Upsert not supported for this model type".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
InternalEventPayload::UpsertModelResponse(UpsertModelResponse { model })
|
||||
}
|
||||
SharedRequest::DeleteModel(req) => {
|
||||
let model = match req.model.as_str() {
|
||||
"http_request" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_http_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
{
|
||||
Ok(model) => AnyModel::HttpRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to delete HTTP request: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"grpc_request" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
{
|
||||
Ok(model) => AnyModel::GrpcRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to delete gRPC request: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"websocket_request" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)
|
||||
{
|
||||
Ok(model) => AnyModel::WebsocketRequest(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to delete WebSocket request: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"folder" => match query_manager
|
||||
.connect()
|
||||
.delete_folder_by_id(&req.id, &UpdateSource::Plugin)
|
||||
{
|
||||
Ok(model) => AnyModel::Folder(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to delete folder: {err}"),
|
||||
});
|
||||
}
|
||||
},
|
||||
"environment" => {
|
||||
match query_manager
|
||||
.connect()
|
||||
.delete_environment_by_id(&req.id, &UpdateSource::Plugin)
|
||||
{
|
||||
Ok(model) => AnyModel::Environment(model),
|
||||
Err(err) => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to delete environment: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "Delete not supported for this model type".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_models::models::{Folder, HttpRequest, Workspace};
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
fn seed_query_manager() -> QueryManager {
|
||||
let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
|
||||
fn seed_query_manager() -> (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) =
|
||||
@@ -332,12 +489,12 @@ mod tests {
|
||||
)
|
||||
.expect("Failed to seed request");
|
||||
|
||||
query_manager
|
||||
(query_manager, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_requests_requires_workspace_when_folder_missing() {
|
||||
let query_manager = seed_query_manager();
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||
);
|
||||
@@ -355,7 +512,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn list_requests_by_workspace_and_folder() {
|
||||
let query_manager = seed_query_manager();
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
|
||||
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||
@@ -394,9 +551,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_http_responses_is_shared_handled() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::FindHttpResponsesRequest(FindHttpResponsesRequest {
|
||||
request_id: "rq_test".to_string(),
|
||||
limit: Some(1),
|
||||
});
|
||||
|
||||
let result = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
);
|
||||
|
||||
match result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::FindHttpResponsesResponse(
|
||||
resp,
|
||||
))) => {
|
||||
assert!(resp.http_responses.is_empty());
|
||||
}
|
||||
other => panic!("unexpected find responses result: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_delete_model_are_shared_handled() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
|
||||
let existing = query_manager
|
||||
.connect()
|
||||
.get_http_request("rq_test")
|
||||
.expect("Failed to load seeded request");
|
||||
let upsert_payload = InternalEventPayload::UpsertModelRequest(UpsertModelRequest {
|
||||
model: AnyModel::HttpRequest(HttpRequest {
|
||||
name: "Request Updated".to_string(),
|
||||
..existing
|
||||
}),
|
||||
});
|
||||
|
||||
let upsert_result = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&upsert_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
);
|
||||
match upsert_result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::UpsertModelResponse(resp))) => {
|
||||
match resp.model {
|
||||
AnyModel::HttpRequest(r) => assert_eq!(r.name, "Request Updated"),
|
||||
other => panic!("unexpected upsert model type: {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("unexpected upsert result: {other:?}"),
|
||||
}
|
||||
|
||||
let delete_payload = InternalEventPayload::DeleteModelRequest(DeleteModelRequest {
|
||||
model: "http_request".to_string(),
|
||||
id: "rq_test".to_string(),
|
||||
});
|
||||
let delete_result = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&delete_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
);
|
||||
match delete_result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::DeleteModelResponse(resp))) => {
|
||||
match resp.model {
|
||||
AnyModel::HttpRequest(r) => assert_eq!(r.id, "rq_test"),
|
||||
other => panic!("unexpected delete model type: {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("unexpected delete result: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_request_classification_works() {
|
||||
let query_manager = seed_query_manager();
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
||||
label: "main".to_string(),
|
||||
});
|
||||
|
||||
@@ -16,7 +16,8 @@ function getBinaryPath() {
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(getBinaryPath(), process.argv.slice(2), {
|
||||
stdio: "inherit"
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? "npm" },
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ function getBinaryPath() {
|
||||
|
||||
module.exports.runBinary = function runBinary(...args) {
|
||||
childProcess.execFileSync(getBinaryPath(), args, {
|
||||
stdio: "inherit"
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? "npm" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -76,10 +76,10 @@ export class PluginInstance {
|
||||
this.#mod = {};
|
||||
|
||||
const fileChangeCallback = async () => {
|
||||
await this.#mod?.dispose?.();
|
||||
this.#importModule();
|
||||
const ctx = this.#newCtx(workerData.context);
|
||||
try {
|
||||
await this.#mod?.dispose?.();
|
||||
this.#importModule();
|
||||
await this.#mod?.init?.(ctx);
|
||||
this.#sendPayload(
|
||||
workerData.context,
|
||||
@@ -90,7 +90,7 @@ export class PluginInstance {
|
||||
null,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
ctx.toast.show({
|
||||
await ctx.toast.show({
|
||||
message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split('/').pop()}: ${err}`,
|
||||
color: 'notice',
|
||||
icon: 'alert_triangle',
|
||||
@@ -1003,6 +1003,7 @@ function watchFile(filepath: string, cb: () => void) {
|
||||
const stat = statSync(filepath, { throwIfNoEntry: false });
|
||||
if (stat == null || stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
|
||||
watchedFiles[filepath] = stat ?? null;
|
||||
console.log('[plugin-runtime] watchFile triggered', filepath);
|
||||
cb();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export const plugin: PluginDefinition = {
|
||||
async onSelect(ctx, args) {
|
||||
const rendered_request = await ctx.grpcRequest.render({
|
||||
grpcRequest: args.grpcRequest,
|
||||
purpose: 'preview',
|
||||
purpose: 'send',
|
||||
});
|
||||
const data = await convert(rendered_request, args.protoFiles);
|
||||
await ctx.clipboard.copyText(data);
|
||||
@@ -103,7 +103,7 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
|
||||
|
||||
// Add form params
|
||||
if (request.message) {
|
||||
xs.push('-d', `${quote(JSON.stringify(JSON.parse(request.message)))}`);
|
||||
xs.push('-d', quote(request.message));
|
||||
xs.push(NEWLINE);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,26 @@ describe('exporter-curl', () => {
|
||||
[
|
||||
`grpcurl -import-path '/'`,
|
||||
`-proto '/foo.proto'`,
|
||||
`-d '{"foo":"bar","baz":1}'`,
|
||||
`-d '{\n "foo": "bar",\n "baz": 1\n}'`,
|
||||
'yaak.app',
|
||||
].join(' \\\n '),
|
||||
);
|
||||
});
|
||||
|
||||
test('Sends data with unresolved template tags', async () => {
|
||||
expect(
|
||||
await convert(
|
||||
{
|
||||
url: 'https://yaak.app',
|
||||
message: '{"timestamp": ${[ faker "timestamp" ]}, "foo": "bar"}',
|
||||
},
|
||||
['/foo.proto'],
|
||||
),
|
||||
).toEqual(
|
||||
[
|
||||
`grpcurl -import-path '/'`,
|
||||
`-proto '/foo.proto'`,
|
||||
`-d '{"timestamp": \${[ faker "timestamp" ]}, "foo": "bar"}'`,
|
||||
'yaak.app',
|
||||
].join(' \\\n '),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"name": "@yaak/template-function-timestamp",
|
||||
"displayName": "Timestamp Template Functions",
|
||||
"description": "Template functions for dealing with timestamps",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
||||
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
|
||||
import { usePluginInfo } from '../../hooks/usePluginInfo';
|
||||
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { showConfirmDelete } from '../../lib/confirm';
|
||||
import { minPromiseMillis } from '../../lib/minPromiseMillis';
|
||||
import { Button } from '../core/Button';
|
||||
@@ -33,16 +32,6 @@ import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
||||
import { EmptyStateText } from '../EmptyStateText';
|
||||
import { SelectFile } from '../SelectFile';
|
||||
|
||||
function isPluginBundled(plugin: Plugin, vendoredPluginDir: string): boolean {
|
||||
const normalizedDir = plugin.directory.replace(/\\/g, '/');
|
||||
const normalizedVendoredDir = vendoredPluginDir.replace(/\\/g, '/');
|
||||
return (
|
||||
normalizedDir.includes(normalizedVendoredDir) ||
|
||||
normalizedDir.includes('vendored/plugins') ||
|
||||
normalizedDir.includes('/plugins/')
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsPluginsProps {
|
||||
defaultSubtab?: string;
|
||||
}
|
||||
@@ -50,8 +39,8 @@ interface SettingsPluginsProps {
|
||||
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||
const [directory, setDirectory] = useState<string | null>(null);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const bundledPlugins = plugins.filter((p) => isPluginBundled(p, appInfo.vendoredPluginDir));
|
||||
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
|
||||
const bundledPlugins = plugins.filter((p) => p.source === 'bundled');
|
||||
const installedPlugins = plugins.filter((p) => p.source !== 'bundled');
|
||||
const createPlugin = useInstallPlugin();
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
return (
|
||||
|
||||
@@ -896,9 +896,9 @@ function MenuItem({
|
||||
};
|
||||
|
||||
const rightSlot = item.submenu ? (
|
||||
<Icon icon="chevron_right" color='secondary' />
|
||||
<Icon icon="chevron_right" color="secondary" />
|
||||
) : (
|
||||
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
|
||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -937,7 +937,7 @@ function MenuItem({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={classNames('truncate')}>{item.label}</div>
|
||||
<div className={classNames('truncate min-w-[5rem]')}>{item.label}</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { installPluginFromDirectory } from '@yaakapp-internal/plugins';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useInstallPlugin() {
|
||||
return useFastMutation<void, unknown, string>({
|
||||
mutationKey: ['install_plugin'],
|
||||
mutationFn: async (directory: string) => {
|
||||
await invokeCmd('cmd_install_plugin', { directory });
|
||||
await installPluginFromDirectory(directory);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ type TauriCmd =
|
||||
| 'cmd_http_request_body'
|
||||
| 'cmd_http_response_body'
|
||||
| 'cmd_import_data'
|
||||
| 'cmd_install_plugin'
|
||||
| 'cmd_metadata'
|
||||
| 'cmd_restart'
|
||||
| 'cmd_new_child_window'
|
||||
|
||||
Reference in New Issue
Block a user