use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use rayon::prelude::*;
use crate::ci;
use crate::config::Config;
use crate::env::{
DotenvConfig, EnvContext, SecretsConfig, ShellConfig, collect_secrets, detect_shell,
generate_dotenv, preview_dotenv, preview_shell_rc, update_shell_rc,
};
use crate::error_codes;
use crate::hooks::{Hook, HookConfig, HookEnv};
use crate::onboarding::mark_initialized;
use crate::packages;
use crate::remote::fetch_remote_config;
use crate::services;
use crate::setup::setup;
use crate::telemetry;
use crate::tools;
#[allow(unsafe_code)] #[allow(clippy::too_many_arguments)]
pub fn run_setup(
file: &str,
from: Option<&str>,
role: Option<&str>,
no_hooks: bool,
dry_run: bool,
ci_flag: bool,
no_ci: bool,
jobs: usize,
sequential: bool,
ignore_missing_deps: bool,
header: &[String],
machine_id: Option<&str>,
) -> i32 {
let parallel_jobs = if sequential { 1 } else { jobs.max(1) };
if ignore_missing_deps {
unsafe { std::env::set_var("JARVY_IGNORE_MISSING_DEPS", "1") };
}
let ci_env = if ci_flag {
unsafe { std::env::set_var("JARVY_CI", "1") };
ci::detect()
} else if no_ci {
unsafe { std::env::set_var("JARVY_NO_CI", "1") };
None
} else {
ci::detect()
};
if let Some(ref env) = ci_env {
let output = env.output();
output.notice(&format!("Running in CI mode: {}", env.provider));
if let Some(ref build_id) = env.build_id {
output.debug(&format!("Build ID: {}", build_id));
}
}
let config_path = if let Some(url) = from {
match fetch_remote_config(url, header) {
Ok(path) => path,
Err(e) => {
eprintln!("Error fetching remote config: {}", e);
return error_codes::CONFIG_ERROR;
}
}
} else {
file.to_string()
};
let run_id = uuid::Uuid::now_v7();
let setup_span = tracing::info_span!("setup", run_id = %run_id);
let _setup_span_guard = setup_span.enter();
tracing::info!(
event = "setup.start",
run_id = %run_id,
version = env!("CARGO_PKG_VERSION"),
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
config_source = %if from.is_some() { "remote" } else { "local" },
config_path = %config_path,
ci_provider = ?ci_env.as_ref().map(|e| e.provider.to_string()),
jobs = parallel_jobs,
dry_run = dry_run,
);
let mut config = Config::new_with_workspace(&config_path);
if from.is_some() {
config.mark_remote();
}
let hooks_config = config.get_hooks();
let hook_settings = HookConfig::from(&hooks_config.config);
tools::set_default_use_sudo(config.use_sudo());
if !no_hooks {
if let Some(ref script) = hooks_config.pre_setup {
let hook = Hook::with_config(script, "pre_setup", hook_settings.clone())
.with_env(HookEnv::global());
if hook.run_with_policy(dry_run).is_err() {
return error_codes::HOOK_FAILED;
}
}
}
if !dry_run {
setup();
} else {
println!("[DRY-RUN] Would run platform setup");
}
if let Some(role_name) = role {
println!("Using role override: {}", role_name);
}
let tool_configs = config.get_tool_configs_with_role_override(role);
telemetry::setup_inventory(
&tool_configs
.values()
.map(|t| (t.name.clone(), t.version.clone()))
.collect::<Vec<_>>(),
role,
file,
machine_id,
);
println!("Checking tool versions...");
let version_check = tools::spec::check_tools_parallel(
tool_configs
.values()
.map(|t| (t.name.as_str(), t.version.as_str())),
);
println!("{}", version_check.summary_string());
if !dry_run
&& let Some(env) = crate::sandbox::detect()
&& let crate::sandbox::InstallCapability::VerifyOnly(reason) =
crate::sandbox::install_capability()
{
if version_check.needs_install.is_empty() && version_check.unknown.is_empty() {
let project_dir = std::path::Path::new(file)
.parent()
.unwrap_or(std::path::Path::new("."));
if !crate::paths::state_json(project_dir).exists() {
let known_tools_for_baseline: Vec<(&String, &crate::config::Tool)> = tool_configs
.iter()
.filter(|(_, t)| tools::get_tool(&t.name).is_some())
.collect();
capture_drift_baseline_borrowed(
project_dir,
std::path::Path::new(file),
&known_tools_for_baseline,
&[],
true,
);
}
tracing::info!(
event = "setup.verify_only.passed",
provider = %env.provider,
reason = %reason,
"sandbox verify-only mode passed; all configured tools present"
);
eprintln!(
"[jarvy] sandbox cannot install tools (read-only or no package manager); \
all configured tools already present — verify-only mode passed"
);
return error_codes::EXIT_SUCCESS;
} else {
let mut missing = String::with_capacity(64);
for (i, (n, _)) in version_check.needs_install.iter().enumerate() {
if i > 0 {
missing.push_str(", ");
}
missing.push_str(n);
}
tracing::warn!(
event = "setup.verify_only.refused",
provider = %env.provider,
reason = %reason,
missing = %missing,
"sandbox cannot install tools"
);
eprintln!(
"[jarvy] sandbox cannot install tools (read-only or no package manager); \
missing: {missing}"
);
return error_codes::PREREQ_MISSING;
}
}
if !version_check.satisfied.is_empty() {
println!(
"Already installed: {}",
version_check
.satisfied
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
let seamless = crate::sandbox::is_seamless();
let unsupported_channel = tools::unsupported::pick_channel(telemetry::is_enabled());
for (name, version) in &version_check.unknown {
let report = tools::unsupported::build_report(name, Some(version), unsupported_channel);
eprint!(
"{}",
tools::unsupported::to_human(&report, unsupported_channel, seamless)
);
let version_str = report.version.as_deref().unwrap_or("");
let suggestions_csv = report.suggestions.join(",");
if matches!(
unsupported_channel,
tools::unsupported::RequestChannel::Manual
) {
tracing::warn!(
event = "tool.unsupported",
tool = %report.tool,
version = %version_str,
source = %telemetry::Source::Config,
platform = %std::env::consts::OS,
suggestions = %suggestions_csv,
channel = %report.channel,
fallback_issue_url = %report.fallback_issue_url,
scaffold_cmd = %report.scaffold_cmd,
exit_code = report.exit_code,
"tool not in registry"
);
} else {
tracing::warn!(
event = "tool.unsupported",
tool = %report.tool,
version = %version_str,
source = %telemetry::Source::Config,
platform = %std::env::consts::OS,
suggestions = %suggestions_csv,
channel = %report.channel,
scaffold_cmd = %report.scaffold_cmd,
exit_code = report.exit_code,
"tool not in registry"
);
}
telemetry::tool_not_supported(
&report.tool,
report.version.as_deref(),
telemetry::Source::Config,
);
}
if !version_check.unknown.is_empty()
&& version_check.needs_install.is_empty()
&& version_check.satisfied.is_empty()
{
return error_codes::TOOL_UNSUPPORTED;
}
let known_tools: Vec<_> = tool_configs
.iter()
.filter(|(_, t)| tools::get_tool(&t.name).is_some())
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let ordered_tools = tools::spec::order_tools_by_dependencies(
version_check
.needs_install
.iter()
.map(|(n, v)| (n.as_str(), v.as_str())),
);
let tool_groups = tools::spec::group_tools_for_installation(
ordered_tools.iter().map(|(n, v)| (n.as_str(), v.as_str())),
);
let mut successfully_installed: Vec<(String, String)> = Vec::new();
if dry_run {
for (pm, packages) in &tool_groups.by_package_manager {
let package_names: Vec<&str> =
packages.iter().map(|(_, pkg, _)| pkg.as_str()).collect();
println!(
"[DRY-RUN] Would batch install via {:?}: {}",
pm,
package_names.join(", ")
);
}
for (name, version) in &tool_groups.custom_install {
println!(
"[DRY-RUN] Would install {} version {} using custom installer",
name, version
);
}
} else {
telemetry::setup_started(version_check.needs_install.len());
let _setup_start = telemetry::now();
for (pm, packages) in &tool_groups.by_package_manager {
if packages.is_empty() {
continue;
}
for (tool_name, _, version) in packages {
telemetry::tool_requested(tool_name, version, telemetry::Source::Config);
}
let package_names: Vec<&str> =
packages.iter().map(|(_, pkg, _)| pkg.as_str()).collect();
println!(
"Batch installing {} packages via {:?}: {}",
packages.len(),
pm,
package_names.join(", ")
);
let install_start = telemetry::now();
match tools::common::PkgOps::batch_install(*pm, &package_names, None) {
Ok(result) => {
let batch_duration = install_start.elapsed();
for pkg_name in &result.succeeded {
if let Some((tool_name, _, version)) =
packages.iter().find(|(_, pkg, _)| pkg == pkg_name)
{
println!("Successfully installed {} ({})", tool_name, version);
successfully_installed.push((tool_name.clone(), version.clone()));
telemetry::tool_installed(
tool_name,
version,
&format!("{:?}", pm),
batch_duration,
);
}
}
for (pkg_name, error) in &result.failed {
if let Some((tool_name, _, version)) =
packages.iter().find(|(_, pkg, _)| pkg == pkg_name)
{
let msg =
format!("Failed to install {} ({}): {}", tool_name, version, error);
eprintln!("{}", msg);
telemetry::tool_failed(tool_name, version, error);
}
}
}
Err(e) => {
let kind = e.kind();
if e.is_no_platform_installer() {
for (tool_name, _, version) in packages {
tracing::info!(
event = "tool.unsupported",
tool = %tool_name,
version = %version,
source = "config",
channel = "registered_no_platform_installer",
platform = std::env::consts::OS,
exit_code = error_codes::TOOL_UNSUPPORTED,
);
telemetry::tool_not_supported(
tool_name,
Some(version),
telemetry::Source::Config,
);
eprintln!(
" {} has no installer on this platform; skipping.",
tool_name
);
}
} else {
for (tool_name, _, version) in packages {
let msg =
format!("Failed to install {} ({}): {:?}", tool_name, version, e);
eprintln!("{}", msg);
telemetry::tool_failed_with_kind(
tool_name,
version,
kind,
&format!("{:?}", e),
);
}
}
}
}
}
if !tool_groups.custom_install.is_empty() {
for (name, version) in &tool_groups.custom_install {
telemetry::tool_requested(name, version, telemetry::Source::Config);
}
let custom_count = tool_groups.custom_install.len();
let effective_jobs = parallel_jobs.min(custom_count);
if effective_jobs > 1 {
println!(
"Installing {} custom tools with {} parallel jobs",
custom_count, effective_jobs
);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(effective_jobs)
.build()
.unwrap_or_else(|_| rayon::ThreadPoolBuilder::new().build().unwrap());
let success_collector: Arc<Mutex<Vec<(String, String)>>> =
Arc::new(Mutex::new(Vec::new()));
let error_collector: Arc<Mutex<Vec<(String, String, String)>>> =
Arc::new(Mutex::new(Vec::new()));
let parent_span = tracing::Span::current();
pool.install(|| {
tool_groups
.custom_install
.par_iter()
.for_each(|(name, version)| {
let _g = parent_span.enter();
let install_span = tracing::info_span!(
"install_tool",
tool = %name,
version = %version,
);
let _is = install_span.enter();
let install_start = std::time::Instant::now();
println!(
"Installing {} version {} using custom installer",
name, version
);
match tools::add(name, version) {
Ok(()) => {
println!("Successfully installed {} ({})", name, version);
telemetry::tool_installed(
name,
version,
"custom",
install_start.elapsed(),
);
if let Ok(mut guard) = success_collector.lock() {
guard.push((name.clone(), version.clone()));
}
}
Err(e) => {
let msg = format!(
"Failed to install {} ({}): {:?}",
name, version, e
);
eprintln!("{}", msg);
if let Ok(mut guard) = error_collector.lock() {
guard.push((
name.clone(),
version.clone(),
format!("{:?}", e),
));
}
}
}
});
});
if let Ok(guard) = success_collector.lock() {
successfully_installed.extend(guard.iter().cloned());
}
if let Ok(guard) = error_collector.lock() {
for (name, version, error) in guard.iter() {
telemetry::tool_failed(name, version, error);
}
}
} else {
for (name, version) in &tool_groups.custom_install {
let install_start = std::time::Instant::now();
println!(
"Installing {} version {} using custom installer",
name, version
);
match tools::add(name, version) {
Ok(()) => {
println!("Successfully installed {} ({})", name, version);
telemetry::tool_installed(
name,
version,
"custom",
install_start.elapsed(),
);
successfully_installed.push((name.clone(), version.clone()));
}
Err(e) => {
if e.is_no_platform_installer() {
tracing::info!(
event = "tool.unsupported",
tool = %name,
version = %version,
source = "config",
channel = "registered_no_platform_installer",
platform = std::env::consts::OS,
exit_code = error_codes::TOOL_UNSUPPORTED,
);
telemetry::tool_not_supported(
name,
Some(version),
telemetry::Source::Config,
);
eprintln!(
" {} has no installer on this platform; skipping.",
name
);
} else {
let msg =
format!("Failed to install {} ({}): {:?}", name, version, e);
eprintln!("{}", msg);
telemetry::tool_failed_with_kind(
name,
version,
e.kind(),
&format!("{:?}", e),
);
}
}
}
}
}
}
if !no_hooks {
for (tool_name, version) in &successfully_installed {
let user_hook = config
.get_tool_hooks(tool_name)
.and_then(|h| h.post_install.as_ref());
if let Some(script) = user_hook {
let env = HookEnv::for_tool(tool_name, version);
let hook = Hook::with_config(
script,
&format!("{} post_install", tool_name),
hook_settings.clone(),
)
.with_env(env);
if hook.run_with_policy(false).is_err() {
return error_codes::HOOK_FAILED;
}
} else if let Some(default_hook) = tools::spec::get_tool_default_hook(tool_name) {
println!(
"Running default hook for {}: {}",
tool_name, default_hook.description
);
let env = HookEnv::for_tool(tool_name, version);
let advisory_settings = HookConfig {
continue_on_error: true,
..hook_settings.clone()
};
let hook = Hook::with_config(
default_hook.script,
&format!("{} default_hook", tool_name),
advisory_settings,
)
.with_env(env);
let _ = hook.run_with_policy(false);
}
}
}
}
if dry_run && !no_hooks {
for (_, tool) in &known_tools {
let user_hook = config
.get_tool_hooks(&tool.name)
.and_then(|h| h.post_install.as_ref());
if let Some(script) = user_hook {
let env = HookEnv::for_tool(&tool.name, &tool.version);
let hook = Hook::with_config(
script,
&format!("{} post_install", tool.name),
hook_settings.clone(),
)
.with_env(env);
hook.dry_run();
} else if let Some(default_hook) = tools::spec::get_tool_default_hook(&tool.name) {
println!(
"[DRY-RUN] Would run default hook for {}: {}",
tool.name, default_hook.description
);
let env = HookEnv::for_tool(&tool.name, &tool.version);
let hook = Hook::with_config(
default_hook.script,
&format!("{} default_hook", tool.name),
hook_settings.clone(),
)
.with_env(env);
hook.dry_run();
}
}
}
run_packages_phase(&config, file, dry_run);
run_git_phase(&config, dry_run);
run_ai_hooks_phase(&config, dry_run);
run_mcp_register_phase(&config, dry_run);
let env_config = config.get_env();
let env_settings = &env_config.config;
if !env_config.vars.is_empty() || !env_config.secrets.is_empty() {
let ctx = EnvContext::new();
let secrets_config = SecretsConfig {
ci_mode: SecretsConfig::default().ci_mode || dry_run,
fail_on_missing: false,
};
let secrets = if !dry_run && !env_config.secrets.is_empty() {
match collect_secrets(&env_config.secrets, &ctx, &secrets_config) {
Ok(s) => s,
Err(e) => {
eprintln!("Warning: Could not collect secrets: {}", e);
HashMap::new()
}
}
} else {
HashMap::new()
};
let mut all_vars: HashMap<String, String> = env_config
.vars
.iter()
.map(|(k, v)| (k.clone(), v.value().to_string()))
.collect();
all_vars.extend(secrets);
if env_settings.generate_dotenv {
let dotenv_path = std::path::Path::new(".env");
let dotenv_config = DotenvConfig {
backup: true,
force: false,
add_to_gitignore: env_settings.add_to_gitignore,
};
if dry_run {
println!("\n=== Environment Setup (dry-run) ===");
println!(
"[DRY-RUN] Would generate .env file at {}",
dotenv_path.display()
);
let preview = preview_dotenv(&all_vars, &ctx);
println!("{}", preview);
} else {
match generate_dotenv(dotenv_path, &all_vars, &ctx, &dotenv_config) {
Ok(_) => println!("\nGenerated .env file at {}", dotenv_path.display()),
Err(e) => eprintln!("Warning: Could not generate .env file: {}", e),
}
}
}
if env_settings.update_rc {
let shell = detect_shell();
let shell_config = ShellConfig {
backup: true,
validate: false,
};
if dry_run {
if !env_settings.generate_dotenv {
println!("\n=== Environment Setup (dry-run) ===");
}
println!("[DRY-RUN] Would update shell rc for {}", shell);
let preview = preview_shell_rc(shell, &all_vars, &ctx);
println!("{}", preview);
} else {
match update_shell_rc(shell, &all_vars, &ctx, &shell_config) {
Ok(path) => println!("Updated shell rc at {}", path.display()),
Err(e) => eprintln!("Warning: Could not update shell rc: {}", e),
}
}
}
}
if !no_hooks {
if let Some(ref script) = hooks_config.post_setup {
let hook = Hook::with_config(script, "post_setup", hook_settings.clone())
.with_env(HookEnv::global());
if hook.run_with_policy(dry_run).is_err() {
return error_codes::HOOK_FAILED;
}
}
}
run_services_phase(&config, file, ci_env.is_some(), dry_run);
if config.has_hooks() && !no_hooks {
println!("\nHooks execution summary:");
if hooks_config.pre_setup.is_some() {
println!(" - pre_setup: executed");
}
let tool_hooks_count = hooks_config
.tool_hooks
.values()
.filter(|h| h.post_install.is_some())
.count();
if tool_hooks_count > 0 {
println!(" - tool post_install hooks: {} executed", tool_hooks_count);
}
if hooks_config.post_setup.is_some() {
println!(" - post_setup: executed");
}
}
if !dry_run {
let drift_config = config.drift.clone().unwrap_or_default();
let project_dir = std::path::Path::new(file)
.parent()
.unwrap_or(std::path::Path::new("."));
let auto_baseline_eligible = !drift_config.enabled
&& crate::sandbox::is_seamless()
&& version_check.needs_install.is_empty()
&& version_check.unknown.is_empty()
&& !crate::paths::state_json(project_dir).exists();
if drift_config.enabled || auto_baseline_eligible {
capture_drift_baseline(
project_dir,
std::path::Path::new(file),
&known_tools,
&drift_config.track_files,
auto_baseline_eligible,
);
}
}
if !dry_run {
let _ = mark_initialized();
}
if !dry_run && !successfully_installed.is_empty() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "$SHELL".to_string());
println!("\nTip: open a new terminal or run `exec {shell}` to pick up new PATH entries.");
}
if !dry_run {
emit_telemetry_hint_if_undecided();
}
0
}
fn emit_telemetry_hint_if_undecided() {
use std::fs;
if !crate::telemetry::is_enabled() {
return;
}
let Some(home) = dirs::home_dir() else {
return;
};
let config_path = home.join(".jarvy").join("config.toml");
let content = fs::read_to_string(&config_path).unwrap_or_default();
if crate::telemetry::user_decided(&content) {
return;
}
eprintln!(
"\nNote: Jarvy telemetry is opt-out and currently on. Anonymized usage data helps prioritize fixes.\n Disable with: jarvy telemetry disable | Details: https://jarvy.dev/telemetry/"
);
crate::telemetry::undecided_nudge_shown();
}
fn preview_packages<'a, I>(ecosystem: &str, scope_label: &str, names: I)
where
I: IntoIterator<Item = &'a str>,
{
print!("{}", render_package_preview(ecosystem, scope_label, names));
}
pub(crate) fn render_package_preview<'a, I>(ecosystem: &str, scope_label: &str, names: I) -> String
where
I: IntoIterator<Item = &'a str>,
{
let mut names: Vec<&str> = names.into_iter().collect();
names.sort_unstable();
let count = names.len();
let (label, command_hint) = match ecosystem {
".NET global tool" => (
".NET global tool(s)",
" via `dotnet tool update -g`".to_string(),
),
other => (
if count == 1 { "package" } else { "packages" },
format!(" via `{} install`", other),
),
};
let mut out = String::with_capacity(64 + count * 32);
use std::fmt::Write;
let _ = writeln!(
out,
"[DRY-RUN] Would install {} {}{} ({})",
count, label, command_hint, scope_label
);
for name in names {
let _ = writeln!(
out,
"[DRY-RUN] - {}",
crate::observability::redact_for_display(name)
);
}
out
}
fn run_packages_phase(config: &Config, file: &str, dry_run: bool) {
let telemetry_on = crate::observability::telemetry_gate::is_enabled();
let packages_ref = config.packages_ref();
let project_dir = std::path::Path::new(file)
.parent()
.unwrap_or(std::path::Path::new("."));
let has_npm = packages_ref.npm.is_some();
let has_pip = packages_ref.pip.is_some();
let has_cargo = packages_ref.cargo.is_some();
let has_nuget = packages_ref.nuget.is_some();
let backend_count =
(has_npm as u32) + (has_pip as u32) + (has_cargo as u32) + (has_nuget as u32);
let _span = tracing::info_span!(
"packages",
dry_run = %dry_run,
npm = %has_npm,
pip = %has_pip,
cargo = %has_cargo,
nuget = %has_nuget,
)
.entered();
if telemetry_on {
tracing::info!(
event = "packages.phase_started",
dry_run,
backend_count,
npm = has_npm,
pip = has_pip,
cargo = has_cargo,
nuget = has_nuget,
);
}
if !config.has_packages() {
if telemetry_on {
tracing::info!(
event = "packages.phase_skipped",
reason = "no_packages_configured",
dry_run,
);
}
return;
}
let started = std::time::Instant::now();
if dry_run {
println!("\n=== Package Dependencies (dry-run) ===");
if telemetry_on {
tracing::info!(
event = "packages.phase_previewed",
npm_count = packages_ref.npm.map(|c| c.packages.len()).unwrap_or(0),
pip_count = packages_ref.pip.map(|c| c.packages.len()).unwrap_or(0),
cargo_count = packages_ref.cargo.map(|c| c.packages.len()).unwrap_or(0),
nuget_count = packages_ref.nuget.map(|c| c.packages.len()).unwrap_or(0),
);
}
if let Some(npm) = packages_ref.npm {
preview_packages(
"npm",
"project-local",
npm.packages.keys().map(String::as_str),
);
}
if let Some(pip) = packages_ref.pip {
preview_packages(
"pip",
"project-local",
pip.packages.keys().map(String::as_str),
);
}
if let Some(cargo) = packages_ref.cargo {
preview_packages(
"cargo",
"user-global",
cargo.packages.keys().map(String::as_str),
);
}
if let Some(nuget) = packages_ref.nuget {
preview_packages(
".NET global tool",
"machine-global",
nuget.packages.keys().map(String::as_str),
);
}
} else {
println!("\n=== Installing Package Dependencies ===");
if let Err(e) = packages::install_packages(packages_ref, project_dir) {
if telemetry_on {
tracing::error!(
event = "packages.install_failed",
error_kind = e.kind(),
error = %e,
);
}
eprintln!("Warning: Package installation failed: {}", e);
}
}
if telemetry_on {
tracing::info!(
event = "packages.phase_completed",
dry_run,
backend_count,
duration_ms = started.elapsed().as_millis() as u64,
);
}
}
fn run_git_phase(config: &Config, dry_run: bool) {
if !config.has_git() {
return;
}
let Some(git_config) = config.get_git() else {
return;
};
if dry_run {
println!("\n=== Git Configuration (dry-run) ===");
if let Some(ref name) = git_config.user_name {
if let Some(resolved) = name.resolve() {
println!("[DRY-RUN] Would set git config user.name: {resolved}");
}
}
if let Some(ref email) = git_config.user_email {
if let Some(resolved) = email.resolve() {
println!("[DRY-RUN] Would set git config user.email: {resolved}");
}
}
if git_config.signing {
println!("[DRY-RUN] Would enable commit signing");
if let Some(ref key) = git_config.signing_key {
println!("[DRY-RUN] Would set signing key: {key}");
}
}
if let Some(ref branch) = git_config.default_branch {
println!("[DRY-RUN] Would set init.defaultBranch: {branch}");
}
if !git_config.aliases.is_empty() {
println!(
"[DRY-RUN] Would configure {} git aliases",
git_config.aliases.len()
);
}
} else {
println!("\n=== Git Configuration ===");
let setup = crate::git::GitSetup::new(git_config.clone());
match setup.configure() {
Ok(()) => println!("Git configuration applied successfully"),
Err(e) => eprintln!("Warning: Git configuration failed: {e}"),
}
}
}
fn run_ai_hooks_phase(config: &Config, dry_run: bool) {
let Some(ref ai_cfg) = config.ai_hooks else {
return;
};
if ai_cfg.is_empty() {
return;
}
let agent_count = ai_cfg.unique_agents().len();
let hooks_count = ai_cfg.hooks.len();
let scope_label = match ai_cfg.scope {
crate::ai_hooks::HookScope::User => "user",
crate::ai_hooks::HookScope::Project => "project",
};
let _span = tracing::info_span!(
"ai_hooks",
agents = %agent_count,
scope = %scope_label,
dry_run = %dry_run,
)
.entered();
if dry_run {
println!("\n=== AI Hooks (dry-run) ===");
println!(
"[DRY-RUN] Would provision {} hook(s) for: {:?}",
hooks_count,
ai_cfg.unique_agents()
);
crate::telemetry::ai_hook_phase_started(agent_count, hooks_count, scope_label, true);
return;
}
println!("\n=== AI Hooks ===");
let started = std::time::Instant::now();
crate::telemetry::ai_hook_phase_started(agent_count, hooks_count, scope_label, false);
match crate::ai_hooks::apply(ai_cfg) {
Ok(report) => {
println!(
" Applied {} hook(s) across {} agent(s)",
report.total_applied(),
report.successes.len()
);
for outcome in &report.successes {
println!(" {:<13} {}", outcome.agent, outcome.path.display());
for w in &outcome.warnings {
println!(" warning: {w}");
}
crate::telemetry::ai_hook_agent_applied(
outcome.agent,
outcome.applied,
outcome.warnings.len(),
&outcome.path,
);
}
for (target, e) in &report.failures {
eprintln!(
" {:<13} FAILED ({}): {} — other agents still applied",
target.slug(),
e.kind(),
e
);
crate::telemetry::ai_hook_agent_failed(target.slug(), e.kind());
}
if !report.refused_custom.is_empty() {
println!(
" Refused {} custom hook(s) (set allow_custom_commands = true to apply)",
report.refused_custom.len()
);
}
if !report.remote_refused_custom.is_empty() {
println!(
" Refused {} custom hook(s) from remote-fetched config (trust boundary)",
report.remote_refused_custom.len()
);
}
crate::telemetry::ai_hook_custom_refused_summary(
report.refused_custom.len(),
report.remote_refused_custom.len(),
);
crate::telemetry::ai_hook_phase_completed(
report.total_applied(),
report.agents_touched(),
report.refused_custom.len(),
report.remote_refused_custom.len(),
report.failures.len(),
started.elapsed(),
);
}
Err(e) => {
eprintln!(" Warning: AI hook provisioning failed: {e}");
crate::telemetry::ai_hook_agent_failed("global", e.kind());
crate::telemetry::ai_hook_phase_completed(0, 0, 0, 0, 1, started.elapsed());
}
}
}
fn run_mcp_register_phase(config: &Config, dry_run: bool) {
let Some(ref mcp_cfg) = config.mcp_register else {
return;
};
if mcp_cfg.is_empty() {
return;
}
let agent_count = mcp_cfg.unique_agents().len();
let servers_count = mcp_cfg.servers.len() + 1; let scope_label = match mcp_cfg.scope {
crate::mcp_register::McpRegistrationScope::User => "user",
crate::mcp_register::McpRegistrationScope::Project => "project",
};
let _span = tracing::info_span!(
"mcp_register",
agents = %agent_count,
scope = %scope_label,
dry_run = %dry_run,
)
.entered();
if dry_run {
println!("\n=== MCP Registration (dry-run) ===");
println!(
"[DRY-RUN] Would register {} server(s) with: {:?}",
servers_count,
mcp_cfg.unique_agents()
);
return;
}
println!("\n=== MCP Registration ===");
let started = std::time::Instant::now();
crate::telemetry::mcp_register_phase_started(agent_count, servers_count, scope_label);
match crate::mcp_register::apply(mcp_cfg) {
Ok(report) => {
println!(
" Registered {} server(s) across {} agent(s)",
report.total_applied(),
report.successes.len()
);
for o in &report.successes {
println!(" {:<13} {}", o.agent, o.path.display());
for w in &o.warnings {
println!(" warning: {w}");
}
crate::telemetry::mcp_register_agent_applied(o.agent, o.applied, &o.path);
}
for (target, e) in &report.failures {
eprintln!(
" {:<13} FAILED ({}): {} — other agents still applied",
target.slug(),
e.kind(),
e
);
crate::telemetry::mcp_register_agent_failed(target.slug(), e.kind());
}
if !report.refused_custom.is_empty() {
println!(
" Refused {} custom server(s) (set allow_custom_servers = true to apply)",
report.refused_custom.len()
);
}
if !report.remote_refused.is_empty() {
println!(
" Refused {} custom server(s) from remote-fetched config (trust boundary)",
report.remote_refused.len()
);
}
crate::telemetry::mcp_register_phase_completed(
report.total_applied(),
report.agents_touched(),
report.refused_custom.len(),
report.remote_refused.len(),
report.failures.len(),
started.elapsed(),
);
}
Err(e) => {
eprintln!(" Warning: MCP registration failed: {e}");
crate::telemetry::mcp_register_agent_failed("global", e.kind());
crate::telemetry::mcp_register_phase_completed(0, 0, 0, 0, 1, started.elapsed());
}
}
}
fn run_services_phase(config: &Config, file: &str, is_ci: bool, dry_run: bool) {
let services_config = &config.services;
if !services_config.should_auto_start(is_ci) {
return;
}
let working_dir = std::path::Path::new(file)
.parent()
.unwrap_or(std::path::Path::new("."));
let Some((backend, config_path)) = services::detect_backend_with_config(
working_dir,
services_config.compose_file.as_deref(),
services_config.tilt_file.as_deref(),
) else {
return;
};
let backend_impl = services::get_backend(backend);
if !backend_impl.is_installed() {
eprintln!(
"Note: {backend} config found but {backend} is not installed. \
Skipping services auto-start."
);
return;
}
if dry_run {
println!("\n[DRY-RUN] Would auto-start {backend} services");
} else {
println!("\nAuto-starting {backend} services...");
match backend_impl.start(&config_path, true) {
Ok(result) => println!("{}", result.message),
Err(e) => {
eprintln!("Warning: Failed to auto-start services: {e}");
}
}
}
}
fn detect_install_method(tool: &str) -> String {
crate::tools::install_method::detect_install_method_for_tool(tool).to_string()
}
fn capture_drift_baseline(
project_dir: &std::path::Path,
config_path: &std::path::Path,
known_tools: &[(String, crate::config::Tool)],
track_files: &[String],
auto: bool,
) {
let borrowed: Vec<(&String, &crate::config::Tool)> =
known_tools.iter().map(|(k, v)| (k, v)).collect();
capture_drift_baseline_borrowed(project_dir, config_path, &borrowed, track_files, auto)
}
fn capture_drift_baseline_borrowed(
project_dir: &std::path::Path,
config_path: &std::path::Path,
known_tools: &[(&String, &crate::config::Tool)],
track_files: &[String],
auto: bool,
) {
let mut state = crate::drift::EnvironmentState::new();
for (tool_name, tool) in known_tools {
if let Ok(path) = which::which(tool_name.as_str()) {
state.set_tool(
tool_name,
&tool.version,
&path,
&detect_install_method(tool_name),
);
}
}
for file_path in track_files {
let full_path = project_dir.join(file_path);
if full_path.exists()
&& let Ok(hash) = crate::drift::state::hash_file(&full_path)
{
state.set_file_hash(file_path, &hash);
}
}
if config_path.exists()
&& let Ok(hash) = crate::drift::state::hash_file(config_path)
{
state.set_config_hash(&hash);
}
match state.save(project_dir) {
Err(e) => eprintln!("Warning: Could not save drift detection state: {}", e),
Ok(()) if auto => {
tracing::info!(
event = "drift.baseline.auto_captured",
tool_count = state.tool_count(),
provider = %crate::sandbox::detect()
.map(|e| e.provider.to_string())
.unwrap_or_default(),
"auto-baselined drift state for seamless mode"
);
eprintln!(
"[jarvy] auto-baselined drift state for seamless mode ({} tools)",
state.tool_count()
);
}
Ok(()) => {
tracing::info!(
event = "drift.baseline.captured",
tool_count = state.tool_count(),
"drift detection baseline captured"
);
println!(
"\nDrift detection baseline captured ({} tools)",
state.tool_count()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_package_preview_zero_count() {
let out: String = render_package_preview("npm", "project-local", std::iter::empty());
assert!(
out.starts_with("[DRY-RUN] Would install 0 packages via `npm install` (project-local)"),
"got: {out:?}"
);
assert_eq!(out.lines().count(), 1, "got: {out:?}");
}
#[test]
fn render_package_preview_singular() {
let out = render_package_preview("cargo", "user-global", ["cargo-watch"]);
assert!(
out.contains("Would install 1 package via `cargo install` (user-global)"),
"expected singular 'package', got: {out:?}"
);
assert!(out.contains("[DRY-RUN] - cargo-watch"));
}
#[test]
fn render_package_preview_plural() {
let out = render_package_preview("npm", "project-local", ["b", "a", "c"]);
assert!(out.contains("Would install 3 packages via `npm install` (project-local)"));
let body: Vec<&str> = out
.lines()
.filter(|l| l.starts_with("[DRY-RUN] - "))
.collect();
assert_eq!(
body,
["[DRY-RUN] - a", "[DRY-RUN] - b", "[DRY-RUN] - c"]
);
}
#[test]
fn render_package_preview_dotnet_label_uses_canonical_string() {
let out = render_package_preview(
".NET global tool",
"machine-global",
["dotnet-ef", "csharpier"],
);
assert!(
out.contains(
"[DRY-RUN] Would install 2 .NET global tool(s) via `dotnet tool update -g` (machine-global)"
),
"got: {out:?}"
);
}
#[test]
fn render_package_preview_redacts_control_bytes() {
let out = render_package_preview(".NET global tool", "machine-global", ["\u{1b}[2J"]);
assert!(
!out.contains('\u{1b}'),
"control byte leaked through preview: {out:?}"
);
assert!(out.contains("[DRY-RUN] - ?"));
}
fn config_from(toml: &str) -> Config {
Config::from_toml_str(toml).expect("test toml must parse")
}
#[test]
fn run_packages_phase_no_packages_section_is_noop() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
"#,
);
run_packages_phase(&cfg, "jarvy.toml", true);
}
#[test]
fn run_packages_phase_dry_run_does_not_invoke_pm() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[npm]
typescript = "^5.0"
"#,
);
run_packages_phase(&cfg, "jarvy.toml", true);
}
#[test]
fn run_git_phase_no_git_section_is_noop() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
"#,
);
run_git_phase(&cfg, true);
}
#[test]
fn run_git_phase_dry_run_does_not_invoke_git() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[git]
user_name = "Test User"
user_email = "test@example.com"
default_branch = "main"
[git.aliases]
co = "checkout"
"#,
);
run_git_phase(&cfg, true);
}
#[test]
fn run_services_phase_disabled_is_noop() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
"#,
);
run_services_phase(&cfg, "jarvy.toml", false, true);
}
#[test]
fn run_services_phase_dry_run_with_compose_file_does_not_invoke_docker() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[services]
enabled = true
auto_start = true
"#,
);
run_services_phase(&cfg, "jarvy.toml", false, true);
}
#[test]
fn run_ai_hooks_phase_skips_when_section_missing() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
"#,
);
run_ai_hooks_phase(&cfg, false);
}
#[test]
fn run_ai_hooks_phase_skips_when_empty() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[ai_hooks]
agents = []
"#,
);
run_ai_hooks_phase(&cfg, false);
}
#[test]
fn run_mcp_register_phase_skips_when_section_missing() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
"#,
);
run_mcp_register_phase(&cfg, false);
}
#[test]
fn run_mcp_register_phase_skips_when_empty() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[mcp_register]
agents = []
"#,
);
run_mcp_register_phase(&cfg, false);
}
#[test]
fn run_mcp_register_phase_dry_run_does_not_write_disk() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[mcp_register]
agents = ["claude-code"]
"#,
);
run_mcp_register_phase(&cfg, true);
}
#[test]
fn run_ai_hooks_phase_dry_run_does_not_write_disk() {
let cfg = config_from(
r#"
[provisioner]
git = "latest"
[ai_hooks]
agents = ["claude-code"]
[[ai_hooks.hook]]
use = "block-rm-rf"
"#,
);
run_ai_hooks_phase(&cfg, true);
}
}