Compare commits

...

3 Commits

Author SHA1 Message Date
Gregory Schier
fbf0473b20 Fix plugin dev rebuild signaling and reload toast timing 2026-03-02 07:02:28 -08:00
Gregory Schier
876b7ef454 Add top-level generate and publish CLI aliases 2026-03-02 06:28:04 -08:00
Gregory Schier
96e8572758 Add CLI update check and API client kind identity 2026-03-02 06:21:00 -08:00
18 changed files with 339 additions and 37 deletions

1
Cargo.lock generated
View File

@@ -10166,6 +10166,7 @@ dependencies = [
"walkdir",
"webbrowser",
"yaak",
"yaak-api",
"yaak-crypto",
"yaak-http",
"yaak-models",

View File

@@ -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 }

View File

@@ -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),

View File

@@ -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(())

View File

@@ -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(())
}

View File

@@ -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"),

View File

@@ -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());

View 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()
}

View File

@@ -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\""));
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -21,7 +21,7 @@ use tauri::{
};
use tokio::sync::Mutex;
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::models::{Plugin, PluginSource};
use yaak_models::util::UpdateSource;
use yaak_plugins::api::{
@@ -73,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?;
@@ -138,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?)
}
@@ -150,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(
@@ -203,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?)
}
@@ -213,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)

View File

@@ -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()

View File

@@ -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?;

View File

@@ -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());

View File

@@ -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) {

View File

@@ -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" },
});
};

View File

@@ -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();
}
});