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 Init,
37
38 Add,
40
41 List,
43
44 Connect {
46 name: Option<String>,
48 },
49
50 Remove {
52 name: String,
54 },
55
56 Quick,
58
59 Search { query: String },
61
62 SshConfig {
64 #[arg(long)]
66 write: bool,
67 },
68
69 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 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 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 match self.vault.unlock(None) {
386 Ok(_) => {
387 println!("Vault unlocked (no password required)!");
388 }
389 Err(_) => {
390 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}