Skip to main content

sentinel_proxy/bundle/
commands.rs

1//! Bundle CLI command handlers
2//!
3//! Implements the `sentinel bundle` subcommand and its subcommands.
4
5use crate::bundle::fetch::{detect_arch, detect_os, download_agent};
6use crate::bundle::install::{
7    generate_default_config, generate_systemd_service, install_binary, install_config,
8    install_systemd_service, uninstall_binary, InstallPaths,
9};
10use crate::bundle::lock::BundleLock;
11use crate::bundle::status::BundleStatus;
12use anyhow::{Context, Result};
13use clap::{Args, Subcommand};
14use std::path::PathBuf;
15
16/// Bundle command arguments
17#[derive(Args, Debug)]
18pub struct BundleArgs {
19    #[command(subcommand)]
20    pub command: BundleCommand,
21}
22
23/// Bundle subcommands
24#[derive(Subcommand, Debug)]
25pub enum BundleCommand {
26    /// Install bundled agents
27    Install {
28        /// Specific agent to install (installs all if not specified)
29        agent: Option<String>,
30
31        /// Preview what would be installed without making changes
32        #[arg(long, short = 'n')]
33        dry_run: bool,
34
35        /// Force reinstallation even if already installed
36        #[arg(long, short = 'f')]
37        force: bool,
38
39        /// Also install systemd service files
40        #[arg(long)]
41        systemd: bool,
42
43        /// Custom installation prefix
44        #[arg(long)]
45        prefix: Option<PathBuf>,
46
47        /// Skip checksum verification
48        #[arg(long)]
49        skip_verify: bool,
50    },
51
52    /// Show status of installed agents
53    Status {
54        /// Show detailed output
55        #[arg(long, short = 'v')]
56        verbose: bool,
57    },
58
59    /// List available agents in the bundle
60    List {
61        /// Show detailed information
62        #[arg(long, short = 'v')]
63        verbose: bool,
64    },
65
66    /// Uninstall bundled agents
67    Uninstall {
68        /// Specific agent to uninstall (uninstalls all if not specified)
69        agent: Option<String>,
70
71        /// Preview what would be uninstalled
72        #[arg(long, short = 'n')]
73        dry_run: bool,
74    },
75
76    /// Check for updates to bundled agents
77    Update {
78        /// Actually perform the update
79        #[arg(long)]
80        apply: bool,
81    },
82}
83
84/// Run the bundle command
85pub fn run_bundle_command(args: BundleArgs) -> Result<()> {
86    // Load the embedded lock file
87    let lock = BundleLock::embedded().context("Failed to load bundle lock file")?;
88
89    match args.command {
90        BundleCommand::Install {
91            agent,
92            dry_run,
93            force,
94            systemd,
95            prefix,
96            skip_verify,
97        } => cmd_install(&lock, agent, dry_run, force, systemd, prefix, skip_verify),
98
99        BundleCommand::Status { verbose } => cmd_status(&lock, verbose),
100
101        BundleCommand::List { verbose } => cmd_list(&lock, verbose),
102
103        BundleCommand::Uninstall { agent, dry_run } => cmd_uninstall(&lock, agent, dry_run),
104
105        BundleCommand::Update { apply } => cmd_update(&lock, apply),
106    }
107}
108
109/// Install command implementation
110fn cmd_install(
111    lock: &BundleLock,
112    agent: Option<String>,
113    dry_run: bool,
114    force: bool,
115    install_systemd: bool,
116    prefix: Option<PathBuf>,
117    skip_verify: bool,
118) -> Result<()> {
119    let paths = match prefix {
120        Some(p) => InstallPaths::with_prefix(&p),
121        None => InstallPaths::detect(),
122    };
123
124    println!("Sentinel Bundle Installer");
125    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
126    println!("Bundle version: {}", lock.bundle.version);
127    println!("Platform:       {}-{}", detect_os(), detect_arch());
128    println!("Install path:   {}", paths.bin_dir.display());
129    if paths.system_wide {
130        println!("Mode:           system-wide (requires root)");
131    } else {
132        println!("Mode:           user-local");
133    }
134    println!();
135
136    // Get agents to install
137    let agents: Vec<_> = match &agent {
138        Some(name) => {
139            let agent_info = lock
140                .agent(name)
141                .ok_or_else(|| anyhow::anyhow!("Unknown agent: {}", name))?;
142            vec![agent_info]
143        }
144        None => lock.agents(),
145    };
146
147    if agents.is_empty() {
148        println!("No agents to install.");
149        return Ok(());
150    }
151
152    // Check current status
153    let status = BundleStatus::check(lock, &paths);
154
155    if dry_run {
156        println!("[DRY RUN] Would install the following agents:");
157        println!();
158        for agent in &agents {
159            let agent_status = status
160                .agents
161                .iter()
162                .find(|a| a.name == agent.name);
163
164            let action = match agent_status {
165                Some(s) if s.status == crate::bundle::status::Status::UpToDate && !force => {
166                    "skip (already installed)"
167                }
168                Some(s) if s.status == crate::bundle::status::Status::Outdated => {
169                    "upgrade"
170                }
171                _ => "install",
172            };
173
174            println!(
175                "  {} {} -> {} ({})",
176                agent.name, agent.version, paths.bin_dir.display(), action
177            );
178        }
179        return Ok(());
180    }
181
182    // Ensure directories exist
183    paths.ensure_dirs().context("Failed to create installation directories")?;
184
185    // Create temporary directory for downloads
186    let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
187
188    // Create async runtime for downloads
189    let rt = tokio::runtime::Runtime::new()?;
190
191    // Install each agent
192    let mut installed = 0;
193    let mut skipped = 0;
194    let mut failed = 0;
195
196    for agent in &agents {
197        let agent_status = status.agents.iter().find(|a| a.name == agent.name);
198
199        // Skip if already installed (unless forced)
200        if !force {
201            if let Some(s) = agent_status {
202                if s.status == crate::bundle::status::Status::UpToDate {
203                    println!("  [skip] {} {} (already installed)", agent.name, agent.version);
204                    skipped += 1;
205                    continue;
206                }
207            }
208        }
209
210        print!("  Installing {} {}...", agent.name, agent.version);
211
212        // Download
213        let download_result = rt.block_on(async {
214            download_agent(agent, temp_dir.path(), !skip_verify).await
215        });
216
217        let download = match download_result {
218            Ok(d) => d,
219            Err(e) => {
220                println!(" FAILED");
221                eprintln!("    Error: {}", e);
222                failed += 1;
223                continue;
224            }
225        };
226
227        // Install binary
228        if let Err(e) = install_binary(&download.binary_path, &paths.bin_dir, &agent.binary_name) {
229            println!(" FAILED");
230            eprintln!("    Error installing binary: {}", e);
231            failed += 1;
232            continue;
233        }
234
235        // Install config
236        let config_content = generate_default_config(&agent.name);
237        let config_path = install_config(&paths.config_dir, &agent.name, &config_content, force)
238            .context("Failed to install config")?;
239
240        // Install systemd service if requested
241        if install_systemd {
242            if let Some(ref systemd_dir) = paths.systemd_dir {
243                let bin_path = paths.bin_dir.join(&agent.binary_name);
244                let service_content = generate_systemd_service(&agent.name, &bin_path, &config_path);
245                install_systemd_service(systemd_dir, &agent.name, &service_content)
246                    .context("Failed to install systemd service")?;
247            }
248        }
249
250        let checksum_status = if download.checksum_verified {
251            "verified"
252        } else {
253            "unverified"
254        };
255
256        println!(
257            " OK ({} KB, {})",
258            download.archive_size / 1024,
259            checksum_status
260        );
261        installed += 1;
262    }
263
264    println!();
265    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
266    println!(
267        "Installed: {} | Skipped: {} | Failed: {}",
268        installed, skipped, failed
269    );
270
271    if installed > 0 {
272        println!();
273        println!("To start the agents:");
274        if paths.system_wide && install_systemd {
275            println!("  sudo systemctl daemon-reload");
276            println!("  sudo systemctl start sentinel.target");
277        } else {
278            println!("  # Add agent endpoints to your sentinel.kdl config");
279            println!("  # See: https://sentinel.raskell.io/docs/bundle");
280        }
281    }
282
283    if failed > 0 {
284        anyhow::bail!("{} agent(s) failed to install", failed);
285    }
286
287    Ok(())
288}
289
290/// Status command implementation
291fn cmd_status(lock: &BundleLock, verbose: bool) -> Result<()> {
292    let paths = InstallPaths::detect();
293    let status = BundleStatus::check(lock, &paths);
294
295    println!("{}", status.display());
296
297    if verbose {
298        println!();
299        println!("Paths:");
300        println!("  Binaries: {}", paths.bin_dir.display());
301        println!("  Configs:  {}", paths.config_dir.display());
302        if let Some(ref sd) = paths.systemd_dir {
303            println!("  Systemd:  {}", sd.display());
304        }
305    }
306
307    Ok(())
308}
309
310/// List command implementation
311fn cmd_list(lock: &BundleLock, verbose: bool) -> Result<()> {
312    println!("Sentinel Bundle Agents");
313    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
314    println!("Bundle version: {}", lock.bundle.version);
315    println!();
316
317    for agent in lock.agents() {
318        println!("  {} v{}", agent.name, agent.version);
319        if verbose {
320            println!("    Repository: {}", agent.repository);
321            println!("    Binary:     {}", agent.binary_name);
322            println!(
323                "    URL:        {}",
324                agent.download_url(detect_os(), detect_arch())
325            );
326            println!();
327        }
328    }
329
330    if !verbose {
331        println!();
332        println!("Use --verbose for more details");
333    }
334
335    Ok(())
336}
337
338/// Uninstall command implementation
339fn cmd_uninstall(lock: &BundleLock, agent: Option<String>, dry_run: bool) -> Result<()> {
340    let paths = InstallPaths::detect();
341
342    println!("Sentinel Bundle Uninstaller");
343    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
344
345    let agents: Vec<_> = match &agent {
346        Some(name) => {
347            let agent_info = lock
348                .agent(name)
349                .ok_or_else(|| anyhow::anyhow!("Unknown agent: {}", name))?;
350            vec![agent_info]
351        }
352        None => lock.agents(),
353    };
354
355    if dry_run {
356        println!("[DRY RUN] Would uninstall:");
357        for agent in &agents {
358            let bin_path = paths.bin_dir.join(&agent.binary_name);
359            if bin_path.exists() {
360                println!("  {} ({})", agent.name, bin_path.display());
361            }
362        }
363        return Ok(());
364    }
365
366    let mut removed = 0;
367    for agent in &agents {
368        if uninstall_binary(&paths.bin_dir, &agent.binary_name)? {
369            println!("  Removed {}", agent.name);
370            removed += 1;
371        }
372    }
373
374    println!();
375    println!("Removed {} agent(s)", removed);
376    println!();
377    println!("Note: Configuration files in {} were preserved", paths.config_dir.display());
378
379    Ok(())
380}
381
382/// Update command implementation
383fn cmd_update(current_lock: &BundleLock, apply: bool) -> Result<()> {
384    println!("Checking for bundle updates...");
385    println!();
386
387    // Fetch latest lock file
388    let rt = tokio::runtime::Runtime::new()?;
389    let latest_lock = rt
390        .block_on(BundleLock::fetch_latest())
391        .context("Failed to fetch latest bundle versions")?;
392
393    println!("Current bundle: {}", current_lock.bundle.version);
394    println!("Latest bundle:  {}", latest_lock.bundle.version);
395    println!();
396
397    // Compare versions
398    let mut updates_available = false;
399    println!("{:<15} {:<12} {:<12}", "Agent", "Current", "Latest");
400    println!("{}", "─".repeat(40));
401
402    for (name, latest_version) in &latest_lock.agents {
403        let current_version = current_lock.agents.get(name).map(|s| s.as_str()).unwrap_or("-");
404        let is_update = current_version != latest_version;
405
406        if is_update {
407            updates_available = true;
408            println!("{:<15} {:<12} {:<12} ←", name, current_version, latest_version);
409        } else {
410            println!("{:<15} {:<12} {:<12}", name, current_version, latest_version);
411        }
412    }
413
414    if !updates_available {
415        println!();
416        println!("All agents are up to date.");
417        return Ok(());
418    }
419
420    println!();
421    if apply {
422        println!("To update, run: sentinel bundle install --force");
423    } else {
424        println!("Updates are available. Run with --apply to update.");
425        println!("  sentinel bundle update --apply");
426    }
427
428    Ok(())
429}