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!("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 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
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 paths.ensure_dirs().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!(" [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 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 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 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 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
290fn 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
310fn 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
338fn 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
382fn cmd_update(current_lock: &BundleLock, apply: bool) -> Result<()> {
384 println!("Checking for bundle updates...");
385 println!();
386
387 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 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}