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 90bc0298..5f7670ee 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod benchmark; pub mod command_tree; pub mod completions; +pub mod config; pub mod contract; pub mod deploy; pub mod gas; @@ -14,9 +15,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 a550d640..44a6660a 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -381,10 +381,13 @@ fn update(name: Option, yes: bool) -> Result<()> { match status { Ok(s) if s.success() => { - // Re-discover commands from the updated library. - let cmds = discover_commands_from_library(&pl.path) - .unwrap_or_else(|_| pl.commands.clone()); - registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source, &pl.starforge_version, &pl.plugin_version, cmds)?; + 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; } @@ -416,7 +419,7 @@ fn update(name: Option, yes: bool) -> Result<()> { }) .unwrap_or(0); - let installed_epoch = 0u64; // no install timestamp stored; treat as always-stale + let installed_epoch = 0; if modified > installed_epoch { // Library on disk is newer — refresh the registry entry. @@ -428,7 +431,6 @@ fn update(name: Option, yes: bool) -> Result<()> { &pl.source, &pl.starforge_version, &pl.plugin_version, - cmds, )?; 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 7d59e040..4039e85b 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -220,6 +220,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 25d10702..79aac574 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,9 +103,13 @@ enum Commands { /// Static analysis and linting for Soroban contracts Lint(commands::lint::LintArgs), - /// Display the full command tree (all top-level commands, subcommands, and plugin extensions) - #[command(name = "commands")] - CommandTree, + /// 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)] @@ -147,7 +151,8 @@ fn main() { Commands::Template(_) => "template", Commands::Upgrade(_) => "upgrade", Commands::Lint(_) => "lint", - Commands::CommandTree => "commands", + Commands::Config(_) => "config", + Commands::Telemetry(_) => "telemetry", Commands::External(_) => "external", } .to_string(); @@ -174,7 +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::CommandTree => commands::command_tree::handle(), + 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 bdecc70d..07b5b926 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -170,7 +170,6 @@ pub fn install_plugin( } let trust = classify_source(source); - let mut reg = load_registry().unwrap_or_default(); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { 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 de978bc9..38e38788 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -962,6 +962,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")); +} +