diff --git a/src/board/mod.rs b/src/board/mod.rs index ad6c7cb..563d6e9 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -35,6 +35,10 @@ impl PortInfo { if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") { return true; } + // FreeBSD: cuaU* are USB serial ports + if self.port_name.contains("cuaU") { + return true; + } false } @@ -87,11 +91,18 @@ struct MatchingBoard { } /// Enumerate serial ports via `arduino-cli board list --format json`. -/// Falls back to OS-level detection if arduino-cli is unavailable. +/// Falls back to OS-level detection if arduino-cli is unavailable or returns +/// empty results (e.g. on FreeBSD where serial-discovery uses unsupported syscalls). pub fn list_ports() -> Vec { if let Some(cli) = find_arduino_cli() { if let Ok(ports) = list_ports_via_cli(&cli) { - return ports; + if !ports.is_empty() { + return ports; + } + // arduino-cli succeeded but found nothing -- on FreeBSD its + // serial-discovery subprocess uses Linux syscalls that are not + // implemented, so it always returns empty. Fall through to the + // OS-level scan which checks /dev/cuaU* directly. } } list_ports_fallback() @@ -171,12 +182,22 @@ fn list_ports_fallback() -> Vec { if let Ok(entries) = fs::read_dir("/dev") { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("ttyUSB") || name.starts_with("ttyACM") { + // Linux: ttyUSB* (CH340/FTDI), ttyACM* (Arduino native USB) + // FreeBSD: cuaU* (USB serial) + // On FreeBSD, cuaU0.init and cuaU0.lock are control nodes, + // not actual ports -- only match bare cuaU* names. + let is_usb_port = name.starts_with("ttyUSB") + || name.starts_with("ttyACM") + || (name.starts_with("cuaU") + && !name.contains('.')); + if is_usb_port { let path = format!("/dev/{}", name); let board = if name.starts_with("ttyUSB") { "Likely CH340/FTDI (run 'anvil setup' for full detection)" - } else { + } else if name.starts_with("ttyACM") { "Likely Arduino (run 'anvil setup' for full detection)" + } else { + "USB serial device (run 'anvil setup' for full detection)" }; result.push(PortInfo { port_name: path, @@ -306,14 +327,23 @@ pub fn print_port_details(ports: &[PortInfo]) { println!(" {}", "No serial devices found.".yellow()); println!(); println!(" Checklist:"); - println!(" 1. Is the board plugged in via USB?"); - println!(" 2. Is the USB cable a data cable (not charge-only)?"); - #[cfg(target_os = "linux")] { - println!(" 3. Check kernel log: dmesg | tail -20"); - println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); + let mut n = 1u32; + println!(" {}. Is the board plugged in via USB?", n); n += 1; + println!(" {}. Is the USB cable a data cable (not charge-only)?", n); n += 1; + #[cfg(target_os = "linux")] + { + println!(" {}. Check kernel log: dmesg | tail -20", n); n += 1; + println!(" {}. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'", n); n += 1; + } + #[cfg(target_os = "freebsd")] + { + println!(" {}. Check kernel log: dmesg | grep -i usb", n); n += 1; + println!(" {}. List USB devices: usbconfig list", n); n += 1; + println!(" {}. Board should appear as /dev/cuaU0 (or cuaU1, cuaU2...)", n); n += 1; + } + println!(" {}. Try a different USB port or cable", n); } - println!(" 5. Try a different USB port or cable"); return; } diff --git a/src/commands/devices.rs b/src/commands/devices.rs index d879545..e97c5af 100644 --- a/src/commands/devices.rs +++ b/src/commands/devices.rs @@ -15,7 +15,20 @@ pub fn scan_devices() -> Result<()> { let ports = board::list_ports(); board::print_port_details(&ports); - // Also run arduino-cli board list for cross-reference + #[cfg(target_os = "freebsd")] + { + println!(); + println!( + " {}", + "Tip: if your board is not showing up, try plugging it directly into the machine -- some USB hubs prevent boards from being detected on FreeBSD.".bright_black() + ); + } + + // Also run arduino-cli board list for cross-reference. + // On FreeBSD, arduino-cli is a Linux binary whose serial-discovery subprocess + // uses syscalls not implemented in the Linux compat layer -- skip it entirely + // since we already detected ports via the OS fallback above. + #[cfg(not(target_os = "freebsd"))] if let Some(cli_path) = board::find_arduino_cli() { println!(); println!( @@ -62,6 +75,14 @@ pub fn scan_devices() -> Result<()> { println!(" - Check kernel log: dmesg | tail -20"); println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); } + #[cfg(target_os = "freebsd")] + { + println!(" - Check kernel log: dmesg | grep -i usb"); + println!(" - List USB devices: usbconfig list"); + println!(" - Board should appear as /dev/cuaU0 (or cuaU1, cuaU2...)"); + println!(" - Make sure you are in the dialer group: sudo pw groupmod dialer -m $USER"); + println!(" - Then log out and back in for group change to take effect"); + } #[cfg(target_os = "windows")] { println!(" - Open Device Manager and check Ports (COM & LPT)"); diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 3f3047f..028e9dd 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -10,7 +10,9 @@ pub struct SystemHealth { pub arduino_cli_path: Option, pub avr_core_ok: bool, pub avr_size_ok: bool, + pub avr_size_path: Option, pub dialout_ok: bool, + pub operator_ok: bool, pub cmake_ok: bool, pub cpp_compiler_ok: bool, pub cpp_on_path: bool, @@ -43,10 +45,10 @@ pub fn run_diagnostics(fix: bool) -> Result<()> { .bold() ); if fix { - // Check optional tools let optional_missing = !health.cmake_ok || !health.cpp_compiler_ok - || !health.git_ok; + || !health.git_ok + || !health.avr_size_ok; if optional_missing { println!(); run_fix_optional(&health)?; @@ -76,13 +78,11 @@ pub fn run_diagnostics(fix: bool) -> Result<()> { } pub fn check_system_health() -> SystemHealth { - // arduino-cli let (arduino_cli_ok, arduino_cli_path) = match board::find_arduino_cli() { Some(path) => (true, Some(path.display().to_string())), None => (false, None), }; - // AVR core let avr_core_ok = if let Some(ref path_str) = arduino_cli_path { let path = std::path::Path::new(path_str); board::is_avr_core_installed(path) @@ -90,16 +90,11 @@ pub fn check_system_health() -> SystemHealth { false }; - // avr-size (optional) - let avr_size_ok = which::which("avr-size").is_ok(); - - // dialout group (Linux only) + let (avr_size_ok, avr_size_path) = find_avr_size(); let dialout_ok = check_dialout(); - - // cmake (optional -- for host tests) + let operator_ok = check_operator_group(); let cmake_ok = which::which("cmake").is_ok(); - // C++ compiler (optional -- for host tests) let cpp_on_path = which::which("g++").is_ok() || which::which("clang++").is_ok() || which::which("cl").is_ok(); @@ -109,10 +104,7 @@ pub fn check_system_health() -> SystemHealth { has_cpp_compiler() }; - // git let git_ok = which::which("git").is_ok(); - - // Serial ports let ports_found = board::list_ports().len(); SystemHealth { @@ -120,7 +112,9 @@ pub fn check_system_health() -> SystemHealth { arduino_cli_path, avr_core_ok, avr_size_ok, + avr_size_path, dialout_ok, + operator_ok, cmake_ok, cpp_compiler_ok, cpp_on_path, @@ -129,7 +123,6 @@ pub fn check_system_health() -> SystemHealth { } } -/// Check for a C++ compiler on any platform. fn has_cpp_compiler() -> bool { if which::which("g++").is_ok() || which::which("clang++").is_ok() { return true; @@ -139,8 +132,6 @@ fn has_cpp_compiler() -> bool { if which::which("cl").is_ok() { return true; } - // cl.exe may be installed via VS Build Tools but not on PATH. - // Check via vswhere.exe (ships with VS installer). if let Ok(output) = std::process::Command::new( r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe", ) @@ -161,7 +152,6 @@ fn print_diagnostics(health: &SystemHealth) { println!("{}", "Required:".bright_yellow().bold()); println!(); - // arduino-cli if health.arduino_cli_ok { println!( " {} arduino-cli {}", @@ -176,7 +166,6 @@ fn print_diagnostics(health: &SystemHealth) { println!(" {} arduino-cli {}", "MISSING".red(), "not found in PATH".red()); } - // AVR core if health.avr_core_ok { println!(" {} arduino:avr core installed", "ok".green()); } else if health.arduino_cli_ok { @@ -197,7 +186,6 @@ fn print_diagnostics(health: &SystemHealth) { println!("{}", "Optional:".bright_yellow().bold()); println!(); - // avr-size if health.avr_size_ok { println!(" {} avr-size (binary size reporting)", "ok".green()); } else if !health.avr_core_ok { @@ -207,39 +195,53 @@ fn print_diagnostics(health: &SystemHealth) { "included with arduino:avr core (no separate install)".yellow() ); } else { + let hint = hint_avr_size_not_on_path(&health.avr_size_path); println!( " {} avr-size {}", "na".yellow(), - hint_avr_size_not_on_path().yellow() + hint.as_str().yellow() ); } - // dialout #[cfg(unix)] { + let group_name = serial_group_name(); if health.dialout_ok { - println!(" {} user in dialout group", "ok".green()); + println!(" {} user in {} group", "ok".green(), group_name); } else { + let fix_cmd = serial_group_fix_command(); println!( - " {} dialout group {}", + " {} {} group {}", "WARN".yellow(), - "run: sudo usermod -aG dialout $USER".yellow() + group_name, + format!("run: {} (then log out and back in)", fix_cmd).yellow() + ); + } + } + + #[cfg(target_os = "freebsd")] + { + if health.operator_ok { + println!(" {} user in operator group (USB device access)", "ok".green()); + } else { + println!( + " {} operator group {}", + "WARN".yellow(), + "run: sudo pw groupmod operator -m $USER (then log out and back in)".yellow() ); } } - // cmake if health.cmake_ok { println!(" {} cmake (for host-side tests)", "ok".green()); } else { println!( " {} cmake {}", "na".yellow(), - hint_cmake().yellow() + hint_cmake().as_str().yellow() ); } - // C++ compiler if health.cpp_on_path { println!(" {} C++ compiler", "ok".green()); } else if health.cpp_compiler_ok { @@ -252,18 +254,17 @@ fn print_diagnostics(health: &SystemHealth) { println!( " {} C++ compiler {}", "na".yellow(), - hint_cpp_compiler().yellow() + hint_cpp_compiler().as_str().yellow() ); } - // git if health.git_ok { println!(" {} git", "ok".green()); } else { println!( " {} git {}", "na".yellow(), - hint_git().yellow() + hint_git().as_str().yellow() ); } @@ -273,20 +274,41 @@ fn print_diagnostics(health: &SystemHealth) { if health.ports_found > 0 { println!( - " {} {} serial port(s) detected", + " {} {} Arduino board(s) detected", "ok".green(), health.ports_found ); } else { - println!( - " {} no serial ports {}", - "na".yellow(), - "(plug in a board to detect)".yellow() - ); + // On FreeBSD, missing group membership prevents port detection entirely -- + // make that clear so the user doesn't chase a phantom hardware problem. + #[cfg(target_os = "freebsd")] + { + if !health.dialout_ok || !health.operator_ok { + println!( + " {} no board detected {}", + "na".yellow(), + "(cannot scan for boards until group access is fixed -- resolve the WARNs above and log out and back in)".yellow() + ); + } else { + println!( + " {} no board detected {}", + "na".yellow(), + "(plug in your Arduino -- on FreeBSD boards appear as /dev/cuaU0)".yellow() + ); + } + } + #[cfg(not(target_os = "freebsd"))] + { + println!( + " {} no board detected {}", + "na".yellow(), + "(plug in your Arduino to continue)".yellow() + ); + } } + } -/// Print step-by-step fix instructions when required items are missing. fn print_fix_instructions(health: &SystemHealth) { println!("{}", "How to fix:".bright_cyan().bold()); println!(); @@ -310,11 +332,32 @@ fn print_fix_instructions(health: &SystemHealth) { println!(" Option C -- Direct download:"); println!( " {}", - "https://arduino.github.io/arduino-cli/installation/" - .bright_cyan() + "https://arduino.github.io/arduino-cli/installation/".bright_cyan() ); } else if cfg!(target_os = "macos") { println!(" {}", "brew install arduino-cli".bright_cyan()); + } else if is_freebsd() { + println!(); + println!(" FreeBSD -- download the Linux binary (runs via Linux compat layer):"); + println!( + " {}", + "curl -L https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz -o /tmp/arduino-cli.tar.gz" + .bright_cyan() + ); + println!( + " {}", + "tar xzf /tmp/arduino-cli.tar.gz -C /tmp".bright_cyan() + ); + println!( + " {}", + "sudo mv /tmp/arduino-cli /usr/local/bin/".bright_cyan() + ); + println!(); + println!(" Ensure Linux compat layer is active:"); + println!( + " {}", + "sudo sysrc linux_enable=YES && sudo service linux start".bright_cyan() + ); } else { println!(); println!(" Option A -- Install script:"); @@ -362,46 +405,54 @@ fn print_fix_instructions(health: &SystemHealth) { println!( " {}. {}", step, - "Install the AVR core and verify everything:" - .bright_white() - .bold() + "Install the AVR core and verify everything:".bright_white().bold() ); println!(" {}", "anvil setup".bright_cyan()); println!(); - // step += 1; + } + + #[cfg(unix)] + if !health.dialout_ok { + let fix_cmd = serial_group_fix_command(); + println!( + " {}", + format!( + "Tip: add yourself to the serial group for board upload access: {}", + fix_cmd + ) + .bright_black() + ); + println!(); + } + + #[cfg(target_os = "freebsd")] + if !health.operator_ok { + println!( + " {}", + "Tip: add yourself to the operator group for USB device access: sudo pw groupmod operator -m $USER" + .bright_black() + ); + println!(); } if !health.git_ok { println!( " {}", - "Tip: git is optional but recommended for version control." - .bright_black() + "Tip: git is optional but recommended for version control.".bright_black() ); if cfg!(target_os = "windows") { - println!( - " {}", - "winget install Git.Git".bright_black() - ); + println!(" {}", "winget install Git.Git".bright_black()); } else if cfg!(target_os = "macos") { - println!( - " {}", - "xcode-select --install".bright_black() - ); + println!(" {}", "xcode-select --install".bright_black()); + } else if is_freebsd() { + println!(" {}", "sudo pkg install git".bright_black()); } else { - println!( - " {}", - "sudo apt install git".bright_black() - ); + println!(" {}", "sudo apt install git".bright_black()); } println!(); } } -// ========================================================================== -// --fix: automated installation -// ========================================================================== - -/// Prompt the user for yes/no confirmation. fn confirm(prompt: &str) -> bool { print!("{} [Y/n] ", prompt); io::stdout().flush().ok(); @@ -413,7 +464,6 @@ fn confirm(prompt: &str) -> bool { trimmed.is_empty() || trimmed == "y" || trimmed == "yes" } -/// Run a command, streaming output to the terminal. fn run_cmd(program: &str, args: &[&str]) -> bool { println!( " {} {} {}", @@ -428,7 +478,6 @@ fn run_cmd(program: &str, args: &[&str]) -> bool { .unwrap_or(false) } -/// Detect which package manager is available on the system. fn detect_package_manager() -> Option<&'static str> { if cfg!(target_os = "windows") { if which::which("winget").is_ok() { @@ -444,8 +493,13 @@ fn detect_package_manager() -> Option<&'static str> { } else { None } + } else if is_freebsd() { + if which::which("pkg").is_ok() { + Some("pkg") + } else { + None + } } else { - // Linux if which::which("apt").is_ok() { Some("apt") } else if which::which("dnf").is_ok() { @@ -458,11 +512,9 @@ fn detect_package_manager() -> Option<&'static str> { } } -/// Fix required items (arduino-cli, avr core). fn run_fix(health: &SystemHealth) -> Result<()> { let pm = detect_package_manager(); - // -- arduino-cli -- if !health.arduino_cli_ok { println!( "{}", @@ -524,10 +576,39 @@ fn run_fix(health: &SystemHealth) -> Result<()> { println!("{} arduino-cli installed.", "ok".green()); } } - Some("dnf") => { + Some("pkg") => { println!( - " arduino-cli is not in dnf repos. Install manually:" + " {}", + "arduino-cli is not in the FreeBSD ports tree.".bright_yellow() ); + println!( + " {}", + "It runs via the Linux compatibility layer (linux_enable must be YES).".bright_black() + ); + if confirm("Download and install the Linux arduino-cli binary now?") { + if !run_cmd("curl", &[ + "-L", + "https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz", + "-o", "/tmp/arduino-cli.tar.gz", + ]) { + println!("{} Download failed. Check your internet connection.", "FAIL".red()); + return Ok(()); + } + run_cmd("tar", &["xzf", "/tmp/arduino-cli.tar.gz", "-C", "/tmp"]); + if !run_cmd("sudo", &["mv", "/tmp/arduino-cli", "/usr/local/bin/arduino-cli"]) { + println!("{} Could not move binary to /usr/local/bin/.", "FAIL".red()); + return Ok(()); + } + println!("{} arduino-cli installed.", "ok".green()); + println!( + " {}", + "If it fails to run, ensure Linux compat is active: sudo sysrc linux_enable=YES && sudo service linux start" + .bright_black() + ); + } + } + Some("dnf") => { + println!(" arduino-cli is not in dnf repos. Install manually:"); println!( " {}", "curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh" @@ -545,19 +626,15 @@ fn run_fix(health: &SystemHealth) -> Result<()> { } } _ => { - println!( - " No supported package manager found. Install manually:" - ); + println!(" No supported package manager found. Install manually:"); println!( " {}", - "https://arduino.github.io/arduino-cli/installation/" - .bright_cyan() + "https://arduino.github.io/arduino-cli/installation/".bright_cyan() ); return Ok(()); } } - // Re-check after install if board::find_arduino_cli().is_none() { println!(); println!( @@ -568,7 +645,6 @@ fn run_fix(health: &SystemHealth) -> Result<()> { } } - // -- AVR core -- if !health.avr_core_ok { if health.arduino_cli_ok || board::find_arduino_cli().is_some() { println!(); @@ -589,16 +665,96 @@ fn run_fix(health: &SystemHealth) -> Result<()> { } } - // Offer optional tools too - run_fix_optional(health)?; + // -- serial group (dialout / dialer) -- + #[cfg(unix)] + if !health.dialout_ok { + let group = serial_group_name(); + let fix_cmd = serial_group_fix_command(); + println!(); + println!( + "{}", + format!("You are not in the {} group (needed for board upload).", group).bright_yellow() + ); + if confirm(&format!("Add yourself to {} now?", group)) { + let parts: Vec<&str> = fix_cmd.split_whitespace().collect(); + if run_cmd(parts[0], &parts[1..]) { + println!("{} Added to {} group. Log out and back in to apply.", "ok".green(), group); + } else { + println!("{} Failed. Run manually: {}", "FAIL".red(), fix_cmd); + } + } + } + // -- operator group (FreeBSD USB device access) -- + #[cfg(target_os = "freebsd")] + if !health.operator_ok { + println!(); + println!( + "{}", + "You are not in the operator group (needed for USB device access on FreeBSD).".bright_yellow() + ); + if confirm("Add yourself to operator group now?") { + if run_cmd("sudo", &["pw", "groupmod", "operator", "-m", &whoami()]) { + println!("{} Added to operator group. Log out and back in to apply.", "ok".green()); + } else { + println!("{} Failed. Run manually: sudo pw groupmod operator -m $USER", "FAIL".red()); + } + } + } + + run_fix_optional(health)?; Ok(()) } -/// Fix optional items (cmake, C++ compiler, git). fn run_fix_optional(health: &SystemHealth) -> Result<()> { let pm = detect_package_manager(); + // -- avr-size PATH fix -- + if !health.avr_size_ok { + if let Some(ref bin_path) = health.avr_size_path { + let bin_dir = std::path::Path::new(bin_path) + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + println!(); + println!( + "{}", + "avr-size is installed but not on PATH.".bright_yellow() + ); + println!( + " Found at: {}", + bin_path.bright_cyan() + ); + if !bin_dir.is_empty() { + let shell_rc = detect_shell_rc(); + let export_line = format!(r#"export PATH="{}:$PATH""#, bin_dir); + if confirm(&format!("Add {} to PATH in {}?", bin_dir, shell_rc)) { + let rc_path = std::path::Path::new(&shell_rc); + let existing = std::fs::read_to_string(rc_path).unwrap_or_default(); + if existing.contains(&export_line) { + println!("{} Already in {} -- log out and back in to apply.", "ok".green(), shell_rc); + } else { + let line = format!(" +# Added by anvil doctor --fix +{} +", export_line); + if std::fs::OpenOptions::new() + .append(true) + .open(rc_path) + .and_then(|mut f| { use std::io::Write; f.write_all(line.as_bytes()) }) + .is_ok() + { + println!("{} Added to {}. Log out and back in to apply.", "ok".green(), shell_rc); + } else { + println!("{} Could not write to {}. Add manually:", "FAIL".red(), shell_rc); + println!(" {}", export_line.bright_cyan()); + } + } + } + } + } + } + let items: Vec<(&str, bool, FixSpec)> = vec![ ("cmake", health.cmake_ok, fix_spec_cmake(pm)), ("C++ compiler", health.cpp_compiler_ok, fix_spec_cpp(pm)), @@ -606,6 +762,9 @@ fn run_fix_optional(health: &SystemHealth) -> Result<()> { ]; let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect(); + if missing.is_empty() && health.avr_size_ok { + return Ok(()); + } if missing.is_empty() { return Ok(()); } @@ -666,6 +825,11 @@ fn fix_spec_cmake(pm: Option<&str>) -> FixSpec { program: "sudo", args: &["apt", "install", "-y", "cmake"], }, + Some("pkg") => FixSpec::Auto { + prompt: "Install cmake via pkg?", + program: "sudo", + args: &["pkg", "install", "-y", "cmake"], + }, Some("dnf") => FixSpec::Auto { prompt: "Install cmake via dnf?", program: "sudo", @@ -702,6 +866,9 @@ fn fix_spec_cpp(pm: Option<&str>) -> FixSpec { program: "sudo", args: &["apt", "install", "-y", "g++"], }, + Some("pkg") => FixSpec::Manual { + message: "clang++ is already part of the FreeBSD base system", + }, Some("dnf") => FixSpec::Auto { prompt: "Install g++ via dnf?", program: "sudo", @@ -740,6 +907,11 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec { program: "sudo", args: &["apt", "install", "-y", "git"], }, + Some("pkg") => FixSpec::Auto { + prompt: "Install git via pkg?", + program: "sudo", + args: &["pkg", "install", "-y", "git"], + }, Some("dnf") => FixSpec::Auto { prompt: "Install git via dnf?", program: "sudo", @@ -756,57 +928,121 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec { } } -// --------------------------------------------------------------------------- -// Platform-aware install hints (one-liners for the diagnostics table) -// --------------------------------------------------------------------------- - -fn hint_avr_size_not_on_path() -> &'static str { +fn hint_avr_size_not_on_path(found_path: &Option) -> String { + let location = match found_path { + Some(p) => format!("found at {} -- add its directory to PATH, or log out and back in", p), + None => "not found -- install the AVR core first: anvil setup".to_string(), + }; if cfg!(target_os = "windows") { - "installed but not on PATH (binary size reports will be skipped)" + format!("installed but not on PATH ({})", location) } else { - "installed but not on PATH" + format!("not on PATH ({})", location) } } -fn hint_cmake() -> &'static str { +/// Search common locations for avr-size and return (on_path, found_path). +fn find_avr_size() -> (bool, Option) { + if which::which("avr-size").is_ok() { + return (true, None); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let search_roots = vec![ + format!("{}/.arduino15/packages/arduino/tools/avr-gcc", home), + format!("{}/.local/share/arduino15/packages/arduino/tools/avr-gcc", home), + format!("{}\\AppData\\Local\\Arduino15\\packages\\arduino\\tools\\avr-gcc", home), + ]; + for root in &search_roots { + let root_path = std::path::Path::new(root); + if !root_path.exists() { continue; } + if let Ok(entries) = std::fs::read_dir(root_path) { + for version_entry in entries.flatten() { + let bin_name = if cfg!(target_os = "windows") { "avr-size.exe" } else { "avr-size" }; + let bin = version_entry.path().join("bin").join(bin_name); + if bin.exists() { + return (false, Some(bin.display().to_string())); + } + } + } + } + (false, None) +} + +fn hint_cmake() -> String { if cfg!(target_os = "windows") { - "install: winget install Kitware.CMake (or choco install cmake)" + "install: winget install Kitware.CMake (or choco install cmake)".into() } else if cfg!(target_os = "macos") { - "install: brew install cmake" + "install: brew install cmake".into() + } else if is_freebsd() { + "install: sudo pkg install cmake".into() } else { - "install: sudo apt install cmake" + match detect_package_manager() { + Some("dnf") => "install: sudo dnf install cmake".into(), + Some("pacman") => "install: sudo pacman -S cmake".into(), + _ => "install: sudo apt install cmake".into(), + } } } -fn hint_cpp_compiler() -> &'static str { +fn hint_cpp_compiler() -> String { if cfg!(target_os = "windows") { - "install: choco install mingw (or open Developer Command Prompt for MSVC)" + "install: choco install mingw (or open Developer Command Prompt for MSVC)".into() } else if cfg!(target_os = "macos") { - "install: xcode-select --install" + "install: xcode-select --install".into() + } else if is_freebsd() { + "clang++ is part of the FreeBSD base system -- check your PATH".into() } else { - "install: sudo apt install g++" + match detect_package_manager() { + Some("dnf") => "install: sudo dnf install gcc-c++".into(), + Some("pacman") => "install: sudo pacman -S gcc".into(), + _ => "install: sudo apt install g++".into(), + } } } -fn hint_git() -> &'static str { +fn hint_git() -> String { if cfg!(target_os = "windows") { - "install: winget install Git.Git (or https://git-scm.com)" + "install: winget install Git.Git (or https://git-scm.com)".into() } else if cfg!(target_os = "macos") { - "install: xcode-select --install (or brew install git)" + "install: xcode-select --install (or brew install git)".into() + } else if is_freebsd() { + "install: sudo pkg install git".into() } else { - "install: sudo apt install git" + match detect_package_manager() { + Some("dnf") => "install: sudo dnf install git".into(), + Some("pacman") => "install: sudo pacman -S git".into(), + _ => "install: sudo apt install git".into(), + } + } +} + +fn is_freebsd() -> bool { + cfg!(target_os = "freebsd") +} + +fn serial_group_name() -> &'static str { + if is_freebsd() { + "dialer" + } else { + "dialout" + } +} + +fn serial_group_fix_command() -> String { + if is_freebsd() { + "sudo pw groupmod dialer -m $USER".to_string() + } else { + "sudo usermod -aG dialout $USER".to_string() } } fn check_dialout() -> bool { #[cfg(unix)] { - let output = std::process::Command::new("groups") - .output(); - match output { + let group = serial_group_name(); + match std::process::Command::new("groups").output() { Ok(out) => { let groups = String::from_utf8_lossy(&out.stdout); - groups.contains("dialout") + groups.contains(group) } Err(_) => false, } @@ -814,6 +1050,49 @@ fn check_dialout() -> bool { #[cfg(not(unix))] { - true // Not applicable on Windows + true } +} +fn check_operator_group() -> bool { + #[cfg(target_os = "freebsd")] + { + match std::process::Command::new("groups").output() { + Ok(out) => { + let groups = String::from_utf8_lossy(&out.stdout); + groups.contains("operator") + } + Err(_) => false, + } + } + + #[cfg(not(target_os = "freebsd"))] + { + true // not applicable on other platforms + } +} + +fn whoami() -> String { + std::process::Command::new("whoami") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "$USER".to_string()) +} + +/// Detect the user's shell RC file for PATH exports. +fn detect_shell_rc() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + // Check SHELL env var first + let shell = std::env::var("SHELL").unwrap_or_default(); + if shell.contains("zsh") { + return format!("{}/.zshrc", home); + } + if shell.contains("fish") { + return format!("{}/.config/fish/config.fish", home); + } + // Fall back to checking which RC files exist + let zshrc = format!("{}/.zshrc", home); + if std::path::Path::new(&zshrc).exists() { + return zshrc; + } + format!("{}/.bashrc", home) } \ No newline at end of file diff --git a/src/commands/refresh.rs b/src/commands/refresh.rs index d8133e3..6e1ce2c 100644 --- a/src/commands/refresh.rs +++ b/src/commands/refresh.rs @@ -306,6 +306,13 @@ pub fn run_refresh( } } + for filename in &has_changes { + println!( + " {} {} (updated)", + "ok".green(), + filename.bright_white() + ); + } println!( "{} Updated {} file(s).", "ok".green(), diff --git a/templates/basic/build.sh b/templates/basic/build.sh index a4201bd..f753817 100644 --- a/templates/basic/build.sh +++ b/templates/basic/build.sh @@ -38,8 +38,29 @@ toml_get() { } toml_array() { - (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \ - | sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' + # Handles both single-line: key = ["a", "b"] + # and multiline: key = [ + # "a", + # "b", + # ] + awk -v key="$1" ' + $0 ~ ("^" key " *= *\\[") { + collecting = 1 + buf = $0 + if (index($0, "]") > 0) { collecting = 0 } + } + collecting && NR > 1 { buf = buf " " $0 } + collecting && index($0, "]") > 0 && NR > 1 { collecting = 0 } + !collecting && buf != "" { + gsub(/.*\[/, "", buf) + gsub(/\].*/, "", buf) + gsub(/"/, "", buf) + gsub(/,/, " ", buf) + print buf + buf = "" + exit + } + ' "$CONFIG" | tr -s " " } toml_section_get() { diff --git a/templates/basic/test.sh b/templates/basic/test.sh index 72a8051..15c16c8 100644 --- a/templates/basic/test.sh +++ b/templates/basic/test.sh @@ -24,6 +24,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" TEST_DIR="$SCRIPT_DIR/test" BUILD_DIR="$TEST_DIR/build" +# Portable CPU count -- BSD make requires a number after -j, GNU make does not +cpu_count() { + if command -v nproc &>/dev/null; then + nproc + elif command -v sysctl &>/dev/null; then + sysctl -n hw.ncpu + else + echo 4 + fi +} + # Color output if [[ -t 1 ]]; then RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m' @@ -67,6 +78,8 @@ if ! command -v cmake &>/dev/null; then echo " Install:" >&2 if [[ "$(uname)" == "Darwin" ]]; then echo " brew install cmake" >&2 + elif [[ "$(uname)" == "FreeBSD" ]]; then + echo " sudo pkg install cmake" >&2 else echo " sudo apt install cmake (Debian/Ubuntu)" >&2 echo " sudo dnf install cmake (Fedora)" >&2 @@ -102,7 +115,7 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then fi info "Building tests..." -cmake --build "$BUILD_DIR" --parallel 2>&1 | \ +cmake --build "$BUILD_DIR" --parallel "$(cpu_count)" 2>&1 | \ while IFS= read -r line; do echo " $line"; done echo "" diff --git a/templates/basic/test/run_tests.sh b/templates/basic/test/run_tests.sh index cd89953..645fab9 100644 --- a/templates/basic/test/run_tests.sh +++ b/templates/basic/test/run_tests.sh @@ -17,6 +17,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" BUILD_DIR="$SCRIPT_DIR/build" +# Portable CPU count -- BSD make requires a number after -j, GNU make does not +cpu_count() { + if command -v nproc &>/dev/null; then + nproc + elif command -v sysctl &>/dev/null; then + sysctl -n hw.ncpu + else + echo 4 + fi +} + # Color output if [[ -t 1 ]]; then RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m' @@ -40,7 +51,7 @@ for arg in "$@"; do esac done -command -v cmake &>/dev/null || die "cmake not found. Install: sudo apt install cmake" +command -v cmake &>/dev/null || die "cmake not found. Install: pkg install cmake (FreeBSD), apt install cmake (Debian/Ubuntu), dnf install cmake (Fedora)" command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found" command -v git &>/dev/null || die "git not found (needed to fetch Google Test)" @@ -55,7 +66,7 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then fi info "Building tests..." -cmake --build "$BUILD_DIR" --parallel +cmake --build "$BUILD_DIR" --parallel "$(cpu_count)" echo "" info "${BLD}Running tests...${RST}" diff --git a/templates/basic/upload.sh b/templates/basic/upload.sh index 758bdd3..5a05969 100644 --- a/templates/basic/upload.sh +++ b/templates/basic/upload.sh @@ -39,8 +39,29 @@ toml_get() { } toml_array() { - (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \ - | sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' + # Handles both single-line: key = ["a", "b"] + # and multiline: key = [ + # "a", + # "b", + # ] + awk -v key="$1" ' + $0 ~ ("^" key " *= *\\[") { + collecting = 1 + buf = $0 + if (index($0, "]") > 0) { collecting = 0 } + } + collecting && NR > 1 { buf = buf " " $0 } + collecting && index($0, "]") > 0 && NR > 1 { collecting = 0 } + !collecting && buf != "" { + gsub(/.*\[/, "", buf) + gsub(/\].*/, "", buf) + gsub(/"/, "", buf) + gsub(/,/, " ", buf) + print buf + buf = "" + exit + } + ' "$CONFIG" | tr -s " " } toml_section_get() { @@ -69,8 +90,8 @@ LOCAL_CONFIG="$SCRIPT_DIR/.anvil.local" LOCAL_PORT="" LOCAL_VID_PID="" if [[ -f "$LOCAL_CONFIG" ]]; then - LOCAL_PORT="$(grep '^port ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')" - LOCAL_VID_PID="$(grep '^vid_pid ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')" + LOCAL_PORT="$(grep '^port ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' || true)" + LOCAL_VID_PID="$(grep '^vid_pid ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' || true)" fi SKETCH_DIR="$SCRIPT_DIR/$SKETCH_NAME" BUILD_DIR="$SCRIPT_DIR/.build" @@ -195,7 +216,7 @@ if [[ -z "$PORT" ]]; then PORT=$(arduino-cli board list 2>/dev/null \ | grep -i "serial" \ | awk '{print $1}' \ - | grep -E 'ttyUSB|ttyACM|COM' \ + | grep -E 'ttyUSB|ttyACM|cuaU|COM' \ | head -1) if [[ -z "$PORT" ]]; then