Skip to main content

portkey/
cli.rs

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