diff --git a/fuelup-init.sh b/fuelup-init.sh index 4d841ad78..72f8b4611 100755 --- a/fuelup-init.sh +++ b/fuelup-init.sh @@ -149,6 +149,9 @@ main() { add_path_message fi + # Prompt for telemetry + prompt_telemetry + return "$_retval" } @@ -182,6 +185,44 @@ fish_add_path ~/.fuelup/bin EOF } +prompt_telemetry() { + # Skip if the file already exists (user might have already been prompted) + if [ -f "$FUELUP_DIR/.telemetry_opt_in" ]; then + return + fi + + # Check if running in a non-interactive environment (CI/CD, Docker, etc.) + # If no terminal is available, default to telemetry disabled + if [ ! -t 0 ] && [ ! -t 1 ]; then + printf "0" >"$FUELUP_DIR/.telemetry_opt_in" + return + fi + + cat 1>&2 <"$FUELUP_DIR/.telemetry_opt_in" + printf "\nTelemetry disabled. You can enable it later with 'fuelup telemetry enable'.\n\n" + ;; + *) + printf "1" >"$FUELUP_DIR/.telemetry_opt_in" + printf "\nTelemetry enabled. Thank you for helping improve the Fuel toolchain!\n" + printf "You can disable it at any time with 'fuelup telemetry disable'.\n\n" + ;; + esac +} + get_architecture() { local _ostype _cputype _ostype="$(uname -s)" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5db91936c..a8335deea 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,5 +3,6 @@ pub mod completions; pub mod component; pub mod default; pub mod fuelup; +pub mod telemetry; pub mod toolchain; pub mod upgrade; diff --git a/src/commands/telemetry.rs b/src/commands/telemetry.rs new file mode 100644 index 000000000..f16f8fc75 --- /dev/null +++ b/src/commands/telemetry.rs @@ -0,0 +1,87 @@ +use crate::fmt::{bold, colored_bold}; +use crate::telemetry::{get_telemetry_status, set_telemetry_status, TelemetryStatus}; +use anyhow::Result; +use clap::Parser; +use tracing::info; + +#[derive(Debug, Parser)] +pub enum TelemetryCommand { + /// Show the current telemetry status + Status, + /// Enable telemetry (opt-in) + Enable, + /// Disable telemetry (opt-out) + Disable, +} + +pub fn exec(command: TelemetryCommand) -> Result<()> { + match command { + TelemetryCommand::Status => show_status(), + TelemetryCommand::Enable => enable(), + TelemetryCommand::Disable => disable(), + } +} + +fn show_status() -> Result<()> { + match get_telemetry_status()? { + Some(TelemetryStatus::Enabled) => { + info!( + "Telemetry is {}", + colored_bold(ansiterm::Color::Green, "enabled") + ); + info!(""); + info!("Telemetry helps improve the Fuel toolchain by collecting usage data."); + info!("To disable, run: {}", bold("fuelup telemetry disable")); + } + Some(TelemetryStatus::Disabled) => { + info!( + "Telemetry is {}", + colored_bold(ansiterm::Color::Yellow, "disabled") + ); + info!(""); + info!("Telemetry is currently disabled. No usage data will be collected."); + info!("To enable, run: {}", bold("fuelup telemetry enable")); + } + None => { + info!( + "Telemetry status is {}", + colored_bold(ansiterm::Color::Yellow, "not set") + ); + info!(""); + info!("Telemetry preference has not been set. It will be disabled by default."); + info!("To enable, run: {}", bold("fuelup telemetry enable")); + info!("To disable, run: {}", bold("fuelup telemetry disable")); + } + } + Ok(()) +} + +fn enable() -> Result<()> { + set_telemetry_status(TelemetryStatus::Enabled)?; + info!( + "Telemetry has been {}", + colored_bold(ansiterm::Color::Green, "enabled") + ); + info!(""); + info!("Thank you for helping improve the Fuel toolchain!"); + info!( + "To disable at any time, run: {}", + bold("fuelup telemetry disable") + ); + Ok(()) +} + +fn disable() -> Result<()> { + set_telemetry_status(TelemetryStatus::Disabled)?; + info!( + "Telemetry has been {}", + colored_bold(ansiterm::Color::Yellow, "disabled") + ); + info!(""); + info!("Telemetry will no longer collect usage data."); + info!( + "To enable at any time, run: {}", + bold("fuelup telemetry enable") + ); + Ok(()) +} diff --git a/src/fuelup_cli.rs b/src/fuelup_cli.rs index 4c97fc7f6..5b134399f 100644 --- a/src/fuelup_cli.rs +++ b/src/fuelup_cli.rs @@ -4,6 +4,7 @@ use crate::commands::{ component::{self, ComponentCommand}, default::{self, DefaultCommand}, fuelup::{self, FuelupCommand}, + telemetry::{self, TelemetryCommand}, toolchain::{self, ToolchainCommand}, upgrade::{self, UpgradeCommand}, }; @@ -32,6 +33,9 @@ enum Commands { /// Manage your fuelup installation. #[clap(name = "self", subcommand)] Fuelup(FuelupCommand), + /// Manage telemetry settings + #[clap(subcommand)] + Telemetry(TelemetryCommand), /// Install new toolchains or modify/query installed toolchains #[clap(subcommand)] Toolchain(ToolchainCommand), @@ -55,6 +59,7 @@ pub fn fuelup_cli() -> Result<()> { FuelupCommand::Update(update) => fuelup::update_exec(update.force), FuelupCommand::Uninstall(remove) => fuelup::remove_exec(remove.force), }, + Commands::Telemetry(command) => telemetry::exec(command), Commands::Show => fuelup_show::show(), Commands::Toolchain(command) => toolchain::exec(command), Commands::Update => fuelup_update::update(), diff --git a/src/lib.rs b/src/lib.rs index e581350a0..b6befaa0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,5 +16,6 @@ pub mod settings; pub mod shell; pub mod store; pub mod target_triple; +pub mod telemetry; pub mod toolchain; pub mod toolchain_override; diff --git a/src/main.rs b/src/main.rs index 5916c865a..a62bf9b16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use fuelup::{ fuelup_cli, logging::{init_tracing, log_command, log_environment}, proxy_cli, + telemetry::prompt_telemetry_if_needed, }; use std::{env, panic, path::PathBuf}; use tracing::error; @@ -18,6 +19,10 @@ fn run() -> Result<()> { .and_then(std::ffi::OsStr::to_str) .map(String::from); + // Prompt for telemetry opt-in if needed (for both fuelup and proxy commands) + // This is a one-time prompt that creates the opt-in file + let _ = prompt_telemetry_if_needed(); + match process_name.as_deref() { Some(component::FUELUP) => { if let Err(e) = fuelup_cli::fuelup_cli() { diff --git a/src/proxy_cli.rs b/src/proxy_cli.rs index b4d764918..6d6414dec 100644 --- a/src/proxy_cli.rs +++ b/src/proxy_cli.rs @@ -1,6 +1,7 @@ use crate::{ download::DownloadCfg, target_triple::TargetTriple, + telemetry::is_telemetry_enabled, toolchain::{DistToolchainDescription, Toolchain}, toolchain_override::{ComponentSpec, ToolchainOverride}, }; @@ -92,6 +93,15 @@ fn direct_proxy(proc_name: &str, args: &[OsString], toolchain: &Toolchain) -> Re cmd.args(args); cmd.stdin(Stdio::inherit()); + // Set FUELUP_NO_TELEMETRY based on user's opt-in preference + // FUELUP_NO_TELEMETRY disables telemetry, so we set it when user has opted out + // and explicitly remove it when user has opted in (to override any inherited value) + if is_telemetry_enabled() { + cmd.env_remove("FUELUP_NO_TELEMETRY"); + } else { + cmd.env("FUELUP_NO_TELEMETRY", "1"); + } + return exec(&mut cmd, proc_name, &toolchain_name).map_err(anyhow::Error::from); fn exec(cmd: &mut Command, proc_name: &str, toolchain_name: &str) -> io::Result { diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 000000000..fc1028c75 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,200 @@ +use crate::path::fuelup_dir; +use anyhow::{anyhow, Result}; +use std::fs; +use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; + +const TELEMETRY_OPT_IN_FILE: &str = ".telemetry_opt_in"; + +/// Get the path to the telemetry opt-in file +pub fn telemetry_opt_in_file() -> PathBuf { + fuelup_dir().join(TELEMETRY_OPT_IN_FILE) +} + +/// Represents the user's telemetry preference +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TelemetryStatus { + /// User has opted in to telemetry + Enabled, + /// User has opted out of telemetry + Disabled, +} + +impl TelemetryStatus { + /// Convert to boolean (true = enabled, false = disabled) + pub fn is_enabled(&self) -> bool { + matches!(self, TelemetryStatus::Enabled) + } + + /// Convert from boolean + pub fn from_bool(enabled: bool) -> Self { + if enabled { + TelemetryStatus::Enabled + } else { + TelemetryStatus::Disabled + } + } + + /// Convert to string for storage + pub fn to_storage_string(&self) -> &'static str { + match self { + TelemetryStatus::Enabled => "1", + TelemetryStatus::Disabled => "0", + } + } + + /// Parse from storage string + pub fn from_storage_string(s: &str) -> Result { + match s.trim() { + "1" | "true" | "enabled" | "yes" | "y" => Ok(TelemetryStatus::Enabled), + "0" | "false" | "disabled" | "no" | "n" => Ok(TelemetryStatus::Disabled), + _ => Err(anyhow!("Invalid telemetry status: {}", s)), + } + } +} + +/// Get the user's telemetry preference from the opt-in file +/// +/// Returns `None` if the file doesn't exist (user hasn't been prompted yet) +pub fn get_telemetry_status() -> Result> { + let path = telemetry_opt_in_file(); + + if !path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&path)?; + let status = TelemetryStatus::from_storage_string(&content)?; + + Ok(Some(status)) +} + +/// Set the user's telemetry preference +pub fn set_telemetry_status(status: TelemetryStatus) -> Result<()> { + let path = telemetry_opt_in_file(); + + // Ensure the parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(&path, status.to_storage_string())?; + Ok(()) +} + +/// Check if telemetry should be enabled based on the opt-in file +/// +/// Returns `true` if telemetry is enabled, `false` otherwise. +/// If the file doesn't exist, returns `false` (disabled by default). +pub fn is_telemetry_enabled() -> bool { + get_telemetry_status() + .ok() + .flatten() + .map(|s| s.is_enabled()) + .unwrap_or(false) +} + +/// Prompt the user for telemetry opt-in if they haven't been asked yet +/// +/// This function checks if the telemetry opt-in file exists. If not, it prompts +/// the user and saves their choice. Returns `Ok(true)` if prompted, `Ok(false)` +/// if the file already exists (no prompt needed), or `Err` if something fails. +/// +/// The prompt will only be shown in an interactive terminal to avoid blocking +/// scripts or automated workflows. +pub fn prompt_telemetry_if_needed() -> Result { + // If the file already exists, no need to prompt + if telemetry_opt_in_file().exists() { + return Ok(false); + } + + // Only prompt if we're in an interactive terminal + // This prevents blocking scripts or CI/CD pipelines + if !std::io::stdin().is_terminal() { + // Default to disabled for non-interactive environments + set_telemetry_status(TelemetryStatus::Disabled)?; + return Ok(false); + } + + // Ensure the fuelup directory exists + if let Some(parent) = telemetry_opt_in_file().parent() { + fs::create_dir_all(parent)?; + } + + // Prompt the user + println!(); + println!("Telemetry helps improve the Fuel toolchain by collecting anonymous usage data."); + println!("You can change this setting at any time by running:"); + println!(" fuelup telemetry enable # to enable telemetry"); + println!(" fuelup telemetry disable # to disable telemetry"); + println!(); + + // Read user input + print!("Would you like to enable telemetry? (Y/n): "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + match input.trim().to_lowercase().as_str() { + "n" | "no" => { + set_telemetry_status(TelemetryStatus::Disabled)?; + println!(); + println!("Telemetry disabled. You can enable it later with 'fuelup telemetry enable'."); + println!(); + } + _ => { + set_telemetry_status(TelemetryStatus::Enabled)?; + println!(); + println!("Telemetry enabled. Thank you for helping improve the Fuel toolchain!"); + println!("You can disable it at any time with 'fuelup telemetry disable'."); + println!(); + } + }; + + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_storage_string_conversion() { + assert_eq!(TelemetryStatus::Enabled.to_storage_string(), "1"); + assert_eq!(TelemetryStatus::Disabled.to_storage_string(), "0"); + } + + #[test] + fn test_from_storage_string() { + assert_eq!( + TelemetryStatus::from_storage_string("1").unwrap(), + TelemetryStatus::Enabled + ); + assert_eq!( + TelemetryStatus::from_storage_string("true").unwrap(), + TelemetryStatus::Enabled + ); + assert_eq!( + TelemetryStatus::from_storage_string("0").unwrap(), + TelemetryStatus::Disabled + ); + assert_eq!( + TelemetryStatus::from_storage_string("false").unwrap(), + TelemetryStatus::Disabled + ); + assert!(TelemetryStatus::from_storage_string("invalid").is_err()); + } + + #[test] + fn test_is_enabled() { + assert!(TelemetryStatus::Enabled.is_enabled()); + assert!(!TelemetryStatus::Disabled.is_enabled()); + } + + #[test] + fn test_from_bool() { + assert_eq!(TelemetryStatus::from_bool(true), TelemetryStatus::Enabled); + assert_eq!(TelemetryStatus::from_bool(false), TelemetryStatus::Disabled); + } +}