Skip to main content

portkey/
cli.rs

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