1use crate::output::{render_output, MultiFormatDisplay, OutputFormat};
4use crate::remote::{AuthMethod, ConnectionOptions, RemoteCommand, RemoteManager, RemoteNode};
5use anyhow::Result;
6use clap::Subcommand;
7use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
8use serde::Serialize;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Subcommand)]
13pub enum RemoteCommands {
14 #[command(visible_aliases = &["ls"])]
16 List {
17 #[arg(short, long)]
19 tag: Option<String>,
20 },
21
22 #[command(visible_aliases = &["create", "new"])]
24 Add {
25 #[arg(short, long)]
27 id: String,
28
29 #[arg(short, long)]
31 name: String,
32
33 #[arg(short, long)]
35 address: String,
36
37 #[arg(long, default_value = "none")]
39 auth: String,
40
41 #[arg(short, long)]
43 tags: Option<String>,
44
45 #[arg(short, long)]
47 description: Option<String>,
48 },
49
50 #[command(visible_aliases = &["rm", "delete"])]
52 Remove {
53 id: String,
55
56 #[arg(short = 'y', long)]
58 yes: bool,
59 },
60
61 #[command(visible_aliases = &["show", "details"])]
63 Info {
64 id: String,
66 },
67
68 Update {
70 id: String,
72
73 #[arg(long)]
75 name: Option<String>,
76
77 #[arg(long)]
79 address: Option<String>,
80
81 #[arg(long)]
83 auth: Option<String>,
84
85 #[arg(long)]
87 tags: Option<String>,
88
89 #[arg(long)]
91 description: Option<String>,
92 },
93
94 #[command(visible_aliases = &["ping"])]
96 Test {
97 id: String,
99 },
100
101 #[command(visible_aliases = &["exec", "run"])]
103 Execute {
104 target: String,
106
107 command: String,
109
110 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
112 args: Vec<String>,
113 },
114
115 Import {
117 path: PathBuf,
119 },
120
121 Export {
123 path: PathBuf,
125 },
126
127 #[command(visible_aliases = &["path"])]
129 Config,
130}
131
132pub async fn handle_remote_command(cmd: RemoteCommands, format: OutputFormat) -> Result<()> {
133 match cmd {
134 RemoteCommands::List { tag } => list_remote_nodes(tag, format).await,
135 RemoteCommands::Add {
136 id,
137 name,
138 address,
139 auth,
140 tags,
141 description,
142 } => add_remote_node(id, name, address, auth, tags, description, format).await,
143 RemoteCommands::Remove { id, yes } => remove_remote_node(&id, yes, format).await,
144 RemoteCommands::Info { id } => show_remote_node_info(&id, format).await,
145 RemoteCommands::Update {
146 id,
147 name,
148 address,
149 auth,
150 tags,
151 description,
152 } => update_remote_node(&id, name, address, auth, tags, description, format).await,
153 RemoteCommands::Test { id } => test_remote_connection(&id, format).await,
154 RemoteCommands::Execute {
155 target,
156 command,
157 args,
158 } => execute_remote_command(&target, &command, args, format).await,
159 RemoteCommands::Import { path } => import_remote_nodes(&path, format).await,
160 RemoteCommands::Export { path } => export_remote_nodes(&path, format).await,
161 RemoteCommands::Config => show_config_path(format).await,
162 }
163}
164
165#[derive(Debug, Serialize)]
166struct RemoteNodeListEntry {
167 id: String,
168 name: String,
169 address: String,
170 auth_type: String,
171 tags: String,
172}
173
174impl MultiFormatDisplay for Vec<RemoteNodeListEntry> {
175 fn to_table(&self) -> Table {
176 let mut table = Table::new();
177 table
178 .load_preset(UTF8_FULL)
179 .set_content_arrangement(ContentArrangement::Dynamic);
180
181 table.set_header(vec![
182 Cell::new("ID").fg(Color::Cyan),
183 Cell::new("Name").fg(Color::Cyan),
184 Cell::new("Address").fg(Color::Cyan),
185 Cell::new("Auth").fg(Color::Cyan),
186 Cell::new("Tags").fg(Color::Cyan),
187 ]);
188
189 for entry in self {
190 table.add_row(vec![
191 Cell::new(&entry.id),
192 Cell::new(&entry.name),
193 Cell::new(&entry.address),
194 Cell::new(&entry.auth_type),
195 Cell::new(&entry.tags),
196 ]);
197 }
198
199 table
200 }
201
202 fn to_quiet(&self) -> String {
203 self.iter()
204 .map(|e| e.id.clone())
205 .collect::<Vec<_>>()
206 .join("\n")
207 }
208}
209
210async fn list_remote_nodes(tag: Option<String>, format: OutputFormat) -> Result<()> {
211 let manager = RemoteManager::new()?;
212
213 let nodes = if let Some(tag_filter) = tag {
214 manager.list_nodes_by_tag(&tag_filter)
215 } else {
216 manager.list_nodes()
217 };
218
219 let entries: Vec<RemoteNodeListEntry> = nodes
220 .iter()
221 .map(|n| RemoteNodeListEntry {
222 id: n.id.clone(),
223 name: n.name.clone(),
224 address: n.address.clone(),
225 auth_type: match &n.auth {
226 AuthMethod::None => "None".to_string(),
227 AuthMethod::ApiKey { .. } => "API Key".to_string(),
228 AuthMethod::Certificate { .. } => "Certificate".to_string(),
229 AuthMethod::Token { .. } => "Token".to_string(),
230 },
231 tags: n.tags.join(", "),
232 })
233 .collect();
234
235 println!("{}", render_output(&entries, format)?);
236 Ok(())
237}
238
239async fn add_remote_node(
240 id: String,
241 name: String,
242 address: String,
243 auth_str: String,
244 tags: Option<String>,
245 description: Option<String>,
246 format: OutputFormat,
247) -> Result<()> {
248 let mut manager = RemoteManager::new()?;
249
250 let auth = parse_auth_method(&auth_str)?;
252
253 let tags_vec = tags
255 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
256 .unwrap_or_default();
257
258 let node = RemoteNode {
259 id: id.clone(),
260 name,
261 address,
262 auth,
263 options: ConnectionOptions::default(),
264 tags: tags_vec,
265 description: description.unwrap_or_default(),
266 };
267
268 manager.add_node(node)?;
269
270 if format != OutputFormat::Quiet {
271 println!("✓ Remote node added: {}", id);
272 }
273
274 Ok(())
275}
276
277async fn remove_remote_node(id: &str, yes: bool, format: OutputFormat) -> Result<()> {
278 let mut manager = RemoteManager::new()?;
279
280 if manager.get_node(id).is_none() {
282 anyhow::bail!("Remote node not found: {}", id);
283 }
284
285 if !yes && format != OutputFormat::Quiet {
287 println!(
288 "Are you sure you want to remove remote node '{}'? [y/N]",
289 id
290 );
291 let mut input = String::new();
292 std::io::stdin().read_line(&mut input)?;
293 if !input.trim().eq_ignore_ascii_case("y") {
294 println!("Removal cancelled");
295 return Ok(());
296 }
297 }
298
299 manager.remove_node(id)?;
300
301 if format != OutputFormat::Quiet {
302 println!("✓ Remote node removed: {}", id);
303 }
304
305 Ok(())
306}
307
308#[derive(Debug, Serialize)]
309struct RemoteNodeInfo {
310 id: String,
311 name: String,
312 address: String,
313 auth_type: String,
314 tls_enabled: bool,
315 verify_ssl: bool,
316 timeout_secs: u64,
317 max_retries: u32,
318 tags: Vec<String>,
319 description: String,
320}
321
322impl MultiFormatDisplay for RemoteNodeInfo {
323 fn to_table(&self) -> Table {
324 let mut table = Table::new();
325 table
326 .load_preset(UTF8_FULL)
327 .set_content_arrangement(ContentArrangement::Dynamic);
328
329 table.add_row(vec![Cell::new("ID").fg(Color::Cyan), Cell::new(&self.id)]);
330 table.add_row(vec![
331 Cell::new("Name").fg(Color::Cyan),
332 Cell::new(&self.name),
333 ]);
334 table.add_row(vec![
335 Cell::new("Address").fg(Color::Cyan),
336 Cell::new(&self.address),
337 ]);
338 table.add_row(vec![
339 Cell::new("Auth Type").fg(Color::Cyan),
340 Cell::new(&self.auth_type),
341 ]);
342 table.add_row(vec![
343 Cell::new("TLS Enabled").fg(Color::Cyan),
344 Cell::new(self.tls_enabled.to_string()),
345 ]);
346 table.add_row(vec![
347 Cell::new("Verify SSL").fg(Color::Cyan),
348 Cell::new(self.verify_ssl.to_string()),
349 ]);
350 table.add_row(vec![
351 Cell::new("Timeout (secs)").fg(Color::Cyan),
352 Cell::new(self.timeout_secs.to_string()),
353 ]);
354 table.add_row(vec![
355 Cell::new("Max Retries").fg(Color::Cyan),
356 Cell::new(self.max_retries.to_string()),
357 ]);
358 table.add_row(vec![
359 Cell::new("Tags").fg(Color::Cyan),
360 Cell::new(self.tags.join(", ")),
361 ]);
362 table.add_row(vec![
363 Cell::new("Description").fg(Color::Cyan),
364 Cell::new(&self.description),
365 ]);
366
367 table
368 }
369
370 fn to_quiet(&self) -> String {
371 format!("{} - {}", self.id, self.name)
372 }
373}
374
375async fn show_remote_node_info(id: &str, format: OutputFormat) -> Result<()> {
376 let manager = RemoteManager::new()?;
377
378 let node = manager
379 .get_node(id)
380 .ok_or_else(|| anyhow::anyhow!("Remote node not found: {}", id))?;
381
382 let info = RemoteNodeInfo {
383 id: node.id.clone(),
384 name: node.name.clone(),
385 address: node.address.clone(),
386 auth_type: match &node.auth {
387 AuthMethod::None => "None".to_string(),
388 AuthMethod::ApiKey { .. } => "API Key".to_string(),
389 AuthMethod::Certificate { .. } => "Certificate".to_string(),
390 AuthMethod::Token { .. } => "Token".to_string(),
391 },
392 tls_enabled: node.options.tls,
393 verify_ssl: node.options.verify_ssl,
394 timeout_secs: node.options.timeout_secs,
395 max_retries: node.options.max_retries,
396 tags: node.tags.clone(),
397 description: node.description.clone(),
398 };
399
400 println!("{}", render_output(&info, format)?);
401 Ok(())
402}
403
404async fn update_remote_node(
405 id: &str,
406 name: Option<String>,
407 address: Option<String>,
408 auth: Option<String>,
409 tags: Option<String>,
410 description: Option<String>,
411 format: OutputFormat,
412) -> Result<()> {
413 let mut manager = RemoteManager::new()?;
414
415 let mut node = manager
416 .get_node(id)
417 .ok_or_else(|| anyhow::anyhow!("Remote node not found: {}", id))?
418 .clone();
419
420 if let Some(new_name) = name {
421 node.name = new_name;
422 }
423
424 if let Some(new_address) = address {
425 node.address = new_address;
426 }
427
428 if let Some(auth_str) = auth {
429 node.auth = parse_auth_method(&auth_str)?;
430 }
431
432 if let Some(tags_str) = tags {
433 node.tags = tags_str.split(',').map(|s| s.trim().to_string()).collect();
434 }
435
436 if let Some(new_desc) = description {
437 node.description = new_desc;
438 }
439
440 manager.update_node(id, node)?;
441
442 if format != OutputFormat::Quiet {
443 println!("✓ Remote node updated: {}", id);
444 }
445
446 Ok(())
447}
448
449async fn test_remote_connection(id: &str, format: OutputFormat) -> Result<()> {
450 let manager = RemoteManager::new()?;
451
452 let connected = manager.test_connection(id).await?;
453
454 if connected {
455 if format != OutputFormat::Quiet {
456 println!("✓ Connection successful to node: {}", id);
457 }
458 } else {
459 anyhow::bail!("Connection failed to node: {}", id);
460 }
461
462 Ok(())
463}
464
465#[derive(Debug, Serialize)]
466struct RemoteExecutionResult {
467 node_id: String,
468 command: String,
469 exit_code: i32,
470 duration_ms: u64,
471 output: String,
472}
473
474impl MultiFormatDisplay for Vec<RemoteExecutionResult> {
475 fn to_table(&self) -> Table {
476 let mut table = Table::new();
477 table
478 .load_preset(UTF8_FULL)
479 .set_content_arrangement(ContentArrangement::Dynamic);
480
481 table.set_header(vec![
482 Cell::new("Node").fg(Color::Cyan),
483 Cell::new("Command").fg(Color::Cyan),
484 Cell::new("Exit Code").fg(Color::Cyan),
485 Cell::new("Duration (ms)").fg(Color::Cyan),
486 Cell::new("Output").fg(Color::Cyan),
487 ]);
488
489 for result in self {
490 let exit_code_cell = if result.exit_code == 0 {
491 Cell::new(result.exit_code.to_string()).fg(Color::Green)
492 } else {
493 Cell::new(result.exit_code.to_string()).fg(Color::Red)
494 };
495
496 table.add_row(vec![
497 Cell::new(&result.node_id),
498 Cell::new(&result.command),
499 exit_code_cell,
500 Cell::new(result.duration_ms.to_string()),
501 Cell::new(&result.output),
502 ]);
503 }
504
505 table
506 }
507
508 fn to_quiet(&self) -> String {
509 self.iter()
510 .map(|r| r.output.clone())
511 .collect::<Vec<_>>()
512 .join("\n")
513 }
514}
515
516async fn execute_remote_command(
517 target: &str,
518 command: &str,
519 args: Vec<String>,
520 format: OutputFormat,
521) -> Result<()> {
522 let manager = RemoteManager::new()?;
523
524 let remote_cmd = RemoteCommand {
525 command: command.to_string(),
526 args,
527 env: HashMap::new(),
528 };
529
530 let results = if target.starts_with("@tag:") {
531 let tag = target.trim_start_matches("@tag:");
533 let nodes = manager.list_nodes_by_tag(tag);
534 let node_ids: Vec<String> = nodes.iter().map(|n| n.id.clone()).collect();
535 manager.execute_on_multiple(&node_ids, remote_cmd).await?
536 } else {
537 let result = manager.execute_command(target, remote_cmd).await?;
539 vec![result]
540 };
541
542 let exec_results: Vec<RemoteExecutionResult> = results
543 .iter()
544 .map(|r| RemoteExecutionResult {
545 node_id: r.node_id.clone(),
546 command: command.to_string(),
547 exit_code: r.exit_code,
548 duration_ms: r.duration_ms,
549 output: r.stdout.clone(),
550 })
551 .collect();
552
553 println!("{}", render_output(&exec_results, format)?);
554 Ok(())
555}
556
557async fn import_remote_nodes(path: &Path, format: OutputFormat) -> Result<()> {
558 let mut manager = RemoteManager::new()?;
559 let count = manager.import_nodes(path)?;
560
561 if format != OutputFormat::Quiet {
562 println!("✓ Imported {} remote node(s)", count);
563 }
564
565 Ok(())
566}
567
568async fn export_remote_nodes(path: &Path, format: OutputFormat) -> Result<()> {
569 let manager = RemoteManager::new()?;
570 manager.export_nodes(path)?;
571
572 if format != OutputFormat::Quiet {
573 println!("✓ Exported remote nodes to {:?}", path);
574 }
575
576 Ok(())
577}
578
579#[derive(Debug, Serialize)]
580struct ConfigPathInfo {
581 path: String,
582 exists: bool,
583}
584
585impl MultiFormatDisplay for ConfigPathInfo {
586 fn to_table(&self) -> Table {
587 let mut table = Table::new();
588 table
589 .load_preset(UTF8_FULL)
590 .set_content_arrangement(ContentArrangement::Dynamic);
591
592 table.add_row(vec![
593 Cell::new("Config Path").fg(Color::Cyan),
594 Cell::new(&self.path),
595 ]);
596
597 table.add_row(vec![
598 Cell::new("Exists").fg(Color::Cyan),
599 if self.exists {
600 Cell::new("Yes").fg(Color::Green)
601 } else {
602 Cell::new("No").fg(Color::Red)
603 },
604 ]);
605
606 table
607 }
608
609 fn to_quiet(&self) -> String {
610 self.path.clone()
611 }
612}
613
614async fn show_config_path(format: OutputFormat) -> Result<()> {
615 let config_path = RemoteManager::get_config_path()?;
616
617 let info = ConfigPathInfo {
618 path: config_path.to_string_lossy().to_string(),
619 exists: config_path.exists(),
620 };
621
622 println!("{}", render_output(&info, format)?);
623 Ok(())
624}
625
626fn parse_auth_method(auth_str: &str) -> Result<AuthMethod> {
627 let parts: Vec<&str> = auth_str.splitn(2, ':').collect();
628
629 match parts[0].to_lowercase().as_str() {
630 "none" => Ok(AuthMethod::None),
631 "apikey" => {
632 if parts.len() != 2 {
633 anyhow::bail!("API key auth requires format: apikey:<KEY>");
634 }
635 Ok(AuthMethod::ApiKey {
636 key: parts[1].to_string(),
637 })
638 }
639 "token" => {
640 if parts.len() != 2 {
641 anyhow::bail!("Token auth requires format: token:<TOKEN>");
642 }
643 Ok(AuthMethod::Token {
644 token: parts[1].to_string(),
645 })
646 }
647 _ => anyhow::bail!(
648 "Unsupported auth method: {}. Use: none, apikey:<KEY>, or token:<TOKEN>",
649 parts[0]
650 ),
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn test_parse_auth_method_none() {
660 let auth = parse_auth_method("none").unwrap();
661 matches!(auth, AuthMethod::None);
662 }
663
664 #[test]
665 fn test_parse_auth_method_apikey() {
666 let auth = parse_auth_method("apikey:test-key").unwrap();
667 matches!(auth, AuthMethod::ApiKey { .. });
668 }
669
670 #[test]
671 fn test_parse_auth_method_token() {
672 let auth = parse_auth_method("token:test-token").unwrap();
673 matches!(auth, AuthMethod::Token { .. });
674 }
675
676 #[test]
677 fn test_parse_auth_method_invalid() {
678 let result = parse_auth_method("invalid");
679 assert!(result.is_err());
680 }
681
682 #[test]
683 fn test_remote_node_list_entry() {
684 let entry = RemoteNodeListEntry {
685 id: "node1".to_string(),
686 name: "Test Node".to_string(),
687 address: "localhost:8080".to_string(),
688 auth_type: "API Key".to_string(),
689 tags: "prod, web".to_string(),
690 };
691
692 let json = serde_json::to_string(&entry).unwrap();
693 assert!(json.contains("node1"));
694 assert!(json.contains("Test Node"));
695 }
696}