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 Init,
36
37 Add,
39
40 List,
42
43 Connect {
45 name: Option<String>,
47 },
48
49 Remove {
51 name: String,
53 },
54
55 Quick,
57
58 Search { query: String },
60
61 SshConfig {
63 #[arg(long)]
65 write: bool,
66 },
67
68 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 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 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 match self.vault.unlock(None) {
385 Ok(_) => {
386 println!("Vault unlocked (no password required)!");
387 }
388 Err(_) => {
389 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}