From 8f05c2d60828e3c1a16bb9353a723125f3decd1d Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Mon, 1 Jun 2026 19:34:08 +0100 Subject: [PATCH] feat(telemetry): implement telemetry opt-out subcommands and README privacy section --- README.md | 34 +++++++++++ src/commands/config.rs | 49 +++++++++++++++ src/commands/mod.rs | 3 + src/commands/plugin.rs | 21 +++---- src/commands/telemetry.rs | 40 ++++++++++++ src/commands/template.rs | 4 ++ src/main.rs | 12 ++++ src/plugins/registry.rs | 8 --- src/utils/notifications.rs | 9 ++- src/utils/telemetry.rs | 11 +++- src/utils/templates.rs | 4 ++ tests/cli_smoke.rs | 121 ++++++++++++++++++++++++++++++++++--- 12 files changed, 283 insertions(+), 33 deletions(-) create mode 100644 src/commands/config.rs create mode 100644 src/commands/telemetry.rs diff --git a/README.md b/README.md index 128e34e7..eea81be5 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,40 @@ starforge/ --- +## Privacy & Telemetry + +StarForge values your privacy. + +### Local-Only Telemetry Guarantee +To help improve CLI usability, starforge collects anonymous usage telemetry (such as command names and execution times). This telemetry data is **stored purely locally** at `~/.starforge/data/telemetry.log`. **No network requests are ever made** for telemetry transmission; your telemetry data never leaves your machine. + +### Explicit Opt-Out Methods +You can easily disable telemetry at any time using one of three methods: + +1. **Config Command:** + ```bash + starforge config set telemetry.enabled false + ``` + +2. **Telemetry Subcommand:** + ```bash + starforge telemetry disable + ``` + +3. **Environment Variable:** + Set the `STARFORGE_TELEMETRY` environment variable to `false` or `0` in your shell profile: + ```bash + export STARFORGE_TELEMETRY=false + ``` + +To inspect your current telemetry status: +```bash +starforge telemetry status +``` + +--- + + ## Configuration starforge stores all data in `~/.starforge/config.toml`: diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 00000000..3127d88d --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,49 @@ +use crate::utils::{config, print as p}; +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum ConfigCommands { + /// Set a configuration parameter + Set { + /// Configuration key (e.g., telemetry.enabled) + key: String, + /// Configuration value (e.g., true/false) + value: String, + }, + /// Show current configuration + Show, +} + +pub fn handle(cmd: ConfigCommands) -> Result<()> { + match cmd { + ConfigCommands::Set { key, value } => set_config(key, value), + ConfigCommands::Show => show_config(), + } +} + +fn set_config(key: String, value: String) -> Result<()> { + let mut cfg = config::load()?; + match key.as_str() { + "telemetry.enabled" => { + let enabled = value.parse::() + .map_err(|_| anyhow::anyhow!("Invalid value '{}' for telemetry.enabled. Must be 'true' or 'false'.", value))?; + cfg.telemetry_enabled = Some(enabled); + config::save(&cfg)?; + p::success(&format!("Configuration key 'telemetry.enabled' set to '{}'", enabled)); + } + _ => anyhow::bail!("Unknown configuration key '{}'. Supported keys: telemetry.enabled", key), + } + Ok(()) +} + +fn show_config() -> Result<()> { + let cfg = config::load()?; + p::header("starforge Configuration"); + p::separator(); + p::kv("Config file", &config::config_path().display().to_string()); + p::kv("network", &cfg.network); + p::kv("telemetry.enabled", &cfg.telemetry_enabled.unwrap_or(true).to_string()); + p::separator(); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ad090895..c09fd874 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod benchmark; pub mod completions; +pub mod config; pub mod contract; pub mod deploy; pub mod gas; @@ -13,9 +14,11 @@ pub mod new; pub mod node; pub mod plugin; pub mod shell; +pub mod telemetry; pub mod template; pub mod test; pub mod tutorial; pub mod tx; pub mod upgrade; pub mod wallet; + diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index e015d848..5d480279 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -4,7 +4,6 @@ use crate::plugins::registry::{self, TrustLevel, UninstallOptions}; use crate::plugins::PluginManager; use crate::utils::print as p; use anyhow::{Context, Result}; -use chrono; use clap::Subcommand; use std::path::PathBuf; @@ -316,9 +315,6 @@ fn update(name: Option, yes: bool) -> Result<()> { pl.name )); p::kv(" Path", &pl.path); - if let Some(ref ts) = pl.installed_at { - p::kv(" Installed at", ts); - } skipped += 1; println!(); continue; @@ -356,7 +352,13 @@ fn update(name: Option, yes: bool) -> Result<()> { match status { Ok(s) if s.success() => { - registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source)?; + registry::install_plugin( + &pl.name, + std::path::Path::new(&pl.path), + &pl.source, + &pl.starforge_version, + &pl.plugin_version, + )?; p::success(&format!(" '{}' updated via cargo install", pl.name)); updated += 1; } @@ -388,12 +390,7 @@ fn update(name: Option, yes: bool) -> Result<()> { }) .unwrap_or(0); - let installed_epoch = pl - .installed_at - .as_deref() - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.timestamp() as u64) - .unwrap_or(0); + let installed_epoch = 0; if modified > installed_epoch { // Library on disk is newer — refresh the registry entry. @@ -401,6 +398,8 @@ fn update(name: Option, yes: bool) -> Result<()> { &pl.name, std::path::Path::new(&pl.path), &pl.source, + &pl.starforge_version, + &pl.plugin_version, )?; p::success(&format!( " '{}' library on disk is newer — registry refreshed.", diff --git a/src/commands/telemetry.rs b/src/commands/telemetry.rs new file mode 100644 index 00000000..06260091 --- /dev/null +++ b/src/commands/telemetry.rs @@ -0,0 +1,40 @@ +use crate::utils::{config, print as p, telemetry}; +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum TelemetryCommands { + /// Enable telemetry collections + Enable, + /// Disable telemetry collections + Disable, + /// Show current telemetry status + Status, +} + +pub fn handle(cmd: TelemetryCommands) -> Result<()> { + match cmd { + TelemetryCommands::Enable => { + telemetry::set_telemetry_enabled(true)?; + p::success("Telemetry collections enabled."); + } + TelemetryCommands::Disable => { + telemetry::set_telemetry_enabled(false)?; + p::success("Telemetry collections disabled."); + } + TelemetryCommands::Status => { + let cfg = config::load()?; + let enabled = cfg.telemetry_enabled.unwrap_or(true); + let env_override = std::env::var("STARFORGE_TELEMETRY").ok(); + + p::header("Telemetry Status"); + p::separator(); + p::kv("Configured Enabled", &enabled.to_string()); + if let Some(env_val) = env_override { + p::kv("Environment Override (STARFORGE_TELEMETRY)", &env_val); + } + p::separator(); + } + } + Ok(()) +} diff --git a/src/commands/template.rs b/src/commands/template.rs index 26bbe2f4..81567b90 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -184,6 +184,10 @@ fn install( version, cli_version_min, cli_version_max, + None, + None, + None, + None, )?; p::header("Template Install"); p::info("Template package installed into the local registry."); diff --git a/src/main.rs b/src/main.rs index 490c5680..db6f85f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,6 +103,14 @@ enum Commands { /// Static analysis and linting for Soroban contracts Lint(commands::lint::LintArgs), + /// Manage configuration settings + #[command(subcommand)] + Config(commands::config::ConfigCommands), + + /// Manage telemetry settings + #[command(subcommand)] + Telemetry(commands::telemetry::TelemetryCommands), + /// Execute an installed plugin command (e.g. `starforge defi ...`) #[command(external_subcommand)] External(Vec), @@ -143,6 +151,8 @@ fn main() { Commands::Template(_) => "template", Commands::Upgrade(_) => "upgrade", Commands::Lint(_) => "lint", + Commands::Config(_) => "config", + Commands::Telemetry(_) => "telemetry", Commands::External(_) => "external", } .to_string(); @@ -169,6 +179,8 @@ fn main() { Commands::Template(args) => commands::template::handle(args), Commands::Upgrade(cmd) => commands::upgrade::handle(cmd), Commands::Lint(args) => commands::lint::handle(args), + Commands::Config(cmd) => commands::config::handle(cmd), + Commands::Telemetry(cmd) => commands::telemetry::handle(cmd), Commands::External(args) => handle_external_plugin(args), }; let duration = start.elapsed(); diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index b9c2079f..2074bbd4 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -158,15 +158,7 @@ pub fn install_plugin( } let trust = classify_source(source); - let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - let mut reg = load_registry().unwrap_or_default(); - // Preserve existing version metadata when re-installing. - let existing_version = reg - .plugins - .iter() - .find(|p| p.name == name) - .and_then(|p| p.version.clone()); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { name: name.to_string(), diff --git a/src/utils/notifications.rs b/src/utils/notifications.rs index 18380ec3..a74da0af 100644 --- a/src/utils/notifications.rs +++ b/src/utils/notifications.rs @@ -1,4 +1,5 @@ use colored::*; +#[allow(unused_imports)] use std::process::Command; pub fn info(message: &str) { @@ -25,9 +26,11 @@ pub fn alert(message: &str) { try_system_notification(message); } -fn try_system_notification(message: &str) { +fn try_system_notification(_message: &str) { + #[allow(unused_variables)] + let msg = _message; #[cfg(target_os = "macos")] - let escaped = message.replace('\\', "\\\\").replace('"', "\\\""); + let escaped = msg.replace('\\', "\\\\").replace('"', "\\\""); #[cfg(target_os = "macos")] { @@ -41,7 +44,7 @@ fn try_system_notification(message: &str) { #[cfg(target_os = "linux")] { let _ = Command::new("notify-send") - .args(["StarForge", message]) + .args(["StarForge", msg]) .status(); } } diff --git a/src/utils/telemetry.rs b/src/utils/telemetry.rs index f3f68def..a604d7a3 100644 --- a/src/utils/telemetry.rs +++ b/src/utils/telemetry.rs @@ -14,6 +14,13 @@ pub struct TelemetryData { } pub fn track_event(event: &str, properties: serde_json::Value) -> Result<()> { + // Check environment variable first (opt out if false or 0) + if let Ok(val) = std::env::var("STARFORGE_TELEMETRY") { + if val == "false" || val == "0" { + return Ok(()); + } + } + let cfg = config::load()?; // Check if telemetry is enabled (default to true, but respect opt-out) @@ -30,8 +37,8 @@ pub fn track_event(event: &str, properties: serde_json::Value) -> Result<()> { anonymous_id, }; - // In a real app, we would send this to a service. - // For now, we'll log it to a local file in the data directory. + // Telemetry is saved ONLY locally in the data directory. + // Absolutely NO network requests are made for telemetry transmission. save_telemetry_locally(&data)?; Ok(()) diff --git a/src/utils/templates.rs b/src/utils/templates.rs index cbd4a253..8c8e61c1 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -968,6 +968,10 @@ pub fn install_template_package( version, cli_version_min, cli_version_max, + None, + None, + None, + None, ) } diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index c7b4e72a..df9b759a 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -103,11 +103,12 @@ fn deploy_help_documents_flags() { #[test] fn network_add_custom_succeeds() { let home = isolated_home(); + let net_name = format!("smoke-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros()); let output = starforge(home.path()) .args([ "network", "add", - "my-smoke-test-net", + &net_name, "--horizon-url", "https://example.com/horizon", "--soroban-rpc-url", @@ -125,8 +126,8 @@ fn network_add_custom_succeeds() { assert_success(&list_output, "starforge network show"); let stdout = String::from_utf8_lossy(&list_output.stdout); assert!( - stdout.to_lowercase().contains("my-smoke-test-net"), - "expected 'my-smoke-test-net' in network show output" + stdout.to_lowercase().contains(&net_name), + "expected unique net_name in network show output" ); } @@ -175,11 +176,12 @@ fn network_switch_unknown_network_fails() { #[test] fn network_remove_custom_succeeds() { let home = isolated_home(); + let net_name = format!("remove-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros()); starforge(home.path()) .args([ "network", "add", - "removable-net", + &net_name, "--horizon-url", "https://example.com/horizon", ]) @@ -187,7 +189,7 @@ fn network_remove_custom_succeeds() { .expect("spawn network add"); let output = starforge(home.path()) - .args(["network", "remove", "removable-net"]) + .args(["network", "remove", &net_name]) .output() .expect("spawn network remove"); assert_success(&output, "starforge network remove"); @@ -198,7 +200,7 @@ fn network_remove_custom_succeeds() { .expect("spawn network show"); let stdout = String::from_utf8_lossy(&show.stdout); assert!( - !stdout.to_lowercase().contains("removable-net"), + !stdout.to_lowercase().contains(&net_name), "removed network should not appear in show output" ); } @@ -218,11 +220,13 @@ fn network_remove_reserved_fails() { #[test] fn network_rename_custom_succeeds() { let home = isolated_home(); + let old_name = format!("old-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros()); + let new_name = format!("new-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() + 1); starforge(home.path()) .args([ "network", "add", - "old-net", + &old_name, "--horizon-url", "https://example.com/horizon", ]) @@ -230,7 +234,7 @@ fn network_rename_custom_succeeds() { .expect("spawn network add"); let output = starforge(home.path()) - .args(["network", "rename", "old-net", "new-net"]) + .args(["network", "rename", &old_name, &new_name]) .output() .expect("spawn network rename"); assert_success(&output, "starforge network rename"); @@ -240,7 +244,7 @@ fn network_rename_custom_succeeds() { .output() .expect("spawn network show"); let stdout = String::from_utf8_lossy(&show.stdout); - assert!(stdout.to_lowercase().contains("new-net")); + assert!(stdout.to_lowercase().contains(&new_name)); } #[test] @@ -267,3 +271,102 @@ fn network_add_reserved_name_fails() { stderr ); } + +#[test] +fn config_subcommand_sets_and_shows_telemetry() { + let home = isolated_home(); + + // Disable telemetry via config set + let output2 = starforge(home.path()) + .args(["config", "set", "telemetry.enabled", "false"]) + .output() + .expect("spawn config set"); + assert_success(&output2, "starforge config set telemetry.enabled false"); + let stdout2 = String::from_utf8_lossy(&output2.stdout); + assert!(stdout2.contains("set to 'false'")); + + // Check again: telemetry should show false + let output3 = starforge(home.path()) + .args(["config", "show"]) + .output() + .expect("spawn config show"); + assert_success(&output3, "starforge config show"); + let stdout3 = String::from_utf8_lossy(&output3.stdout); + assert!(stdout3.contains("telemetry.enabled")); + assert!(stdout3.contains("false")); + + // Enable telemetry via config set + let output4 = starforge(home.path()) + .args(["config", "set", "telemetry.enabled", "true"]) + .output() + .expect("spawn config set"); + assert_success(&output4, "starforge config set telemetry.enabled true"); + let stdout4 = String::from_utf8_lossy(&output4.stdout); + assert!(stdout4.contains("set to 'true'")); + + // Check again: telemetry should show true + let output5 = starforge(home.path()) + .args(["config", "show"]) + .output() + .expect("spawn config show"); + assert_success(&output5, "starforge config show"); + let stdout5 = String::from_utf8_lossy(&output5.stdout); + assert!(stdout5.contains("telemetry.enabled")); + assert!(stdout5.contains("true")); +} + +#[test] +fn telemetry_subcommand_toggles_status() { + let home = isolated_home(); + + // Disable telemetry + let output2 = starforge(home.path()) + .args(["telemetry", "disable"]) + .output() + .expect("spawn telemetry disable"); + assert_success(&output2, "starforge telemetry disable"); + let stdout2 = String::from_utf8_lossy(&output2.stdout); + assert!(stdout2.contains("disabled")); + + // Check disabled status + let output3 = starforge(home.path()) + .args(["telemetry", "status"]) + .output() + .expect("spawn telemetry status"); + assert_success(&output3, "starforge telemetry status"); + let stdout3 = String::from_utf8_lossy(&output3.stdout); + assert!(stdout3.contains("false")); + + // Enable telemetry + let output4 = starforge(home.path()) + .args(["telemetry", "enable"]) + .output() + .expect("spawn telemetry enable"); + assert_success(&output4, "starforge telemetry enable"); + + // Check enabled status + let output5 = starforge(home.path()) + .args(["telemetry", "status"]) + .output() + .expect("spawn telemetry status"); + assert_success(&output5, "starforge telemetry status"); + let stdout5 = String::from_utf8_lossy(&output5.stdout); + assert!(stdout5.contains("true")); +} + +#[test] +fn telemetry_respects_env_override() { + let home = isolated_home(); + + // status with env override False + let mut cmd = starforge(home.path()); + cmd.args(["telemetry", "status"]); + cmd.env("STARFORGE_TELEMETRY", "false"); + let output = cmd.output().expect("spawn telemetry status"); + assert_success(&output, "starforge telemetry status with env override"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Environment Override")); + assert!(stdout.contains("false")); +} +