1use anyhow::{Context, Result};
6use std::path::Path;
7
8use crate::providers;
9use crate::providers::ProviderKind;
10use crate::snippet;
11use crate::ssh_config::model::{HostEntry, SshConfigFile};
12use crate::vault_ssh;
13
14use super::cli_args::{
15 PasswordCommands, ProviderCommands, SnippetCommands, ThemeCommands, TunnelCommands,
16};
17use super::{askpass, import, logging, preferences, quick_add, should_write_certificate_file, ui};
18
19pub fn handle_quick_add(
20 mut config: SshConfigFile,
21 target: &str,
22 alias: Option<&str>,
23 key: Option<&str>,
24) -> Result<()> {
25 log::info!(
26 "[purple] cli add: target={} alias={:?} key={:?}",
27 target,
28 alias,
29 key
30 );
31 let parsed = quick_add::parse_target(target).map_err(|e| anyhow::anyhow!(e))?;
32
33 let alias_str = alias.map(|a| a.to_string()).unwrap_or_else(|| {
34 parsed
35 .hostname
36 .split('.')
37 .next()
38 .unwrap_or(&parsed.hostname)
39 .to_string()
40 });
41
42 if alias_str.trim().is_empty() {
43 eprintln!("{}", crate::messages::cli::ALIAS_EMPTY);
44 std::process::exit(1);
45 }
46 if alias_str.contains(char::is_whitespace) {
47 eprintln!("{}", crate::messages::cli::ALIAS_WHITESPACE);
48 std::process::exit(1);
49 }
50 if crate::ssh_config::model::is_host_pattern(&alias_str) {
51 eprintln!("{}", crate::messages::cli::ALIAS_PATTERN_CHARS);
52 std::process::exit(1);
53 }
54
55 let key_val = key.unwrap_or("").to_string();
57 for (value, name) in [
58 (&alias_str, "Alias"),
59 (&parsed.hostname, "Hostname"),
60 (&parsed.user, "User"),
61 (&key_val, "Identity file"),
62 ] {
63 if value.chars().any(|c| c.is_control()) {
64 eprintln!("{}", crate::messages::cli::control_chars(name));
65 std::process::exit(1);
66 }
67 }
68
69 if parsed.hostname.contains(char::is_whitespace) {
71 eprintln!("{}", crate::messages::cli::HOSTNAME_WHITESPACE);
72 std::process::exit(1);
73 }
74 if parsed.user.contains(char::is_whitespace) {
75 eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
76 std::process::exit(1);
77 }
78
79 if config.has_host(&alias_str) {
80 eprintln!("{}", crate::messages::cli::alias_already_exists(&alias_str));
81 std::process::exit(1);
82 }
83
84 let entry = HostEntry {
85 alias: alias_str.clone(),
86 hostname: parsed.hostname,
87 user: parsed.user,
88 port: parsed.port,
89 identity_file: key_val,
90 ..Default::default()
91 };
92
93 config.add_host(&entry);
94 log::debug!("[config] cli add: writing ssh config (alias={})", alias_str);
95 config.write()?;
96 log::info!("[purple] cli add: host added alias={}", alias_str);
97 println!("{}", crate::messages::cli::welcome(&alias_str));
98 Ok(())
99}
100
101pub fn handle_import(
102 env: &crate::runtime::env::Env,
103 mut config: SshConfigFile,
104 file: Option<&str>,
105 known_hosts: bool,
106 group: Option<&str>,
107) -> Result<()> {
108 log::info!(
109 "[purple] cli import: source={} group={:?}",
110 if known_hosts {
111 "known_hosts".to_string()
112 } else {
113 file.unwrap_or("(missing)").to_string()
114 },
115 group
116 );
117 let result = if known_hosts {
118 import::import_from_known_hosts(env.paths(), &mut config, group)
119 } else if let Some(path) = file {
120 let resolved = super::resolve_config_path(env.paths(), path)?;
121 import::import_from_file(&mut config, &resolved, group)
122 } else {
123 eprintln!("{}", crate::messages::cli::IMPORT_NO_FILE);
124 std::process::exit(1);
125 };
126
127 match result {
128 Ok((imported, skipped, parse_failures, read_errors)) => {
129 if imported > 0 {
130 log::debug!(
131 "[config] cli import: writing ssh config ({} new hosts)",
132 imported
133 );
134 config.write()?;
135 }
136 log::info!(
137 "[purple] cli import: imported={} skipped={} parse_failures={} read_errors={}",
138 imported,
139 skipped,
140 parse_failures,
141 read_errors
142 );
143 println!("{}", crate::messages::imported_hosts(imported, skipped));
144 if parse_failures > 0 {
145 eprintln!(
146 "{}",
147 crate::messages::cli::import_parse_failures(parse_failures)
148 );
149 }
150 if read_errors > 0 {
151 eprintln!("{}", crate::messages::cli::import_read_errors(read_errors));
152 }
153 Ok(())
154 }
155 Err(e) => {
156 eprintln!("{}", e);
157 std::process::exit(1);
158 }
159 }
160}
161
162pub fn handle_sync(
163 env: &crate::runtime::env::Env,
164 mut config: SshConfigFile,
165 provider_name: Option<&str>,
166 dry_run: bool,
167 remove: bool,
168) -> Result<()> {
169 log::info!(
170 "[purple] cli sync: provider={:?} dry_run={} remove={}",
171 provider_name,
172 dry_run,
173 remove
174 );
175 let provider_config = providers::config::ProviderConfig::load(env.paths());
176 let sections: Vec<&providers::config::ProviderSection> = if let Some(arg) = provider_name {
180 let id: providers::config::ProviderConfigId = match arg.parse() {
181 Ok(id) => id,
182 Err(e) => {
183 eprintln!("{}: {}", arg, e);
184 std::process::exit(1);
185 }
186 };
187 if providers::get_provider(&id.provider).is_none() {
188 eprintln!("{}", crate::messages::cli::unknown_provider(&id.provider));
189 std::process::exit(1);
190 }
191 let matched: Vec<&providers::config::ProviderSection> = match &id.label {
192 Some(_) => provider_config.section_by_id(&id).into_iter().collect(),
193 None => provider_config.sections_for_provider(&id.provider),
194 };
195 if matched.is_empty() {
196 eprintln!("{}", crate::messages::cli::no_config_for(arg));
197 std::process::exit(1);
198 }
199 matched
200 } else {
201 let configured = provider_config.configured_providers();
202 if configured.is_empty() {
203 eprintln!("{}", crate::messages::cli::NO_PROVIDERS);
204 std::process::exit(1);
205 }
206 configured.iter().collect()
207 };
208
209 let mut any_changes = false;
210 let mut any_failures = false;
211 let mut any_hard_failures = false;
212 let mut all_renames: Vec<(String, String)> = Vec::new();
213
214 for section in §ions {
215 let provider = match providers::get_provider_with_config(section) {
216 Some(p) => p,
217 None => {
218 log::warn!(
219 "[config] cli sync: skipping unknown provider '{}'",
220 section.provider()
221 );
222 eprintln!(
223 "{}",
224 crate::messages::cli::skipping_unknown_provider(section.provider())
225 );
226 any_failures = true;
227 continue;
230 }
231 };
232 let display_name = providers::provider_display_name(section.provider());
233 log::debug!(
234 "[external] cli sync: starting provider={} label={:?}",
235 section.provider(),
236 section.id.label
237 );
238 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
239 print!("{}", crate::messages::cli::syncing_start(display_name));
240 let _ = std::io::Write::flush(&mut std::io::stdout());
241
242 let last_summary = std::cell::RefCell::new(String::new());
243 let progress = |msg: &str| {
244 *last_summary.borrow_mut() = msg.to_string();
245 if is_tty {
246 print!("{}", crate::messages::cli::syncing(display_name, msg));
247 let _ = std::io::Write::flush(&mut std::io::stdout());
248 }
249 };
250 let fetch_result = provider.fetch_hosts_with_progress(
251 §ion.token,
252 &std::sync::atomic::AtomicBool::new(false),
253 env,
254 &progress,
255 );
256 let summary = last_summary.into_inner();
257 if is_tty {
259 if summary.is_empty() {
260 print!("{}", crate::messages::cli::syncing(display_name, ""));
261 } else {
262 println!("{}", crate::messages::cli::syncing(display_name, &summary));
263 }
264 let _ = std::io::Write::flush(&mut std::io::stdout());
265 } else if !summary.is_empty() {
266 println!("{}", summary);
267 }
268 let (hosts, suppress_remove) = match fetch_result {
269 Ok(hosts) => (hosts, false),
270 Err(providers::ProviderError::PartialResult {
271 hosts,
272 failures,
273 total,
274 }) => {
275 println!(
276 "{}",
277 crate::messages::cli::servers_found_with_failures(hosts.len(), failures, total)
278 );
279 if remove {
280 eprintln!("{}", crate::messages::cli::sync_skip_remove(display_name));
281 }
282 any_failures = true;
283 (hosts, true)
284 }
285 Err(e) => {
286 println!("{}", crate::messages::cli::SYNC_FAILED);
287 eprintln!("{}", crate::messages::cli::sync_error(display_name, &e));
288 any_failures = true;
289 any_hard_failures = true;
290 continue;
291 }
292 };
293 if !suppress_remove {
294 println!("{}", crate::messages::cli::servers_found(hosts.len()));
295 }
296 let effective_remove = remove && !suppress_remove;
297 let result = providers::sync::sync_provider(
298 &mut config,
299 &*provider,
300 &hosts,
301 section,
302 effective_remove,
303 suppress_remove, dry_run,
305 );
306 let prefix = if dry_run {
307 crate::messages::cli::SYNC_RESULT_PREFIX_DRY_RUN
308 } else {
309 crate::messages::cli::SYNC_RESULT_PREFIX_LIVE
310 };
311 println!(
312 "{}",
313 crate::messages::cli::sync_result(
314 prefix,
315 result.added,
316 result.updated,
317 result.unchanged
318 )
319 );
320 if result.removed > 0 {
321 println!("{}", crate::messages::cli::sync_removed(result.removed));
322 }
323 if result.stale > 0 {
324 println!("{}", crate::messages::cli::sync_stale(result.stale));
325 }
326 if result.added > 0 || result.updated > 0 || result.removed > 0 || result.stale > 0 {
327 any_changes = true;
328 }
329 if !dry_run {
330 all_renames.extend(result.renames);
331 }
332 }
333
334 if any_changes && !dry_run {
335 if any_hard_failures {
336 log::warn!("[config] cli sync: skipping ssh config write due to hard failures");
337 eprintln!("{}", crate::messages::cli::SYNC_SKIP_WRITE);
338 } else {
339 log::debug!("[config] cli sync: writing ssh config");
340 config.write()?;
341 log::info!("[purple] cli sync: ssh config written");
342 if !all_renames.is_empty() {
347 log::info!(
348 "[purple] cli sync: migrating per-host state for {} rename(s)",
349 all_renames.len()
350 );
351 crate::app::migrate_renames_persistent_state(env.paths(), &all_renames);
352 }
353 }
354 }
355
356 if any_failures {
357 log::warn!("[purple] cli sync: completed with failures (exit 1)");
358 std::process::exit(1);
359 }
360
361 log::info!("[purple] cli sync: completed successfully");
362 Ok(())
363}
364
365pub fn handle_provider_command(
366 env: &crate::runtime::env::Env,
367 command: ProviderCommands,
368) -> Result<()> {
369 log::info!("[purple] cli provider: dispatch");
370 match command {
371 ProviderCommands::Add {
372 provider,
373 token,
374 token_stdin,
375 mut prefix,
376 mut user,
377 mut key,
378 url,
379 mut profile,
380 mut regions,
381 mut project,
382 mut compartment,
383 no_verify_tls,
384 verify_tls,
385 auto_sync,
386 no_auto_sync,
387 label,
388 } => {
389 let p = match providers::get_provider(&provider) {
390 Some(p) => p,
391 None => {
392 eprintln!(
393 "Never heard of '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip, teleport.",
394 provider
395 );
396 std::process::exit(1);
397 }
398 };
399 let kind = provider.parse::<ProviderKind>().ok();
401
402 let mut token = token;
404 let mut url = url;
405 let mut no_verify_tls = no_verify_tls;
406 let mut verify_tls = verify_tls;
407 if kind != Some(ProviderKind::Proxmox) {
408 if url.is_some() {
409 eprintln!("{}", crate::messages::cli::WARN_URL_NOT_USED);
410 url = None;
411 }
412 if no_verify_tls {
413 eprintln!("{}", crate::messages::cli::WARN_NO_VERIFY_TLS_NOT_USED);
414 no_verify_tls = false;
415 }
416 if verify_tls {
417 eprintln!("{}", crate::messages::cli::WARN_VERIFY_TLS_NOT_USED);
418 verify_tls = false;
419 }
420 }
421 if kind != Some(ProviderKind::Aws) && profile.is_some() {
423 eprintln!("{}", crate::messages::cli::WARN_PROFILE_NOT_USED);
424 profile = None;
425 }
426 if !kind.is_some_and(ProviderKind::accepts_cli_regions) && regions.is_some() {
427 eprintln!("{}", crate::messages::cli::WARN_REGIONS_NOT_USED);
428 regions = None;
429 }
430 if kind != Some(ProviderKind::Gcp) && project.is_some() {
431 eprintln!("{}", crate::messages::cli::WARN_PROJECT_NOT_USED);
432 project = None;
433 }
434 if kind != Some(ProviderKind::Oracle) && compartment.is_some() {
435 eprintln!("{}", crate::messages::cli::WARN_COMPARTMENT_NOT_USED);
436 compartment = None;
437 }
438
439 let target_id = match label.as_deref() {
446 Some(l) => providers::config::ProviderConfigId::labeled(provider.clone(), l),
447 None => providers::config::ProviderConfigId::bare(provider.clone()),
448 };
449 let existing_section = providers::config::ProviderConfig::load(env.paths())
450 .section_by_id(&target_id)
451 .cloned();
452
453 if let Some(ref existing) = existing_section {
454 if kind == Some(ProviderKind::Proxmox) && url.is_none() && !existing.url.is_empty()
456 {
457 url = Some(existing.url.clone());
458 }
459 if token.is_none()
460 && !token_stdin
461 && env.purple_token().is_none()
462 && !existing.token.is_empty()
463 {
464 token = Some(existing.token.clone());
465 }
466 if prefix.is_none() {
467 prefix = Some(existing.alias_prefix.clone());
468 }
469 if user.is_none() {
470 user = Some(existing.user.clone());
471 }
472 if key.is_none() && !existing.identity_file.is_empty() {
473 key = Some(existing.identity_file.clone());
474 }
475 if !no_verify_tls && !verify_tls && !existing.verify_tls {
477 no_verify_tls = true;
478 }
479 if kind == Some(ProviderKind::Aws)
481 && profile.is_none()
482 && !existing.profile.is_empty()
483 {
484 profile = Some(existing.profile.clone());
485 }
486 if kind.is_some_and(ProviderKind::accepts_cli_regions)
488 && regions.is_none()
489 && !existing.regions.is_empty()
490 {
491 regions = Some(existing.regions.clone());
492 }
493 if kind == Some(ProviderKind::Gcp)
495 && project.is_none()
496 && !existing.project.is_empty()
497 {
498 project = Some(existing.project.clone());
499 }
500 if kind == Some(ProviderKind::Oracle)
502 && compartment.is_none()
503 && !existing.compartment.is_empty()
504 {
505 compartment = Some(existing.compartment.clone());
506 }
507 }
508
509 if kind == Some(ProviderKind::Proxmox) {
511 if url.is_none() || url.as_deref().unwrap_or("").trim().is_empty() {
512 eprintln!("{}", crate::messages::cli::PROXMOX_URL_REQUIRED);
513 std::process::exit(1);
514 }
515 let u = url.as_deref().unwrap();
516 if !u.to_ascii_lowercase().starts_with("https://") {
517 eprintln!("{}", crate::messages::cli::PROVIDER_URL_REQUIRES_HTTPS);
518 std::process::exit(1);
519 }
520 }
521
522 let token_omitted = token.is_none() && !token_stdin && env.purple_token().is_none();
528 let token_optional = kind.is_some_and(ProviderKind::token_is_optional);
529 let token = if token_optional && token_omitted {
530 String::new()
531 } else {
532 match super::resolve_token(env, token, token_stdin) {
533 Ok(t) => t,
534 Err(e) => {
535 eprintln!("{}", e);
536 std::process::exit(1);
537 }
538 }
539 };
540
541 if token.trim().is_empty() && token_optional && !token_omitted {
546 let display_name = providers::provider_display_name(&provider);
547 let replaces_stored = existing_section
548 .as_ref()
549 .is_some_and(|s| !s.token.trim().is_empty());
550 if replaces_stored {
551 eprintln!(
552 "{}",
553 crate::messages::cli::warn_token_replaced_with_empty(display_name)
554 );
555 } else {
556 eprintln!(
557 "{}",
558 crate::messages::cli::warn_token_resolved_empty(display_name)
559 );
560 }
561 }
562
563 if token.trim().is_empty() && !token_optional {
564 if kind == Some(ProviderKind::Gcp) {
565 eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_GCP);
566 } else if kind == Some(ProviderKind::Oracle) {
567 eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_ORACLE);
568 } else {
569 eprintln!(
570 "{}",
571 crate::messages::cli::provider_token_required(
572 providers::provider_display_name(&provider)
573 )
574 );
575 }
576 std::process::exit(1);
577 }
578
579 let alias_prefix = prefix.unwrap_or_else(|| p.short_label().to_string());
580 if crate::ssh_config::model::is_host_pattern(&alias_prefix) {
581 eprintln!("{}", crate::messages::cli::ALIAS_PREFIX_INVALID);
582 std::process::exit(1);
583 }
584
585 let user = user.unwrap_or_else(|| "root".to_string());
586 let identity_file = key.unwrap_or_default();
587
588 let url_value = url.clone().unwrap_or_default();
590 let profile_value = profile.clone().unwrap_or_default();
591 let regions_value = regions.clone().unwrap_or_default();
592 let project_value = project.clone().unwrap_or_default();
593 let compartment_value = compartment.clone().unwrap_or_default();
594 for (value, name) in [
595 (&url_value, "URL"),
596 (&token, "Token"),
597 (&alias_prefix, "Alias prefix"),
598 (&user, "User"),
599 (&identity_file, "Identity file"),
600 (&profile_value, "Profile"),
601 (&project_value, "Project"),
602 (®ions_value, "Regions"),
603 (&compartment_value, "Compartment"),
604 ] {
605 if value.chars().any(|c| c.is_control()) {
606 eprintln!("{}", crate::messages::cli::control_chars(name));
607 std::process::exit(1);
608 }
609 }
610 if user.contains(char::is_whitespace) {
611 eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
612 std::process::exit(1);
613 }
614
615 let resolved_auto_sync = if auto_sync {
617 true
618 } else if no_auto_sync {
619 false
620 } else if let Some(ref existing) = existing_section {
621 existing.auto_sync
622 } else {
623 kind != Some(ProviderKind::Proxmox)
624 };
625
626 let resolved_profile = profile.unwrap_or_default();
627 let resolved_regions = regions.unwrap_or_default();
628 let resolved_project = project.unwrap_or_default();
629 let resolved_compartment = compartment.unwrap_or_default();
630
631 if kind == Some(ProviderKind::Aws) && resolved_regions.trim().is_empty() {
633 eprintln!("{}", crate::messages::cli::AWS_REGIONS_REQUIRED);
634 std::process::exit(1);
635 }
636 if kind == Some(ProviderKind::Scaleway) && resolved_regions.trim().is_empty() {
637 eprintln!("{}", crate::messages::cli::SCALEWAY_REGIONS_REQUIRED);
638 std::process::exit(1);
639 }
640 if kind == Some(ProviderKind::Azure) {
641 if resolved_regions.trim().is_empty() {
642 eprintln!("{}", crate::messages::cli::AZURE_REGIONS_REQUIRED);
643 std::process::exit(1);
644 }
645 for sub in resolved_regions
646 .split(',')
647 .map(|s| s.trim())
648 .filter(|s| !s.is_empty())
649 {
650 if !providers::azure::is_valid_subscription_id(sub) {
651 eprintln!(
652 "{}",
653 crate::messages::cli::azure_subscription_id_invalid(sub)
654 );
655 std::process::exit(1);
656 }
657 }
658 }
659 if kind == Some(ProviderKind::Gcp) && resolved_project.trim().is_empty() {
661 eprintln!("{}", crate::messages::cli::GCP_PROJECT_REQUIRED);
662 std::process::exit(1);
663 }
664 if kind == Some(ProviderKind::Oracle) && resolved_compartment.trim().is_empty() {
666 eprintln!("{}", crate::messages::cli::ORACLE_COMPARTMENT_REQUIRED);
667 std::process::exit(1);
668 }
669
670 let mut config = providers::config::ProviderConfig::load(env.paths());
671
672 let id: providers::config::ProviderConfigId = match label.as_deref() {
678 Some(l) => {
679 if let Err(e) = providers::config::validate_label(l) {
680 eprintln!("{}", crate::messages::cli::invalid_label_flag(&e));
681 std::process::exit(1);
682 }
683 providers::config::ProviderConfigId::labeled(provider.clone(), l)
684 }
685 None => providers::config::ProviderConfigId::bare(provider.clone()),
686 };
687
688 let existing = config.sections_for_provider(&provider);
691 let has_bare = existing.iter().any(|s| s.id.label.is_none());
692 let has_labeled = existing.iter().any(|s| s.id.label.is_some());
693 if id.label.is_none() && has_labeled {
694 eprintln!("{}", crate::messages::cli::add_requires_label(&provider));
695 std::process::exit(1);
696 }
697 if id.label.is_some() && has_bare {
698 eprintln!(
699 "{}",
700 crate::messages::cli::add_label_collides_with_bare(&provider)
701 );
702 std::process::exit(1);
703 }
704
705 let section = providers::config::ProviderSection {
706 id: id.clone(),
707 token,
708 alias_prefix,
709 user,
710 identity_file,
711 url: url.unwrap_or_default(),
712 verify_tls: !no_verify_tls,
713 auto_sync: resolved_auto_sync,
714 profile: resolved_profile,
715 regions: resolved_regions,
716 project: resolved_project,
717 compartment: resolved_compartment,
718 vault_role: String::new(),
719 vault_addr: String::new(),
720 };
721
722 let saved_record = section.log_record();
724 config.set_section(section);
725 config.save().map_err(|e| {
726 log::warn!("[config] Save failed for [{}]: {}", id, e);
727 anyhow::anyhow!("Failed to save: {}", e)
728 })?;
729 log::debug!("[purple] provider saved: [{}] {}", id, saved_record);
730 println!("{}", crate::messages::cli::saved_config(&id.to_string()));
731 Ok(())
732 }
733 ProviderCommands::List => {
734 let config = providers::config::ProviderConfig::load(env.paths());
735 let sections = config.configured_providers();
736 if sections.is_empty() {
737 println!("{}", crate::messages::cli::NO_PROVIDERS);
738 } else {
739 for s in sections {
740 let display_name = providers::provider_display_name(s.provider());
741 let label_suffix = match &s.id.label {
742 Some(l) => format!(" ({})", l),
743 None => String::new(),
744 };
745 println!(
746 " {:<24} {}-*{:>8}",
747 format!("{}{}", display_name, label_suffix),
748 s.alias_prefix,
749 s.user
750 );
751 }
752 }
753 Ok(())
754 }
755 ProviderCommands::Remove { provider } => {
756 let id: providers::config::ProviderConfigId = match provider.parse() {
759 Ok(id) => id,
760 Err(e) => {
761 eprintln!("{}: {}", provider, e);
762 std::process::exit(1);
763 }
764 };
765 let mut config = providers::config::ProviderConfig::load(env.paths());
766 let removed = match &id.label {
767 Some(_) => {
768 if config.section_by_id(&id).is_none() {
769 eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
770 std::process::exit(1);
771 }
772 config.remove_section_by_id(&id);
773 1
774 }
775 None => {
776 let count = config.sections_for_provider(&id.provider).len();
777 if count == 0 {
778 eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
779 std::process::exit(1);
780 }
781 config.remove_section(&id.provider);
782 count
783 }
784 };
785 config.save().map_err(|e| {
786 log::warn!("[config] Save failed removing [{}]: {}", id, e);
787 anyhow::anyhow!("Failed to save: {}", e)
788 })?;
789 log::debug!(
790 "[purple] provider removed: [{}], {} section(s)",
791 id,
792 removed
793 );
794 if removed == 1 {
795 println!("{}", crate::messages::cli::removed_config(&provider));
796 } else {
797 println!(
798 "{}",
799 crate::messages::cli::removed_configs(&provider, removed)
800 );
801 }
802 Ok(())
803 }
804 }
805}
806
807pub fn handle_tunnel_command(mut config: SshConfigFile, command: TunnelCommands) -> Result<()> {
808 log::info!("[purple] cli tunnel: dispatch");
809 match command {
810 TunnelCommands::List { alias } => {
811 if let Some(alias) = alias {
812 if !config.has_host(&alias) {
814 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
815 std::process::exit(1);
816 }
817 let rules = config.find_tunnel_directives(&alias);
818 if rules.is_empty() {
819 println!("{}", crate::messages::cli::no_tunnels_for(&alias));
820 } else {
821 println!("{}", crate::messages::cli::tunnels_for(&alias));
822 for rule in &rules {
823 println!(" {}", rule.display());
824 }
825 }
826 } else {
827 let entries = config.host_entries();
829 let with_tunnels: Vec<_> = entries.iter().filter(|e| e.tunnel_count > 0).collect();
830 if with_tunnels.is_empty() {
831 println!("{}", crate::messages::cli::NO_TUNNELS);
832 } else {
833 for (i, host) in with_tunnels.iter().enumerate() {
834 if i > 0 {
835 println!();
836 }
837 println!("{}:", host.alias);
838 for rule in config.find_tunnel_directives(&host.alias) {
839 println!(" {}", rule.display());
840 }
841 }
842 }
843 }
844 Ok(())
845 }
846 TunnelCommands::Add { alias, forward } => {
847 if !config.has_host(&alias) {
848 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
849 std::process::exit(1);
850 }
851 if config.is_included_host(&alias) {
852 eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
853 std::process::exit(1);
854 }
855 let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
856 eprintln!("{}", e);
857 std::process::exit(1);
858 });
859 let key = rule.tunnel_type.directive_key();
860 let value = rule.to_directive_value();
861 if config.has_forward(&alias, key, &value) {
863 eprintln!("{}", crate::messages::cli::forward_exists(&forward, &alias));
864 std::process::exit(1);
865 }
866 config.add_forward(&alias, key, &value);
867 log::debug!(
868 "[config] cli tunnel add: writing ssh config (alias={})",
869 alias
870 );
871 if let Err(e) = config.write() {
872 log::warn!("[purple] cli tunnel add: write failed: {}", e);
873 eprintln!("{}", crate::messages::cli::save_config_failed(&e));
874 std::process::exit(1);
875 }
876 log::info!(
877 "[purple] cli tunnel add: forward={} alias={}",
878 forward,
879 alias
880 );
881 println!("{}", crate::messages::cli::added_forward(&forward, &alias));
882 Ok(())
883 }
884 TunnelCommands::Remove { alias, forward } => {
885 if !config.has_host(&alias) {
886 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
887 std::process::exit(1);
888 }
889 if config.is_included_host(&alias) {
890 eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
891 std::process::exit(1);
892 }
893 let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
894 eprintln!("{}", e);
895 std::process::exit(1);
896 });
897 let key = rule.tunnel_type.directive_key();
898 let value = rule.to_directive_value();
899 let removed = config.remove_forward(&alias, key, &value);
900 if !removed {
901 eprintln!(
902 "{}",
903 crate::messages::cli::forward_not_found(&forward, &alias)
904 );
905 std::process::exit(1);
906 }
907 log::debug!(
908 "[config] cli tunnel remove: writing ssh config (alias={})",
909 alias
910 );
911 if let Err(e) = config.write() {
912 log::warn!("[purple] cli tunnel remove: write failed: {}", e);
913 eprintln!("{}", crate::messages::cli::save_config_failed(&e));
914 std::process::exit(1);
915 }
916 log::info!(
917 "[purple] cli tunnel remove: forward={} alias={}",
918 forward,
919 alias
920 );
921 println!(
922 "{}",
923 crate::messages::cli::removed_forward(&forward, &alias)
924 );
925 Ok(())
926 }
927 TunnelCommands::Start { alias } => {
928 log::info!("[purple] cli tunnel start: alias={}", alias);
929 if !config.has_host(&alias) {
930 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
931 std::process::exit(1);
932 }
933 let tunnels = config.find_tunnel_directives(&alias);
934 if tunnels.is_empty() {
935 log::warn!("[purple] cli tunnel start: no forwards for alias={}", alias);
936 eprintln!("{}", crate::messages::cli::no_forwards(&alias));
937 std::process::exit(1);
938 }
939 println!("{}", crate::messages::cli::starting_tunnel(&alias));
940 let status = std::process::Command::new("ssh")
942 .arg("-F")
943 .arg(&config.path)
944 .arg("-N")
945 .arg("--")
946 .arg(&alias)
947 .status()
948 .map_err(|e| anyhow::anyhow!("Failed to start ssh: {}", e))?;
949 let code = status.code().unwrap_or(1);
950 std::process::exit(code);
951 }
952 }
953}
954
955pub fn prompt_hidden_input(prompt: &str) -> Result<Option<String>> {
957 eprint!("{}", prompt);
958 crossterm::terminal::enable_raw_mode()?;
959 let mut input = String::new();
960 loop {
961 if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
962 match key.code {
963 crossterm::event::KeyCode::Enter => break,
964 crossterm::event::KeyCode::Char(c) => {
965 input.push(c);
966 eprint!("*");
967 }
968 crossterm::event::KeyCode::Backspace if input.pop().is_some() => {
969 eprint!("\x08 \x08");
970 }
971 crossterm::event::KeyCode::Esc => {
972 crossterm::terminal::disable_raw_mode()?;
973 eprintln!();
974 return Ok(None);
975 }
976 _ => {}
977 }
978 }
979 }
980 crossterm::terminal::disable_raw_mode()?;
981 eprintln!();
982 Ok(Some(input))
983}
984
985pub fn handle_password_command(
991 env: &crate::runtime::env::Env,
992 command: PasswordCommands,
993) -> Result<()> {
994 log::info!("[purple] cli password: dispatch");
995 match command {
996 PasswordCommands::Set { alias } => {
997 let password =
998 match prompt_hidden_input(&crate::messages::askpass::password_prompt(&alias))? {
999 Some(p) if !p.is_empty() => p,
1000 Some(_) => {
1001 eprintln!("{}", crate::messages::cli::PASSWORD_EMPTY);
1002 std::process::exit(1);
1003 }
1004 None => {
1005 eprintln!("{}", crate::messages::cli::CANCELLED);
1006 std::process::exit(1);
1007 }
1008 };
1009
1010 askpass::store_in_keychain(env, &alias, &password)?;
1011 println!(
1012 "Password stored for {}. Set 'keychain' as password source to use it.",
1013 alias
1014 );
1015 Ok(())
1016 }
1017 PasswordCommands::Remove { alias } => {
1018 askpass::remove_from_keychain(env, &alias)?;
1019 println!("{}", crate::messages::cli::password_removed(&alias));
1020 Ok(())
1021 }
1022 }
1023}
1024
1025pub fn handle_snippet_command(
1026 env: &crate::runtime::env::Env,
1027 config: SshConfigFile,
1028 command: SnippetCommands,
1029 config_path: &Path,
1030) -> Result<()> {
1031 log::info!("[purple] cli snippet: dispatch");
1032 match command {
1033 SnippetCommands::List => {
1034 let store = snippet::SnippetStore::load(env.paths());
1035 if store.snippets.is_empty() {
1036 println!("{}", crate::messages::cli::NO_SNIPPETS);
1037 } else {
1038 for s in &store.snippets {
1039 if s.description.is_empty() {
1040 println!(" {} {}", s.name, s.command);
1041 } else {
1042 println!(" {} {} ({})", s.name, s.command, s.description);
1043 }
1044 }
1045 }
1046 Ok(())
1047 }
1048 SnippetCommands::Add {
1049 name,
1050 command,
1051 description,
1052 } => {
1053 if let Err(e) = snippet::validate_name(&name) {
1054 eprintln!("{}", e);
1055 std::process::exit(1);
1056 }
1057 if let Err(e) = snippet::validate_command(&command) {
1058 eprintln!("{}", e);
1059 std::process::exit(1);
1060 }
1061 if let Some(ref desc) = description {
1062 if desc.contains(|c: char| c.is_control()) {
1063 eprintln!("{}", crate::messages::cli::DESCRIPTION_CONTROL_CHARS);
1064 std::process::exit(1);
1065 }
1066 }
1067 let mut store = snippet::SnippetStore::load(env.paths());
1068 let is_update = store.get(&name).is_some();
1069 store.set(snippet::Snippet {
1070 name: name.clone(),
1071 command,
1072 description: description.unwrap_or_default(),
1073 });
1074 store.save()?;
1075 if is_update {
1076 println!("{}", crate::messages::cli::snippet_updated(&name));
1077 } else {
1078 println!("{}", crate::messages::cli::snippet_added(&name));
1079 }
1080 Ok(())
1081 }
1082 SnippetCommands::Remove { name } => {
1083 let mut store = snippet::SnippetStore::load(env.paths());
1084 if store.get(&name).is_none() {
1085 eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1086 std::process::exit(1);
1087 }
1088 store.remove(&name);
1089 store.save()?;
1090 println!("{}", crate::messages::cli::snippet_removed(&name));
1091 Ok(())
1092 }
1093 SnippetCommands::Run {
1094 name,
1095 alias,
1096 tag,
1097 all,
1098 parallel,
1099 } => {
1100 let store = snippet::SnippetStore::load(env.paths());
1101 let snip = match store.get(&name) {
1102 Some(s) => s.clone(),
1103 None => {
1104 eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1105 std::process::exit(1);
1106 }
1107 };
1108
1109 let entries = config.host_entries();
1110
1111 let targets: Vec<&HostEntry> = if let Some(ref alias) = alias {
1113 match entries.iter().find(|h| h.alias == *alias) {
1114 Some(h) => vec![h],
1115 None => {
1116 eprintln!("{}", crate::messages::cli::host_not_found(alias));
1117 std::process::exit(1);
1118 }
1119 }
1120 } else if let Some(ref tag_filter) = tag {
1121 let matched: Vec<_> = entries
1122 .iter()
1123 .filter(|h| h.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_filter)))
1124 .collect();
1125 if matched.is_empty() {
1126 eprintln!("{}", crate::messages::cli::no_hosts_with_tag(tag_filter));
1127 std::process::exit(1);
1128 }
1129 matched
1130 } else if all {
1131 entries.iter().collect()
1132 } else {
1133 eprintln!("{}", crate::messages::cli::SPECIFY_TARGET);
1134 std::process::exit(1);
1135 };
1136
1137 if targets.len() == 1 {
1138 let host = targets[0];
1140 let askpass = host
1141 .askpass
1142 .clone()
1143 .or_else(|| preferences::load_askpass_default(env.paths()));
1144 super::ensure_proton_login(env, askpass.as_deref());
1145 let bw_session = super::ensure_bw_session(env, None, askpass.as_deref());
1146 super::ensure_keychain_password(env, &host.alias, askpass.as_deref());
1147 match snippet::run_snippet(
1148 &host.alias,
1149 config_path,
1150 env,
1151 &snip.command,
1152 askpass.as_deref(),
1153 bw_session.as_deref(),
1154 false,
1155 false,
1156 ) {
1157 Ok(r) => {
1158 if !r.status.success() {
1159 std::process::exit(r.status.code().unwrap_or(1));
1160 }
1161 }
1162 Err(e) => {
1163 eprintln!("{}", crate::messages::cli::operation_failed(&e));
1164 std::process::exit(1);
1165 }
1166 }
1167 } else if parallel {
1168 use std::sync::mpsc;
1170 use std::thread;
1171 let (tx, rx) = mpsc::channel();
1172 let max_concurrent: usize = 20;
1173 let (slot_tx, slot_rx) = mpsc::channel();
1174 for _ in 0..max_concurrent {
1175 let _ = slot_tx.send(());
1176 }
1177 let config_path = config_path.to_path_buf();
1178 let any_bw = targets.iter().any(|h| {
1180 let askpass = h
1181 .askpass
1182 .clone()
1183 .or_else(|| preferences::load_askpass_default(env.paths()));
1184 askpass.as_deref().unwrap_or("").starts_with("bw:")
1185 });
1186 let bw_session = if any_bw {
1187 let bw_askpass = targets
1188 .iter()
1189 .find_map(|h| h.askpass.as_ref().filter(|a| a.starts_with("bw:")))
1190 .cloned()
1191 .or_else(|| preferences::load_askpass_default(env.paths()));
1192 super::ensure_bw_session(env, None, bw_askpass.as_deref())
1193 } else {
1194 None
1195 };
1196 let target_askpass: Vec<Option<String>> =
1200 targets.iter().map(|h| h.askpass.clone()).collect();
1201 if let Some(askpass) = select_proton_askpass(
1202 &target_askpass,
1203 preferences::load_askpass_default(env.paths()),
1204 ) {
1205 super::ensure_proton_login(env, Some(&askpass));
1206 }
1207 let targets_info: Vec<_> = targets
1208 .iter()
1209 .map(|h| {
1210 let askpass = h
1211 .askpass
1212 .clone()
1213 .or_else(|| preferences::load_askpass_default(env.paths()));
1214 super::ensure_keychain_password(env, &h.alias, askpass.as_deref());
1215 (h.alias.clone(), askpass)
1216 })
1217 .collect();
1218 let command = snip.command.clone();
1219 let env = std::sync::Arc::new(env.clone());
1220 thread::spawn(move || {
1221 for (alias, askpass) in targets_info {
1222 let _ = slot_rx.recv();
1223 let slot_tx = slot_tx.clone();
1224 let tx = tx.clone();
1225 let config_path = config_path.clone();
1226 let env = std::sync::Arc::clone(&env);
1227 let command = command.clone();
1228 let bw_session = bw_session.clone();
1229 thread::spawn(move || {
1230 let result = snippet::run_snippet(
1231 &alias,
1232 &config_path,
1233 &env,
1234 &command,
1235 askpass.as_deref(),
1236 bw_session.as_deref(),
1237 true,
1238 false,
1239 );
1240 let _ = tx.send((alias, result));
1241 let _ = slot_tx.send(());
1242 });
1243 }
1244 });
1245
1246 let host_count = targets.len();
1247 for _ in 0..host_count {
1248 if let Ok((alias, result)) = rx.recv() {
1249 match result {
1250 Ok(r) => {
1251 for line in r.stdout.lines() {
1252 println!("[{}] {}", alias, line);
1253 }
1254 for line in r.stderr.lines() {
1255 eprintln!("[{}] {}", alias, line);
1256 }
1257 }
1258 Err(e) => {
1259 eprintln!("{}", crate::messages::cli::host_failed(&alias, &e))
1260 }
1261 }
1262 }
1263 }
1264 } else {
1265 let mut bw_session: Option<String> = None;
1267 for host in &targets {
1268 let askpass = host
1269 .askpass
1270 .clone()
1271 .or_else(|| preferences::load_askpass_default(env.paths()));
1272 super::ensure_proton_login(env, askpass.as_deref());
1273 if let Some(token) =
1274 super::ensure_bw_session(env, bw_session.as_deref(), askpass.as_deref())
1275 {
1276 bw_session = Some(token);
1277 }
1278 super::ensure_keychain_password(env, &host.alias, askpass.as_deref());
1279 println!("{}", crate::messages::cli::host_separator(&host.alias));
1280 match snippet::run_snippet(
1281 &host.alias,
1282 config_path,
1283 env,
1284 &snip.command,
1285 askpass.as_deref(),
1286 bw_session.as_deref(),
1287 false,
1288 false,
1289 ) {
1290 Ok(r) => {
1291 if !r.status.success() {
1292 eprintln!(
1293 "{}",
1294 crate::messages::cli::exited_with_code(
1295 r.status.code().unwrap_or(1)
1296 )
1297 );
1298 }
1299 }
1300 Err(e) => {
1301 eprintln!("{}", crate::messages::cli::host_failed(&host.alias, &e))
1302 }
1303 }
1304 println!();
1305 }
1306 }
1307 Ok(())
1308 }
1309 }
1310}
1311
1312pub fn handle_logs_command(tail: bool, clear: bool, env: &crate::runtime::env::Env) -> Result<()> {
1313 let path = logging::log_path(env.paths()).context("Could not determine log path")?;
1314 if clear {
1315 if path.exists() {
1316 std::fs::remove_file(&path)?;
1317 println!("{}", crate::messages::cli::log_deleted(&path.display()));
1318 } else {
1319 println!("{}", crate::messages::cli::no_log_file(&path.display()));
1320 }
1321 } else if tail {
1322 let status = std::process::Command::new("tail")
1323 .args(["-f", &path.to_string_lossy()])
1324 .status()
1325 .context("Failed to run tail")?;
1326 std::process::exit(status.code().unwrap_or(1));
1327 } else {
1328 println!("{}", path.display());
1329 }
1330 Ok(())
1331}
1332
1333pub fn handle_theme_command(env: &crate::runtime::env::Env, command: ThemeCommands) -> Result<()> {
1334 log::info!("[purple] cli theme: dispatch");
1335 match command {
1336 ThemeCommands::List => {
1337 let current =
1338 preferences::load_theme(env.paths()).unwrap_or_else(|| "Purple".to_string());
1339 println!("{}", crate::messages::cli::BUILTIN_THEMES);
1340 for theme in ui::theme::ThemeDef::builtins() {
1341 let marker = if theme.name.eq_ignore_ascii_case(¤t) {
1342 "*"
1343 } else {
1344 " "
1345 };
1346 println!(" {} {}", marker, theme.name);
1347 }
1348 let custom = ui::theme::ThemeDef::load_custom(env.paths());
1349 if !custom.is_empty() {
1350 println!("{}", crate::messages::cli::CUSTOM_THEMES);
1351 for theme in &custom {
1352 let marker = if theme.name.eq_ignore_ascii_case(¤t) {
1353 "*"
1354 } else {
1355 " "
1356 };
1357 println!(" {} {}", marker, theme.name);
1358 }
1359 }
1360 }
1361 ThemeCommands::Set { name } => {
1362 let found = ui::theme::ThemeDef::find_builtin(&name).or_else(|| {
1363 ui::theme::ThemeDef::load_custom(env.paths())
1364 .into_iter()
1365 .find(|t| t.name.eq_ignore_ascii_case(&name))
1366 });
1367 match found {
1368 Some(theme) => {
1369 preferences::save_theme(env.paths(), &theme.name)?;
1370 println!("{}", crate::messages::cli::theme_set(&theme.name));
1371 }
1372 None => {
1373 anyhow::bail!("Unknown theme: {}", name);
1374 }
1375 }
1376 }
1377 }
1378 Ok(())
1379}
1380
1381pub fn handle_vault_sign_command(
1382 env: &crate::runtime::env::Env,
1383 mut config: SshConfigFile,
1384 alias: Option<String>,
1385 all: bool,
1386 cli_vault_addr: Option<String>,
1387) -> Result<()> {
1388 log::info!(
1389 "[purple] cli vault sign: alias={:?} all={} vault_addr={:?}",
1390 alias,
1391 all,
1392 cli_vault_addr
1393 );
1394 if let Some(ref addr) = cli_vault_addr {
1395 if !vault_ssh::is_valid_vault_addr(addr) {
1396 anyhow::bail!(
1397 "Invalid --vault-addr value. Must be non-empty, no whitespace or control chars."
1398 );
1399 }
1400 }
1401 let provider_config = providers::config::ProviderConfig::load(env.paths());
1402 let entries = config.host_entries();
1403
1404 if all {
1405 let mut signed = 0u32;
1406 let mut failed = 0u32;
1407 let mut skipped = 0u32;
1408
1409 for entry in &entries {
1410 let role = match vault_ssh::resolve_vault_role(
1411 entry.vault_ssh.as_deref(),
1412 entry.provider.as_deref(),
1413 entry.provider_label.as_deref(),
1414 &provider_config,
1415 ) {
1416 Some(r) => r,
1417 None => {
1418 skipped += 1;
1419 continue;
1420 }
1421 };
1422
1423 let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &entry.identity_file) {
1424 Ok(p) => p,
1425 Err(e) => {
1426 println!("{}", crate::messages::cli::skipping_host(&entry.alias, &e));
1427 failed += 1;
1428 continue;
1429 }
1430 };
1431 let cert_path =
1432 vault_ssh::resolve_cert_path(env.paths(), &entry.alias, &entry.certificate_file)?;
1433 let status = vault_ssh::check_cert_validity(env, &cert_path);
1434
1435 if !vault_ssh::needs_renewal(&status) {
1436 skipped += 1;
1437 continue;
1438 }
1439
1440 let resolved_addr = cli_vault_addr.clone().or_else(|| {
1442 vault_ssh::resolve_vault_addr(
1443 entry.vault_addr.as_deref(),
1444 entry.provider.as_deref(),
1445 entry.provider_label.as_deref(),
1446 &provider_config,
1447 )
1448 });
1449 print!("{}", crate::messages::cli::vault_signing_host(&entry.alias));
1450 match vault_ssh::sign_certificate(
1451 env,
1452 &role,
1453 &pubkey,
1454 &entry.alias,
1455 resolved_addr.as_deref(),
1456 ) {
1457 Ok(result) => {
1458 println!("\u{2713}");
1459 if should_write_certificate_file(&entry.certificate_file) {
1462 let updated = config.set_host_certificate_file(
1463 &entry.alias,
1464 &result.cert_path.to_string_lossy(),
1465 );
1466 if !updated {
1467 eprintln!(
1468 "{}",
1469 crate::messages::cli::vault_sign_host_block_gone(&entry.alias)
1470 );
1471 }
1472 }
1473 signed += 1;
1474 }
1475 Err(e) => {
1476 println!("{}", crate::messages::cli::vault_sign_failed(&e));
1477 failed += 1;
1478 }
1479 }
1480 }
1481 if signed > 0 {
1482 if let Err(e) = config.write() {
1483 eprintln!("{}", crate::messages::cli::vault_config_update_warning(&e));
1484 }
1485 }
1486 println!(
1487 "\nSigned: {}, failed: {}, skipped (valid): {}",
1488 signed, failed, skipped
1489 );
1490 if failed > 0 {
1491 std::process::exit(1);
1492 }
1493 } else if let Some(alias) = alias {
1494 let entry = entries
1495 .iter()
1496 .find(|h| h.alias == alias)
1497 .with_context(|| format!("Host '{}' not found", alias))?;
1498
1499 let role = vault_ssh::resolve_vault_role(
1500 entry.vault_ssh.as_deref(),
1501 entry.provider.as_deref(),
1502 entry.provider_label.as_deref(),
1503 &provider_config,
1504 )
1505 .with_context(|| crate::messages::cli::vault_no_role(&alias))?;
1506
1507 let pubkey = vault_ssh::resolve_pubkey_path(env.paths(), &entry.identity_file)?;
1508 let resolved_addr = cli_vault_addr.clone().or_else(|| {
1509 vault_ssh::resolve_vault_addr(
1510 entry.vault_addr.as_deref(),
1511 entry.provider.as_deref(),
1512 entry.provider_label.as_deref(),
1513 &provider_config,
1514 )
1515 });
1516 let result =
1517 vault_ssh::sign_certificate(env, &role, &pubkey, &alias, resolved_addr.as_deref())?;
1518 if should_write_certificate_file(&entry.certificate_file) {
1522 let updated =
1523 config.set_host_certificate_file(&alias, &result.cert_path.to_string_lossy());
1524 if !updated {
1525 anyhow::bail!(
1532 "Host '{}' disappeared from ssh config before CertificateFile could be written. Cert saved to {}.",
1533 alias,
1534 result.cert_path.display()
1535 );
1536 }
1537 config
1538 .write()
1539 .with_context(|| "Failed to update SSH config with CertificateFile")?;
1540 }
1541 println!(
1542 "{}",
1543 crate::messages::cli::vault_cert_signed(&result.cert_path.display())
1544 );
1545 } else {
1546 anyhow::bail!("Provide a host alias or use --all");
1547 }
1548 Ok(())
1549}
1550
1551pub fn select_proton_askpass(
1557 target_askpass: &[Option<String>],
1558 default: Option<String>,
1559) -> Option<String> {
1560 let any_proton = target_askpass.iter().any(|a| {
1561 let resolved = a.clone().or_else(|| default.clone());
1562 resolved.as_deref().unwrap_or("").starts_with("proton:")
1563 });
1564 if !any_proton {
1565 return None;
1566 }
1567 target_askpass
1568 .iter()
1569 .find_map(|a| a.as_ref().filter(|s| s.starts_with("proton:")))
1570 .cloned()
1571 .or(default)
1572}
1573
1574pub fn run_whats_new(since: Option<&str>) -> Result<String> {
1575 use crate::changelog::{self, EntryKind};
1576 use semver::Version;
1577
1578 let current = Version::parse(env!("CARGO_PKG_VERSION"))
1579 .with_context(|| "failed to parse current version")?;
1580 let last = match since {
1581 Some(s) => Some(Version::parse(s).with_context(|| format!("invalid --since version {s}"))?),
1582 None => None,
1583 };
1584
1585 let sections = changelog::cached();
1586 let shown = changelog::versions_to_show(sections, last.as_ref(), ¤t, sections.len());
1587
1588 let mut out = String::new();
1589 out.push_str(crate::messages::cli::whats_new::HEADER);
1590 out.push_str("\n\n");
1591 for section in shown {
1592 out.push_str(&format!("## {}", section.version));
1593 if let Some(date) = §ion.date {
1594 out.push_str(&format!(" - {}", date));
1595 }
1596 out.push('\n');
1597 for entry in §ion.entries {
1598 let prefix = match entry.kind {
1599 EntryKind::Feature => "+ ",
1600 EntryKind::Change => "~ ",
1601 EntryKind::Fix => "! ",
1602 };
1603 out.push_str(prefix);
1604 out.push_str(&entry.text);
1605 out.push('\n');
1606 }
1607 out.push('\n');
1608 }
1609 Ok(out)
1610}
1611
1612#[cfg(test)]
1613mod whats_new_tests {
1614 use super::*;
1615
1616 #[test]
1617 fn whats_new_cli_outputs_header() {
1618 let output = run_whats_new(None).unwrap();
1619 assert!(output.contains("purple release notes"));
1620 }
1621
1622 #[test]
1623 fn whats_new_cli_filters_by_since() {
1624 let output = run_whats_new(Some("999.0.0")).unwrap();
1625 assert!(!output.contains("## "));
1626 }
1627
1628 #[test]
1629 fn whats_new_cli_returns_error_on_bad_version() {
1630 let result = run_whats_new(Some("not-a-version"));
1631 assert!(result.is_err());
1632 }
1633}
1634
1635#[cfg(test)]
1636mod select_proton_askpass_tests {
1637 use super::*;
1638
1639 #[test]
1640 fn returns_none_when_no_target_uses_proton_and_no_default() {
1641 let targets = vec![
1642 Some("bw:foo".to_string()),
1643 Some("keychain".to_string()),
1644 None,
1645 ];
1646 assert_eq!(select_proton_askpass(&targets, None), None);
1647 }
1648
1649 #[test]
1650 fn returns_none_when_no_target_uses_proton_and_default_is_not_proton() {
1651 let targets = vec![Some("bw:foo".to_string()), None];
1652 let default = Some("keychain".to_string());
1653 assert_eq!(select_proton_askpass(&targets, default), None);
1654 }
1655
1656 #[test]
1657 fn returns_proton_value_when_one_target_uses_proton() {
1658 let targets = vec![
1659 Some("bw:other".to_string()),
1660 Some("proton:Vault/Item/p".to_string()),
1661 None,
1662 ];
1663 assert_eq!(
1664 select_proton_askpass(&targets, None),
1665 Some("proton:Vault/Item/p".to_string())
1666 );
1667 }
1668
1669 #[test]
1670 fn prefers_first_per_host_proton_value_over_default() {
1671 let targets = vec![
1672 Some("proton:First/Item/p".to_string()),
1673 Some("proton:Second/Item/p".to_string()),
1674 ];
1675 let default = Some("proton:Default/Item/p".to_string());
1676 assert_eq!(
1677 select_proton_askpass(&targets, default),
1678 Some("proton:First/Item/p".to_string())
1679 );
1680 }
1681
1682 #[test]
1683 fn falls_back_to_default_when_no_per_host_proton_value_but_default_is_proton() {
1684 let targets = vec![None, Some("bw:foo".to_string()), None];
1685 let default = Some("proton:Default/Item/p".to_string());
1686 assert_eq!(
1687 select_proton_askpass(&targets, default),
1688 Some("proton:Default/Item/p".to_string())
1689 );
1690 }
1691
1692 #[test]
1693 fn handles_all_proton_targets() {
1694 let targets = vec![
1695 Some("proton:A/x/p".to_string()),
1696 Some("proton:B/y/p".to_string()),
1697 ];
1698 assert_eq!(
1699 select_proton_askpass(&targets, None),
1700 Some("proton:A/x/p".to_string())
1701 );
1702 }
1703
1704 #[test]
1705 fn handles_empty_target_list_with_proton_default() {
1706 let default = Some("proton:Default/Item/p".to_string());
1707 assert_eq!(select_proton_askpass(&[], default), None);
1708 }
1709
1710 #[test]
1711 fn handles_empty_target_list_with_no_default() {
1712 assert_eq!(select_proton_askpass(&[], None), None);
1713 }
1714
1715 #[test]
1716 fn empty_string_askpass_does_not_match_proton() {
1717 let targets = vec![Some(String::new()), Some("bw:foo".to_string())];
1718 assert_eq!(select_proton_askpass(&targets, None), None);
1719 }
1720}