portkey/
cli.rs

1use anyhow::Result;
2use clap::{Parser, Subcommand};
3use inquire::{Confirm, Password, Select, Text};
4
5use crate::models::Server;
6use crate::vault::Vault;
7use crate::tui;
8use crate::ssh;
9use fuzzy_matcher::FuzzyMatcher;
10
11#[derive(Parser)]
12#[command(name = "portkey")]
13#[command(about = "Secure SSH credential manager")]
14#[command(version)]
15pub struct Cli {
16    #[command(subcommand)]
17    command: Option<Commands>,
18}
19
20#[derive(Subcommand)]
21pub enum Commands {
22    /// Initialize a new vault
23    Init,
24    
25    /// Add a new server
26    Add,
27    
28    /// List all servers
29    List,
30    
31    /// Connect to a server
32    Connect {
33        /// Server name or ID
34        name: Option<String>,
35    },
36    
37    /// Remove a server
38    Remove {
39        /// Server name or ID
40        name: String,
41    },
42    
43    /// Interactive server selection and connection
44    Quick,
45    
46    /// Search servers
47    Search {
48        query: String,
49    },
50
51    /// Export SSH config entries for servers
52    SshConfig {
53        /// Actually write to ~/.ssh/config instead of printing
54        #[arg(long)]
55        write: bool,
56    },
57
58    /// Full-screen TUI application
59    Ui,
60}
61
62pub struct CliHandler {
63    vault: Vault,
64}
65
66impl CliHandler {
67    pub fn new() -> Result<Self> {
68        let vault = Vault::new()?;
69        Ok(Self { vault })
70    }
71
72    pub async fn run(&mut self) -> Result<()> {
73        let cli = Cli::parse();
74
75        match cli.command {
76            Some(Commands::Init) => self.handle_init().await?,
77            Some(Commands::Add) => self.handle_add().await?,
78            Some(Commands::List) => self.handle_list().await?,
79            Some(Commands::Connect { name }) => self.handle_connect(name).await?,
80            Some(Commands::Remove { name }) => self.handle_remove(name).await?,
81            Some(Commands::Quick) => self.handle_quick().await?,
82            Some(Commands::Search { query }) => self.handle_search(query).await?,
83            Some(Commands::SshConfig { write }) => self.handle_ssh_config(write).await?,
84            Some(Commands::Ui) => self.handle_interactive().await?,
85            None => self.handle_interactive().await?,
86        }
87
88        Ok(())
89    }
90
91    async fn handle_init(&mut self) -> Result<()> {
92        if self.vault.exists() {
93            let confirmed = Confirm::new("Vault already exists. Do you want to overwrite it?")
94                .with_default(false)
95                .prompt()?;
96            
97            if !confirmed {
98                println!("Operation cancelled.");
99                return Ok(());
100            }
101        }
102
103        let use_password = Confirm::new("Would you like to protect your vault with a master password?")
104            .with_default(true)
105            .prompt()?;
106
107        let password = if use_password {
108            Password::new("Enter master password:")
109                .with_display_toggle_enabled()
110                .prompt()?
111        } else {
112            println!("Creating vault without password protection...");
113            String::new()
114        };
115
116        let password_opt = if password.is_empty() { None } else { Some(password.as_str()) };
117        self.vault.create(password_opt)?;
118        
119        if use_password {
120            println!("🔒 Vault created with password protection!");
121        } else {
122            println!("✅ Vault created without password protection!");
123        }
124
125        Ok(())
126    }
127
128    async fn handle_add(&mut self) -> Result<()> {
129        self.ensure_unlocked().await?;
130
131        let name = Text::new("Server name:").prompt()?;
132        let host = Text::new("Host/IP:").prompt()?;
133        let port = Text::new("Port:")
134            .with_default("22")
135            .prompt()?
136            .parse::<u16>()
137            .unwrap_or(22);
138        let username = Text::new("Username:").prompt()?;
139        let password = Password::new("Password:")
140            .with_display_toggle_enabled()
141            .prompt()?;
142        let description = Text::new("Description (optional):").prompt().ok();
143
144        let server = Server::new(
145            name,
146            host,
147            port,
148            username,
149            password,
150            description,
151        );
152
153        self.vault.add_server(server)?;
154        println!("Server added successfully!");
155
156        Ok(())
157    }
158
159    async fn handle_list(&mut self) -> Result<()> {
160        self.ensure_unlocked().await?;
161
162        let servers = self.vault.list_servers()?;
163        
164        if servers.is_empty() {
165            println!("No servers configured.");
166            return Ok(());
167        }
168
169        println!("\nConfigured servers:");
170        println!("{:-<60}", "");
171        
172        for server in servers {
173            println!("ID: {}", server.id);
174            println!("Name: {}", server.name);
175            println!("Host: {}:{}", server.host, server.port);
176            println!("User: {}", server.username);
177            if let Some(desc) = &server.description {
178                println!("Description: {}", desc);
179            }
180            println!("{:-<60}", "");
181        }
182
183        Ok(())
184    }
185
186    async fn handle_connect(&mut self, name: Option<String>) -> Result<()> {
187        self.ensure_unlocked().await?;
188
189        let server = match name {
190            Some(name) => self.find_server_by_name_or_id(&name)?,
191            None => {
192                let servers = self.vault.list_servers()?;
193                if servers.is_empty() {
194                    println!("No servers available.");
195                    return Ok(());
196                }
197
198                let options: Vec<String> = servers
199                    .iter()
200                    .map(|s| format!("{} ({})", s.name, s.host))
201                    .collect();
202
203                let selection = Select::new("Select server:", options)
204                    .prompt()?;
205
206                let index = servers.iter().position(|s| 
207                    format!("{} ({})", s.name, s.host) == selection
208                ).unwrap();
209                
210                &servers[index]
211            }
212        };
213
214        self.connect_to_server(server).await
215    }
216
217    async fn handle_remove(&mut self, name: String) -> Result<()> {
218        self.ensure_unlocked().await?;
219
220        let server_id = {
221            let server = self.find_server_by_name_or_id(&name)?;
222            server.id
223        };
224        
225        let server = self.vault.find_server(&server_id)?
226            .ok_or_else(|| anyhow::anyhow!("Server not found"))?;
227        
228        let confirmed = Confirm::new(&format!("Remove server '{}' ({})?", server.name, server.host)
229        ).with_default(false).prompt()?;
230
231        if confirmed {
232            self.vault.remove_server(&server_id)?;
233            println!("Server removed successfully!");
234        } else {
235            println!("Operation cancelled.");
236        }
237
238        Ok(())
239    }
240
241    async fn handle_quick(&mut self) -> Result<()> {
242        // Quick now just launches the full TUI
243        self.handle_interactive().await
244    }
245
246    async fn handle_search(&mut self, query: String) -> Result<()> {
247        self.ensure_unlocked().await?;
248
249        let servers = self.vault.list_servers()?;
250        let matcher = fuzzy_matcher::skim::SkimMatcherV2::default();
251        let mut matches: Vec<(&Server, i64)> = servers
252            .iter()
253            .filter_map(|s| {
254                let hay = format!("{} {} {} {} {}", s.name, s.host, s.username, s.port, s.description.as_deref().unwrap_or(""));
255                matcher.fuzzy_match(&hay, &query).map(|score| (s, score))
256            })
257            .collect();
258        matches.sort_by(|a, b| b.1.cmp(&a.1));
259
260        if matches.is_empty() {
261            println!("No servers match your search.");
262            return Ok(());
263        }
264
265        println!("Search results:");
266        println!("{:-<60}", "");
267        
268        for (server, _) in matches {
269            println!("Name: {}", server.name);
270            println!("Host: {}:{}", server.host, server.port);
271            println!("User: {}", server.username);
272            if let Some(desc) = &server.description {
273                println!("Description: {}", desc);
274            }
275            println!("{:-<60}", "");
276        }
277
278        Ok(())
279    }
280
281    async fn handle_ssh_config(&mut self, write: bool) -> Result<()> {
282        self.ensure_unlocked().await?;
283        let servers = self.vault.list_servers()?;
284
285        let mut output = String::new();
286        for s in servers {
287            output.push_str(&format!(
288                "Host {}\n  HostName {}\n  User {}\n  Port {}\n\n",
289                s.name, s.host, s.username, s.port
290            ));
291        }
292
293        if write {
294            let mut path = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Home directory not found"))?;
295            path.push(".ssh");
296            std::fs::create_dir_all(&path)?;
297            path.push("config");
298
299            // Append entries; avoid overwriting existing unrelated entries.
300            use std::fs::OpenOptions;
301            use std::io::Write;
302            let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
303            writeln!(file, "\n# Portkey managed entries")?;
304            write!(file, "{}", output)?;
305            println!("Written SSH config entries to {}", path.display());
306        } else {
307            println!("# Preview: add these to ~/.ssh/config\n{}", output);
308        }
309
310        println!("Note: SSH config does not store passwords. Consider setting up SSH keys.");
311        Ok(())
312    }
313
314    async fn handle_interactive(&mut self) -> Result<()> {
315        if !self.vault.exists() {
316            println!("No vault found. Run 'portkey init' to create one.");
317            return Ok(());
318        }
319
320        // Unlock before entering raw mode
321        self.ensure_unlocked().await?;
322        tui::run_full_ui(&mut self.vault).map_err(|e| anyhow::anyhow!(e))
323    }
324
325    async fn ensure_unlocked(&mut self) -> Result<()> {
326        if !self.vault.exists() {
327            return Err(anyhow::anyhow!("No vault found. Run 'portkey init' to create one."));
328        }
329
330        if !self.vault.is_unlocked() {
331            // Try to unlock with no password first (for unencrypted vaults)
332            match self.vault.unlock(None) {
333                Ok(_) => {
334                    println!("Vault unlocked (no password required)!");
335                }
336                Err(_) => {
337                    // Encrypted vault - prompt for password
338                    let password = Password::new("Enter master password:")
339                        .with_display_toggle_enabled()
340                        .prompt()?;
341                    
342                    self.vault.unlock(Some(&password))?;
343                    println!("Vault unlocked!");
344                }
345            }
346        }
347
348        Ok(())
349    }
350
351    fn find_server_by_name_or_id(&self, name_or_id: &str) -> Result<&Server> {
352        let servers = self.vault.list_servers()?;
353        
354        servers.iter()
355            .find(|s| 
356                s.name.eq_ignore_ascii_case(name_or_id) || 
357                s.id.to_string().starts_with(name_or_id)
358            )
359            .ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name_or_id))
360    }
361
362    async fn connect_to_server(&self, 
363        server: &Server
364    ) -> Result<()> {
365        ssh::connect(server)
366    }
367}