mielin_cli/commands/
remote.rs

1//! Remote node management commands
2
3use 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    /// List all remote nodes
15    #[command(visible_aliases = &["ls"])]
16    List {
17        /// Filter by tag
18        #[arg(short, long)]
19        tag: Option<String>,
20    },
21
22    /// Add a new remote node
23    #[command(visible_aliases = &["create", "new"])]
24    Add {
25        /// Node ID
26        #[arg(short, long)]
27        id: String,
28
29        /// Node name
30        #[arg(short, long)]
31        name: String,
32
33        /// Node address (host:port)
34        #[arg(short, long)]
35        address: String,
36
37        /// Authentication method: none, `apikey:<KEY>`, `token:<TOKEN>`
38        #[arg(long, default_value = "none")]
39        auth: String,
40
41        /// Tags (comma-separated)
42        #[arg(short, long)]
43        tags: Option<String>,
44
45        /// Description
46        #[arg(short, long)]
47        description: Option<String>,
48    },
49
50    /// Remove a remote node
51    #[command(visible_aliases = &["rm", "delete"])]
52    Remove {
53        /// Node ID
54        id: String,
55
56        /// Skip confirmation
57        #[arg(short = 'y', long)]
58        yes: bool,
59    },
60
61    /// Show node information
62    #[command(visible_aliases = &["show", "details"])]
63    Info {
64        /// Node ID
65        id: String,
66    },
67
68    /// Update a remote node
69    Update {
70        /// Node ID
71        id: String,
72
73        /// New name
74        #[arg(long)]
75        name: Option<String>,
76
77        /// New address
78        #[arg(long)]
79        address: Option<String>,
80
81        /// New authentication
82        #[arg(long)]
83        auth: Option<String>,
84
85        /// New tags (comma-separated)
86        #[arg(long)]
87        tags: Option<String>,
88
89        /// New description
90        #[arg(long)]
91        description: Option<String>,
92    },
93
94    /// Test connection to a remote node
95    #[command(visible_aliases = &["ping"])]
96    Test {
97        /// Node ID
98        id: String,
99    },
100
101    /// Execute a command on a remote node
102    #[command(visible_aliases = &["exec", "run"])]
103    Execute {
104        /// Node ID or tag (prefix with @tag: for tags)
105        target: String,
106
107        /// Command to execute
108        command: String,
109
110        /// Command arguments
111        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
112        args: Vec<String>,
113    },
114
115    /// Import nodes from a configuration file
116    Import {
117        /// Path to configuration file
118        path: PathBuf,
119    },
120
121    /// Export nodes to a configuration file
122    Export {
123        /// Path to output file
124        path: PathBuf,
125    },
126
127    /// Show remote nodes configuration file path
128    #[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    // Parse authentication method
251    let auth = parse_auth_method(&auth_str)?;
252
253    // Parse tags
254    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    // Check if node exists
281    if manager.get_node(id).is_none() {
282        anyhow::bail!("Remote node not found: {}", id);
283    }
284
285    // Confirm removal
286    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        // Execute on all nodes with the specified tag
532        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        // Execute on a single node
538        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}