1use 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#[derive(Args, Debug)]
18pub struct BundleArgs {
19 #[command(subcommand)]
20 pub command: BundleCommand,
21}
22
23#[derive(Subcommand, Debug)]
25pub enum BundleCommand {
26 Install {
28 agent: Option<String>,
30
31 #[arg(long, short = 'n')]
33 dry_run: bool,
34
35 #[arg(long, short = 'f')]
37 force: bool,
38
39 #[arg(long)]
41 systemd: bool,
42
43 #[arg(long)]
45 prefix: Option<PathBuf>,
46
47 #[arg(long)]
49 skip_verify: bool,
50 },
51
52 Status {
54 #[arg(long, short = 'v')]
56 verbose: bool,
57 },
58
59 List {
61 #[arg(long, short = 'v')]
63 verbose: bool,
64 },
65
66 Uninstall {
68 agent: Option<String>,
70
71 #[arg(long, short = 'n')]
73 dry_run: bool,
74 },
75
76 Update {
78 #[arg(long)]
80 apply: bool,
81 },
82}
83
84pub fn run_bundle_command(args: BundleArgs) -> Result<()> {
86 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
109fn 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 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 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 paths
182 .ensure_dirs()
183 .context("Failed to create installation directories")?;
184
185 let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
187
188 let rt = tokio::runtime::Runtime::new()?;
190
191 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 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 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 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 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 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
293fn 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
313fn 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
341fn 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
388fn cmd_update(current_lock: &BundleLock, apply: bool) -> Result<()> {
390 println!("Checking for bundle updates...");
391 println!();
392
393 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 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}