diff --git a/examples/ios/devbox.lock b/examples/ios/devbox.lock index 082ee73..2532db5 100644 --- a/examples/ios/devbox.lock +++ b/examples/ios/devbox.lock @@ -393,6 +393,10 @@ "last_modified": "2026-02-15T17:45:47Z", "resolved": "github:NixOS/nixpkgs/ac055f38c798b0d87695240c7b761b82fc7e5bc2?lastModified=1771177547" }, + "github:segment-integrations/mobile-devtools?dir=segkit&ref=main": { + "last_modified": "2026-05-08T18:52:56Z", + "resolved": "github:segment-integrations/mobile-devtools/725aff9a75fa0ae7269f0f6dbf36fa5611b0664c?dir=segkit&lastModified=1778266376" + }, "gnugrep@latest": { "last_modified": "2026-01-23T17:20:52Z", "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#gnugrep", diff --git a/examples/react-native/devbox.lock b/examples/react-native/devbox.lock index 3f53bbe..043ae72 100644 --- a/examples/react-native/devbox.lock +++ b/examples/react-native/devbox.lock @@ -517,6 +517,10 @@ "last_modified": "2026-02-15T17:45:47Z", "resolved": "github:NixOS/nixpkgs/ac055f38c798b0d87695240c7b761b82fc7e5bc2?lastModified=1771177547" }, + "github:segment-integrations/mobile-devtools?dir=segkit&ref=main": { + "last_modified": "2026-05-08T18:52:56Z", + "resolved": "github:segment-integrations/mobile-devtools/725aff9a75fa0ae7269f0f6dbf36fa5611b0664c?dir=segkit&lastModified=1778266376" + }, "gnugrep@latest": { "last_modified": "2026-01-23T17:20:52Z", "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#gnugrep", diff --git a/plugins/ios/virtenv/scripts/init/doctor.sh b/plugins/ios/virtenv/scripts/init/doctor.sh index 99b4b98..e0410f1 100644 --- a/plugins/ios/virtenv/scripts/init/doctor.sh +++ b/plugins/ios/virtenv/scripts/init/doctor.sh @@ -44,7 +44,15 @@ else DOCTOR_CHECKS_PASSED=$((DOCTOR_CHECKS_PASSED + 1)) fi -# Check 3: Device lock file +# Check 3: applesimutils (required for Detox iOS testing) +if command -v applesimutils >/dev/null 2>&1; then + DOCTOR_CHECKS_PASSED=$((DOCTOR_CHECKS_PASSED + 1)) +else + issues+=("applesimutils not installed (run: segkit setup)") + DOCTOR_CHECKS_WARNED=$((DOCTOR_CHECKS_WARNED + 1)) +fi + +# Check 4: Device lock file config_dir=$(doctor_resolve_config_dir 2>/dev/null || echo "${IOS_CONFIG_DIR:-./devbox.d/ios}") devices_dir="${IOS_DEVICES_DIR:-${config_dir}/devices}" lock_file="${devices_dir}/devices.lock" diff --git a/plugins/ios/virtenv/scripts/init/init-hook.sh b/plugins/ios/virtenv/scripts/init/init-hook.sh index 3088c47..145d310 100644 --- a/plugins/ios/virtenv/scripts/init/init-hook.sh +++ b/plugins/ios/virtenv/scripts/init/init-hook.sh @@ -15,6 +15,11 @@ if [ "$(uname -s)" != "Darwin" ]; then exit 0 fi +# Ensure iOS dependencies (Homebrew, applesimutils) are installed +if command -v segkit >/dev/null 2>&1; then + segkit doctor --fix || true +fi + # Find virtenv directory VIRTENV_DIR="${IOS_SCRIPTS_DIR:-}/.." if [ -z "$VIRTENV_DIR" ] || [ "$VIRTENV_DIR" = "/.." ]; then diff --git a/plugins/ios/virtenv/scripts/platform/core.sh b/plugins/ios/virtenv/scripts/platform/core.sh index 46bf6c9..c0b845a 100644 --- a/plugins/ios/virtenv/scripts/platform/core.sh +++ b/plugins/ios/virtenv/scripts/platform/core.sh @@ -212,11 +212,28 @@ ios_setup_native_toolchain() { done IFS="$_ntc_oifs" - # Prepend Xcode and system tool paths + # Prepend Xcode, system, and Homebrew tool paths PATH="/usr/bin:/bin:/usr/sbin:/sbin" if [ -n "${DEVELOPER_DIR:-}" ]; then PATH="$DEVELOPER_DIR/usr/bin:$PATH" fi + # Add specific Homebrew-installed tools needed for iOS development. + # We symlink individual binaries into a private dir rather than adding + # all of /opt/homebrew/bin, to avoid conflicts with user-installed + # packages (e.g. node via Homebrew shadowing the Nix-provided one). + _ntc_brew_shims="${IOS_SCRIPTS_DIR:-/tmp}/../brew-shims" + mkdir -p "$_ntc_brew_shims" 2>/dev/null || true + for _ntc_tool in applesimutils; do + for _ntc_brew_dir in /opt/homebrew/bin /usr/local/bin; do + if [ -x "$_ntc_brew_dir/$_ntc_tool" ] && [ ! -e "$_ntc_brew_shims/$_ntc_tool" ]; then + ln -sf "$_ntc_brew_dir/$_ntc_tool" "$_ntc_brew_shims/$_ntc_tool" 2>/dev/null || true + break + fi + done + done + if [ -d "$_ntc_brew_shims" ]; then + PATH="$PATH:$_ntc_brew_shims" + fi PATH="$PATH:$_ntc_clean" export PATH fi diff --git a/plugins/ios/virtenv/scripts/user/doctor.sh b/plugins/ios/virtenv/scripts/user/doctor.sh index e0f9220..a98638e 100644 --- a/plugins/ios/virtenv/scripts/user/doctor.sh +++ b/plugins/ios/virtenv/scripts/user/doctor.sh @@ -95,6 +95,13 @@ doctor_check_command "xcodebuild" "xcodebuild" "false" doctor_check_command "xcbeautify" "xcbeautify (for pretty build output)" "false" doctor_check_command "pod" "CocoaPods" "false" +# applesimutils (required for Detox iOS testing) +if command -v applesimutils >/dev/null 2>&1; then + doctor_check_pass "applesimutils (for Detox iOS testing)" +else + doctor_check_warn "applesimutils" "Not installed. Run: segkit setup" +fi + # Check jq (needed for config operations) doctor_check_command "jq" "jq (for config operations)" "false" diff --git a/segkit/src/doctor.rs b/segkit/src/doctor.rs new file mode 100644 index 0000000..fa390be --- /dev/null +++ b/segkit/src/doctor.rs @@ -0,0 +1,316 @@ +use std::process::{Command, ExitCode}; + +const DEVBOX_INSTALL_URL: &str = "https://get.jetify.com/devbox"; +const HOMEBREW_INSTALL_URL: &str = + "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"; + +fn is_installed(cmd: &str) -> bool { + which::which(cmd).is_ok() +} + +fn is_macos() -> bool { + cfg!(target_os = "macos") +} + +fn homebrew_bin_dir() -> Option<&'static str> { + if !is_macos() { + return None; + } + if std::path::Path::new("/opt/homebrew/bin/brew").exists() { + Some("/opt/homebrew/bin") + } else if std::path::Path::new("/usr/local/bin/brew").exists() { + Some("/usr/local/bin") + } else { + None + } +} + +fn ensure_homebrew_in_path() { + if let Some(brew_dir) = homebrew_bin_dir() { + let path = std::env::var("PATH").unwrap_or_default(); + if !path.split(':').any(|p| p == brew_dir) { + // SAFETY: segkit is single-threaded at this point + unsafe { + std::env::set_var("PATH", format!("{}:{}", brew_dir, path)); + } + } + } +} + +// ============================================================================ +// Installation functions +// ============================================================================ + +fn install_devbox() -> Result<(), String> { + let status = if is_installed("curl") { + Command::new("sh") + .args(["-c", &format!("curl -fsSL {DEVBOX_INSTALL_URL} | bash")]) + .status() + } else if is_installed("wget") { + Command::new("sh") + .args(["-c", &format!("wget -qO- {DEVBOX_INSTALL_URL} | bash")]) + .status() + } else { + return Err("neither curl nor wget found".into()); + }; + + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(format!( + "installer exited with code {}", + s.code().unwrap_or(-1) + )), + Err(e) => Err(format!("failed to run installer: {e}")), + } +} + +fn install_homebrew() -> Result<(), String> { + let status = Command::new("bash") + .args([ + "-c", + &format!("NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL {HOMEBREW_INSTALL_URL})\""), + ]) + .status(); + + match status { + Ok(s) if s.success() => { + ensure_homebrew_in_path(); + Ok(()) + } + Ok(s) => Err(format!( + "installer exited with code {}", + s.code().unwrap_or(-1) + )), + Err(e) => Err(format!("failed to run installer: {e}")), + } +} + +fn install_applesimutils() -> Result<(), String> { + ensure_homebrew_in_path(); + + let brew = homebrew_bin_dir() + .map(|d| format!("{}/brew", d)) + .unwrap_or_else(|| "brew".into()); + + let status = Command::new(&brew).args(["tap", "wix/brew"]).status(); + match status { + Ok(s) if !s.success() => { + return Err(format!( + "brew tap wix/brew failed (code {})", + s.code().unwrap_or(-1) + )); + } + Err(e) => return Err(format!("failed to run brew tap: {e}")), + _ => {} + } + + let status = Command::new(&brew) + .args(["install", "applesimutils"]) + .status(); + + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(format!( + "brew install failed (code {})", + s.code().unwrap_or(-1) + )), + Err(e) => Err(format!("failed to run brew install: {e}")), + } +} + +// ============================================================================ +// Check results +// ============================================================================ + +enum CheckStatus { + Ok, + Missing, + Fixed, + Failed(String), +} + +struct CheckResult { + name: &'static str, + status: CheckStatus, +} + +// ============================================================================ +// Individual checks +// ============================================================================ + +fn check_devbox(fix: bool) -> CheckResult { + let name = "devbox"; + if is_installed("devbox") { + return CheckResult { + name, + status: CheckStatus::Ok, + }; + } + if !fix { + return CheckResult { + name, + status: CheckStatus::Missing, + }; + } + eprintln!("segkit: installing devbox..."); + match install_devbox() { + Ok(()) => CheckResult { + name, + status: CheckStatus::Fixed, + }, + Err(e) => CheckResult { + name, + status: CheckStatus::Failed(e), + }, + } +} + +fn check_homebrew(fix: bool) -> CheckResult { + let name = "homebrew"; + if !is_macos() { + return CheckResult { + name, + status: CheckStatus::Ok, + }; + } + + ensure_homebrew_in_path(); + if is_installed("brew") { + return CheckResult { + name, + status: CheckStatus::Ok, + }; + } + if !fix { + return CheckResult { + name, + status: CheckStatus::Missing, + }; + } + eprintln!("segkit: installing homebrew..."); + match install_homebrew() { + Ok(()) => CheckResult { + name, + status: CheckStatus::Fixed, + }, + Err(e) => CheckResult { + name, + status: CheckStatus::Failed(e), + }, + } +} + +fn check_applesimutils(fix: bool) -> CheckResult { + let name = "applesimutils"; + if !is_macos() { + return CheckResult { + name, + status: CheckStatus::Ok, + }; + } + + ensure_homebrew_in_path(); + if is_installed("applesimutils") { + return CheckResult { + name, + status: CheckStatus::Ok, + }; + } + if !fix { + return CheckResult { + name, + status: CheckStatus::Missing, + }; + } + if homebrew_bin_dir().is_none() { + return CheckResult { + name, + status: CheckStatus::Failed("Homebrew not available".into()), + }; + } + eprintln!("segkit: installing applesimutils..."); + match install_applesimutils() { + Ok(()) => { + ensure_homebrew_in_path(); + CheckResult { + name, + status: CheckStatus::Fixed, + } + } + Err(e) => CheckResult { + name, + status: CheckStatus::Failed(e), + }, + } +} + +// ============================================================================ +// Public entry point +// ============================================================================ + +pub fn run(fix: bool) -> ExitCode { + let results = vec![ + check_devbox(fix), + check_homebrew(fix), + check_applesimutils(fix), + ]; + + let mut any_missing = false; + let mut any_fixed = false; + let mut any_failed = false; + + for r in &results { + match &r.status { + CheckStatus::Ok => {} + CheckStatus::Missing => { + any_missing = true; + } + CheckStatus::Fixed => { + any_fixed = true; + } + CheckStatus::Failed(_) => { + any_failed = true; + } + } + } + + for r in &results { + match &r.status { + CheckStatus::Ok => {} + CheckStatus::Missing => { + eprintln!(" \u{2717} {} (not installed)", r.name); + } + CheckStatus::Fixed => { + eprintln!(" \u{2713} {} (installed)", r.name); + } + CheckStatus::Failed(e) => { + eprintln!(" \u{2717} {} — {}", r.name, e); + } + } + } + + if any_failed { + eprintln!("\nSome dependencies could not be installed."); + return ExitCode::FAILURE; + } + + if any_missing { + eprintln!("\nMissing dependencies. Run `segkit doctor --fix` to install them."); + return ExitCode::FAILURE; + } + + if any_fixed { + let fixed_names: Vec<&str> = results + .iter() + .filter(|r| matches!(r.status, CheckStatus::Fixed)) + .map(|r| r.name) + .collect(); + eprintln!( + "\nInstalled missing dependencies: {}.", + fixed_names.join(", ") + ); + return ExitCode::SUCCESS; + } + + ExitCode::SUCCESS +} diff --git a/segkit/src/main.rs b/segkit/src/main.rs index 465b825..f9cc988 100644 --- a/segkit/src/main.rs +++ b/segkit/src/main.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; mod delegate; -mod setup; +mod doctor; #[derive(Parser)] #[command(name = "segkit", version, about = "Segment SDK developer toolkit")] @@ -34,8 +34,12 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, - /// Check and install required dependencies (devbox) - Setup, + /// Check environment health and report missing dependencies + Doctor { + /// Automatically install missing dependencies + #[arg(long)] + fix: bool, + }, } fn main() -> ExitCode { @@ -46,7 +50,7 @@ fn main() -> ExitCode { Some(Commands::Ios { args }) => delegate::run("ios.sh", &args), Some(Commands::Rn { args }) => delegate::run("rn.sh", &args), Some(Commands::Metro { args }) => delegate::run("metro.sh", &args), - Some(Commands::Setup) => setup::run(), + Some(Commands::Doctor { fix }) => doctor::run(fix), None => { println!("segkit {}", env!("CARGO_PKG_VERSION")); ExitCode::SUCCESS diff --git a/segkit/src/setup.rs b/segkit/src/setup.rs deleted file mode 100644 index d1041ff..0000000 --- a/segkit/src/setup.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::process::{Command, ExitCode}; - -const DEVBOX_INSTALL_URL: &str = "https://get.jetify.com/devbox"; - -fn is_installed(cmd: &str) -> bool { - which::which(cmd).is_ok() -} - -fn install_devbox() -> Result<(), String> { - eprintln!("segkit: devbox not found, installing via {DEVBOX_INSTALL_URL}"); - - let curl_available = is_installed("curl"); - let wget_available = is_installed("wget"); - - let status = if curl_available { - Command::new("sh") - .args(["-c", &format!("curl -fsSL {DEVBOX_INSTALL_URL} | bash")]) - .status() - } else if wget_available { - Command::new("sh") - .args(["-c", &format!("wget -qO- {DEVBOX_INSTALL_URL} | bash")]) - .status() - } else { - return Err("neither curl nor wget found — cannot download devbox installer".into()); - }; - - match status { - Ok(s) if s.success() => Ok(()), - Ok(s) => Err(format!( - "devbox installer exited with code {}", - s.code().unwrap_or(-1) - )), - Err(e) => Err(format!("failed to run installer: {e}")), - } -} - -pub fn run() -> ExitCode { - if is_installed("devbox") { - println!("devbox: installed"); - } else { - if let Err(e) = install_devbox() { - eprintln!("segkit: {e}"); - return ExitCode::FAILURE; - } - if !is_installed("devbox") { - eprintln!( - "segkit: devbox installed but not found on PATH — you may need to restart your shell" - ); - return ExitCode::FAILURE; - } - println!("devbox: installed"); - } - - ExitCode::SUCCESS -} diff --git a/segkit/tests/cli.rs b/segkit/tests/cli.rs index 2d3f418..af709e8 100644 --- a/segkit/tests/cli.rs +++ b/segkit/tests/cli.rs @@ -24,7 +24,7 @@ fn help_flag() { .stdout(predicate::str::contains("ios")) .stdout(predicate::str::contains("rn")) .stdout(predicate::str::contains("metro")) - .stdout(predicate::str::contains("setup")); + .stdout(predicate::str::contains("doctor")); } #[test] @@ -81,9 +81,6 @@ fn metro_subcommand_without_script_fails_gracefully() { } #[test] -fn setup_detects_devbox() { - segkit() - .arg("setup") - .assert() - .stdout(predicate::str::contains("devbox:")); +fn doctor_succeeds_when_deps_present() { + segkit().arg("doctor").assert().success(); }