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 Init,
24
25 Add,
27
28 List,
30
31 Connect {
33 name: Option<String>,
35 },
36
37 Remove {
39 name: String,
41 },
42
43 Quick,
45
46 Search {
48 query: String,
49 },
50
51 SshConfig {
53 #[arg(long)]
55 write: bool,
56 },
57
58 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 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 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 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 match self.vault.unlock(None) {
333 Ok(_) => {
334 println!("Vault unlocked (no password required)!");
335 }
336 Err(_) => {
337 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}