From 96e857275805e6751faccf54d40ba8e93057bfab Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 2 Mar 2026 06:21:00 -0800 Subject: [PATCH] Add CLI update check and API client kind identity --- Cargo.lock | 1 + crates-cli/yaak-cli/Cargo.toml | 1 + crates-cli/yaak-cli/src/commands/workspace.rs | 15 +- crates-cli/yaak-cli/src/main.rs | 3 + crates-cli/yaak-cli/src/ui.rs | 8 + crates-cli/yaak-cli/src/version_check.rs | 227 ++++++++++++++++++ .../yaak-cli/tests/workspace_commands.rs | 4 +- crates-tauri/yaak-app/src/notifications.rs | 4 +- crates-tauri/yaak-app/src/plugins_ext.rs | 12 +- crates-tauri/yaak-app/src/uri_scheme.rs | 7 +- crates-tauri/yaak-license/src/license.rs | 8 +- crates/yaak-api/src/lib.rs | 14 +- npm/cli/bin/cli.js | 3 +- npm/cli/index.js | 3 +- 14 files changed, 280 insertions(+), 30 deletions(-) create mode 100644 crates-cli/yaak-cli/src/version_check.rs diff --git a/Cargo.lock b/Cargo.lock index 374dc0be..929908b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10166,6 +10166,7 @@ dependencies = [ "walkdir", "webbrowser", "yaak", + "yaak-api", "yaak-crypto", "yaak-http", "yaak-models", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index 17da495f..a21f280c 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -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 } diff --git a/crates-cli/yaak-cli/src/commands/workspace.rs b/crates-cli/yaak-cli/src/commands/workspace.rs index 76d85316..38bfcc01 100644 --- a/crates-cli/yaak-cli/src/commands/workspace.rs +++ b/crates-cli/yaak-cli/src/commands/workspace.rs @@ -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(()) } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index 931109c5..bce6a577 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -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(_) diff --git a/crates-cli/yaak-cli/src/ui.rs b/crates-cli/yaak-cli/src/ui.rs index 18a48bb1..dba33693 100644 --- a/crates-cli/yaak-cli/src/ui.rs +++ b/crates-cli/yaak-cli/src/ui.rs @@ -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()); diff --git a/crates-cli/yaak-cli/src/version_check.rs b/crates-cli/yaak-cli/src/version_check.rs new file mode 100644 index 00000000..dbc82346 --- /dev/null +++ b/crates-cli/yaak-cli/src/version_check.rs @@ -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, + upgrade_hint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct CacheRecord { + checked_at_epoch_secs: u64, + response: VersionCheckResponse, + last_warned_at_epoch_secs: Option, + last_warned_version: Option, +} + +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 { + 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::()).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 { + let contents = fs::read_to_string(path).ok()?; + serde_json::from_str::(&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() +} diff --git a/crates-cli/yaak-cli/tests/workspace_commands.rs b/crates-cli/yaak-cli/tests/workspace_commands.rs index 995532e2..c2bb6408 100644 --- a/crates-cli/yaak-cli/tests/workspace_commands.rs +++ b/crates-cli/yaak-cli/tests/workspace_commands.rs @@ -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\"")); } diff --git a/crates-tauri/yaak-app/src/notifications.rs b/crates-tauri/yaak-app/src/notifications.rs index b75c844e..52b0eca9 100644 --- a/crates-tauri/yaak-app/src/notifications.rs +++ b/crates-tauri/yaak-app/src/notifications.rs @@ -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), diff --git a/crates-tauri/yaak-app/src/plugins_ext.rs b/crates-tauri/yaak-app/src/plugins_ext.rs index ca23b10c..143ae769 100644 --- a/crates-tauri/yaak-app/src/plugins_ext.rs +++ b/crates-tauri/yaak-app/src/plugins_ext.rs @@ -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( query: &str, ) -> Result { 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( ) -> Result<()> { let plugin_manager = Arc::new((*window.state::()).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::(); let plugin_context = window.plugin_context(); download_and_install( @@ -203,7 +203,7 @@ pub async fn cmd_plugins_updates( app_handle: AppHandle, ) -> Result { 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( window: WebviewWindow, ) -> Result> { 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) diff --git a/crates-tauri/yaak-app/src/uri_scheme.rs b/crates-tauri/yaak-app/src/uri_scheme.rs index a9ecd182..d540afff 100644 --- a/crates-tauri/yaak-app/src/uri_scheme.rs +++ b/crates-tauri/yaak-app/src/uri_scheme.rs @@ -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( let plugin_manager = Arc::new((*window.state::()).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( } 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() diff --git a/crates-tauri/yaak-license/src/license.rs b/crates-tauri/yaak-license/src/license.rs index 93aeb200..f0732b8a 100644 --- a/crates-tauri/yaak-license/src/license.rs +++ b/crates-tauri/yaak-license/src/license.rs @@ -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( ) -> 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(window: &WebviewWindow) -> 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(window: &WebviewWindow) -> Result { 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?; diff --git a/crates/yaak-api/src/lib.rs b/crates/yaak-api/src/lib.rs index 6e18de19..b7881b0f 100644 --- a/crates/yaak-api/src/lib.rs +++ b/crates/yaak-api/src/lib.rs @@ -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 { +pub fn yaak_api_client(kind: ApiClientKind, version: &str) -> Result { 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()); diff --git a/npm/cli/bin/cli.js b/npm/cli/bin/cli.js index e0fff298..05275256 100755 --- a/npm/cli/bin/cli.js +++ b/npm/cli/bin/cli.js @@ -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) { diff --git a/npm/cli/index.js b/npm/cli/index.js index 888c76ca..ae8f8663 100644 --- a/npm/cli/index.js +++ b/npm/cli/index.js @@ -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" }, }); };