Skip to main content

grapsus_proxy/bundle/
commands.rs

1//! Bundle CLI command handlers
2//!
3//! Implements the `grapsus 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!("Grapsus 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.agents.iter().find(|a| a.name == agent.name);
160
161            let action = match agent_status {
162                Some(s) if s.status == crate::bundle::status::Status::UpToDate && !force => {
163                    "skip (already installed)"
164                }
165                Some(s) if s.status == crate::bundle::status::Status::Outdated => "upgrade",
166                _ => "install",
167            };
168
169            println!(
170                "  {} {} -> {} ({})",
171                agent.name,
172                agent.version,
173                paths.bin_dir.display(),
174                action
175            );
176        }
177        return Ok(());
178    }
179
180    // Ensure directories exist
181    paths
182        .ensure_dirs()
183        .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!(
204                        "  [skip] {} {} (already installed)",
205                        agent.name, agent.version
206                    );
207                    skipped += 1;
208                    continue;
209                }
210            }
211        }
212
213        print!("  Installing {} {}...", agent.name, agent.version);
214
215        // Download
216        let download_result =
217            rt.block_on(async { download_agent(agent, temp_dir.path(), !skip_verify).await });
218
219        let download = match download_result {
220            Ok(d) => d,
221            Err(e) => {
222                println!(" FAILED");
223                eprintln!("    Error: {}", e);
224                failed += 1;
225                continue;
226            }
227        };
228
229        // Install binary
230        if let Err(e) = install_binary(&download.binary_path, &paths.bin_dir, &agent.binary_name) {
231            println!(" FAILED");
232            eprintln!("    Error installing binary: {}", e);
233            failed += 1;
234            continue;
235        }
236
237        // Install config
238        let config_content = generate_default_config(&agent.name);
239        let config_path = install_config(&paths.config_dir, &agent.name, &config_content, force)
240            .context("Failed to install config")?;
241
242        // Install systemd service if requested
243        if install_systemd {
244            if let Some(ref systemd_dir) = paths.systemd_dir {
245                let bin_path = paths.bin_dir.join(&agent.binary_name);
246                let service_content =
247                    generate_systemd_service(&agent.name, &bin_path, &config_path);
248                install_systemd_service(systemd_dir, &agent.name, &service_content)
249                    .context("Failed to install systemd service")?;
250            }
251        }
252
253        let checksum_status = if download.checksum_verified {
254            "verified"
255        } else {
256            "unverified"
257        };
258
259        println!(
260            " OK ({} KB, {})",
261            download.archive_size / 1024,
262            checksum_status
263        );
264        installed += 1;
265    }
266
267    println!();
268    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
269    println!(
270        "Installed: {} | Skipped: {} | Failed: {}",
271        installed, skipped, failed
272    );
273
274    if installed > 0 {
275        println!();
276        println!("To start the agents:");
277        if paths.system_wide && install_systemd {
278            println!("  sudo systemctl daemon-reload");
279            println!("  sudo systemctl start grapsus.target");
280        } else {
281            println!("  # Add agent endpoints to your grapsus.kdl config");
282            println!("  # See: https://grapsusproxy.io/docs/bundle");
283        }
284    }
285
286    if failed > 0 {
287        anyhow::bail!("{} agent(s) failed to install", failed);
288    }
289
290    Ok(())
291}
292
293/// Status command implementation
294fn cmd_status(lock: &BundleLock, verbose: bool) -> Result<()> {
295    let paths = InstallPaths::detect();
296    let status = BundleStatus::check(lock, &paths);
297
298    println!("{}", status.display());
299
300    if verbose {
301        println!();
302        println!("Paths:");
303        println!("  Binaries: {}", paths.bin_dir.display());
304        println!("  Configs:  {}", paths.config_dir.display());
305        if let Some(ref sd) = paths.systemd_dir {
306            println!("  Systemd:  {}", sd.display());
307        }
308    }
309
310    Ok(())
311}
312
313/// List command implementation
314fn cmd_list(lock: &BundleLock, verbose: bool) -> Result<()> {
315    println!("Grapsus Bundle Agents");
316    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
317    println!("Bundle version: {}", lock.bundle.version);
318    println!();
319
320    for agent in lock.agents() {
321        println!("  {} v{}", agent.name, agent.version);
322        if verbose {
323            println!("    Repository: {}", agent.repository);
324            println!("    Binary:     {}", agent.binary_name);
325            println!(
326                "    URL:        {}",
327                agent.download_url(detect_os(), detect_arch())
328            );
329            println!();
330        }
331    }
332
333    if !verbose {
334        println!();
335        println!("Use --verbose for more details");
336    }
337
338    Ok(())
339}
340
341/// Uninstall command implementation
342fn cmd_uninstall(lock: &BundleLock, agent: Option<String>, dry_run: bool) -> Result<()> {
343    let paths = InstallPaths::detect();
344
345    println!("Grapsus Bundle Uninstaller");
346    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
347
348    let agents: Vec<_> = match &agent {
349        Some(name) => {
350            let agent_info = lock
351                .agent(name)
352                .ok_or_else(|| anyhow::anyhow!("Unknown agent: {}", name))?;
353            vec![agent_info]
354        }
355        None => lock.agents(),
356    };
357
358    if dry_run {
359        println!("[DRY RUN] Would uninstall:");
360        for agent in &agents {
361            let bin_path = paths.bin_dir.join(&agent.binary_name);
362            if bin_path.exists() {
363                println!("  {} ({})", agent.name, bin_path.display());
364            }
365        }
366        return Ok(());
367    }
368
369    let mut removed = 0;
370    for agent in &agents {
371        if uninstall_binary(&paths.bin_dir, &agent.binary_name)? {
372            println!("  Removed {}", agent.name);
373            removed += 1;
374        }
375    }
376
377    println!();
378    println!("Removed {} agent(s)", removed);
379    println!();
380    println!(
381        "Note: Configuration files in {} were preserved",
382        paths.config_dir.display()
383    );
384
385    Ok(())
386}
387
388/// Update command implementation
389fn cmd_update(current_lock: &BundleLock, apply: bool) -> Result<()> {
390    println!("Checking for bundle updates...");
391    println!();
392
393    // Fetch latest lock file
394    let rt = tokio::runtime::Runtime::new()?;
395    let latest_lock = rt
396        .block_on(BundleLock::fetch_latest())
397        .context("Failed to fetch latest bundle versions")?;
398
399    println!("Current bundle: {}", current_lock.bundle.version);
400    println!("Latest bundle:  {}", latest_lock.bundle.version);
401    println!();
402
403    // Compare versions
404    let mut updates_available = false;
405    println!("{:<15} {:<12} {:<12}", "Agent", "Current", "Latest");
406    println!("{}", "─".repeat(40));
407
408    for (name, latest_version) in &latest_lock.agents {
409        let current_version = current_lock
410            .agents
411            .get(name)
412            .map(|s| s.as_str())
413            .unwrap_or("-");
414        let is_update = current_version != latest_version;
415
416        if is_update {
417            updates_available = true;
418            println!(
419                "{:<15} {:<12} {:<12} ←",
420                name, current_version, latest_version
421            );
422        } else {
423            println!(
424                "{:<15} {:<12} {:<12}",
425                name, current_version, latest_version
426            );
427        }
428    }
429
430    if !updates_available {
431        println!();
432        println!("All agents are up to date.");
433        return Ok(());
434    }
435
436    println!();
437    if apply {
438        println!("To update, run: grapsus bundle install --force");
439    } else {
440        println!("Updates are available. Run with --apply to update.");
441        println!("  grapsus bundle update --apply");
442    }
443
444    Ok(())
445}