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 mut filter,
384 no_verify_tls,
385 verify_tls,
386 auto_sync,
387 no_auto_sync,
388 label,
389 } => {
390 let p = match providers::get_provider(&provider) {
391 Some(p) => p,
392 None => {
393 eprintln!("{}", crate::messages::cli::unknown_provider(&provider));
394 std::process::exit(1);
395 }
396 };
397 let kind = provider.parse::<ProviderKind>().ok();
399
400 let mut token = token;
403 let mut url = url;
404 let mut no_verify_tls = no_verify_tls;
405 let mut verify_tls = verify_tls;
406 if !kind.is_some_and(ProviderKind::requires_url) {
407 if url.is_some() {
408 eprintln!("{}", crate::messages::cli::WARN_URL_NOT_USED);
409 url = None;
410 }
411 if no_verify_tls {
412 eprintln!("{}", crate::messages::cli::WARN_NO_VERIFY_TLS_NOT_USED);
413 no_verify_tls = false;
414 }
415 if verify_tls {
416 eprintln!("{}", crate::messages::cli::WARN_VERIFY_TLS_NOT_USED);
417 verify_tls = false;
418 }
419 }
420 if kind != Some(ProviderKind::Aws) && profile.is_some() {
422 eprintln!("{}", crate::messages::cli::WARN_PROFILE_NOT_USED);
423 profile = None;
424 }
425 if !kind.is_some_and(ProviderKind::accepts_cli_regions) && regions.is_some() {
426 eprintln!("{}", crate::messages::cli::WARN_REGIONS_NOT_USED);
427 regions = None;
428 }
429 if kind != Some(ProviderKind::Gcp) && project.is_some() {
430 eprintln!("{}", crate::messages::cli::WARN_PROJECT_NOT_USED);
431 project = None;
432 }
433 if kind != Some(ProviderKind::Oracle) && compartment.is_some() {
434 eprintln!("{}", crate::messages::cli::WARN_COMPARTMENT_NOT_USED);
435 compartment = None;
436 }
437 if kind != Some(ProviderKind::NetBox) && filter.is_some() {
438 eprintln!("{}", crate::messages::cli::WARN_FILTER_NOT_USED);
439 filter = None;
440 }
441
442 let target_id = match label.as_deref() {
449 Some(l) => providers::config::ProviderConfigId::labeled(provider.clone(), l),
450 None => providers::config::ProviderConfigId::bare(provider.clone()),
451 };
452 let existing_section = providers::config::ProviderConfig::load(env.paths())
453 .section_by_id(&target_id)
454 .cloned();
455
456 if let Some(ref existing) = existing_section {
457 if kind.is_some_and(ProviderKind::requires_url)
459 && url.is_none()
460 && !existing.url.is_empty()
461 {
462 url = Some(existing.url.clone());
463 }
464 if token.is_none()
465 && !token_stdin
466 && env.purple_token().is_none()
467 && !existing.token.is_empty()
468 {
469 token = Some(existing.token.clone());
470 }
471 if prefix.is_none() {
472 prefix = Some(existing.alias_prefix.clone());
473 }
474 if user.is_none() {
475 user = Some(existing.user.clone());
476 }
477 if key.is_none() && !existing.identity_file.is_empty() {
478 key = Some(existing.identity_file.clone());
479 }
480 if !no_verify_tls && !verify_tls && !existing.verify_tls {
482 no_verify_tls = true;
483 }
484 if kind == Some(ProviderKind::Aws)
486 && profile.is_none()
487 && !existing.profile.is_empty()
488 {
489 profile = Some(existing.profile.clone());
490 }
491 if kind.is_some_and(ProviderKind::accepts_cli_regions)
493 && regions.is_none()
494 && !existing.regions.is_empty()
495 {
496 regions = Some(existing.regions.clone());
497 }
498 if kind == Some(ProviderKind::Gcp)
500 && project.is_none()
501 && !existing.project.is_empty()
502 {
503 project = Some(existing.project.clone());
504 }
505 if kind == Some(ProviderKind::Oracle)
507 && compartment.is_none()
508 && !existing.compartment.is_empty()
509 {
510 compartment = Some(existing.compartment.clone());
511 }
512 if kind == Some(ProviderKind::NetBox)
514 && filter.is_none()
515 && !existing.filter.is_empty()
516 {
517 filter = Some(existing.filter.clone());
518 }
519 }
520
521 if kind.is_some_and(ProviderKind::requires_url) {
523 if url.is_none() || url.as_deref().unwrap_or("").trim().is_empty() {
524 let msg = if kind == Some(ProviderKind::NetBox) {
525 crate::messages::cli::NETBOX_URL_REQUIRED
526 } else {
527 crate::messages::cli::PROXMOX_URL_REQUIRED
528 };
529 eprintln!("{}", msg);
530 std::process::exit(1);
531 }
532 let u = url.as_deref().unwrap();
533 if !u.to_ascii_lowercase().starts_with("https://") {
534 eprintln!("{}", crate::messages::cli::PROVIDER_URL_REQUIRES_HTTPS);
535 std::process::exit(1);
536 }
537 }
538
539 let token_omitted = token.is_none() && !token_stdin && env.purple_token().is_none();
545 let token_optional = kind.is_some_and(ProviderKind::token_is_optional);
546 let token = if token_optional && token_omitted {
547 String::new()
548 } else {
549 match super::resolve_token(env, token, token_stdin) {
550 Ok(t) => t,
551 Err(e) => {
552 eprintln!("{}", e);
553 std::process::exit(1);
554 }
555 }
556 };
557
558 if token.trim().is_empty() && token_optional && !token_omitted {
563 let display_name = providers::provider_display_name(&provider);
564 let replaces_stored = existing_section
565 .as_ref()
566 .is_some_and(|s| !s.token.trim().is_empty());
567 if replaces_stored {
568 eprintln!(
569 "{}",
570 crate::messages::cli::warn_token_replaced_with_empty(display_name)
571 );
572 } else {
573 eprintln!(
574 "{}",
575 crate::messages::cli::warn_token_resolved_empty(display_name)
576 );
577 }
578 }
579
580 if token.trim().is_empty() && !token_optional {
581 if kind == Some(ProviderKind::Gcp) {
582 eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_GCP);
583 } else if kind == Some(ProviderKind::Oracle) {
584 eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_ORACLE);
585 } else {
586 eprintln!(
587 "{}",
588 crate::messages::cli::provider_token_required(
589 providers::provider_display_name(&provider)
590 )
591 );
592 }
593 std::process::exit(1);
594 }
595
596 let alias_prefix = prefix.unwrap_or_else(|| p.short_label().to_string());
597 if crate::ssh_config::model::is_host_pattern(&alias_prefix) {
598 eprintln!("{}", crate::messages::cli::ALIAS_PREFIX_INVALID);
599 std::process::exit(1);
600 }
601
602 let user = user.unwrap_or_else(|| "root".to_string());
603 let identity_file = key.unwrap_or_default();
604
605 let url_value = url.clone().unwrap_or_default();
607 let profile_value = profile.clone().unwrap_or_default();
608 let regions_value = regions.clone().unwrap_or_default();
609 let project_value = project.clone().unwrap_or_default();
610 let compartment_value = compartment.clone().unwrap_or_default();
611 let filter_value = filter.clone().unwrap_or_default();
612 for (value, name) in [
613 (&url_value, "URL"),
614 (&token, "Token"),
615 (&alias_prefix, "Alias prefix"),
616 (&user, "User"),
617 (&identity_file, "Identity file"),
618 (&profile_value, "Profile"),
619 (&project_value, "Project"),
620 (®ions_value, "Regions"),
621 (&compartment_value, "Compartment"),
622 (&filter_value, "Filter"),
623 ] {
624 if value.chars().any(|c| c.is_control()) {
625 eprintln!("{}", crate::messages::cli::control_chars(name));
626 std::process::exit(1);
627 }
628 }
629 if user.contains(char::is_whitespace) {
630 eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
631 std::process::exit(1);
632 }
633
634 let resolved_auto_sync = if auto_sync {
636 true
637 } else if no_auto_sync {
638 false
639 } else if let Some(ref existing) = existing_section {
640 existing.auto_sync
641 } else {
642 kind.is_none_or(ProviderKind::default_auto_sync)
643 };
644
645 let resolved_profile = profile.unwrap_or_default();
646 let resolved_regions = regions.unwrap_or_default();
647 let resolved_project = project.unwrap_or_default();
648 let resolved_compartment = compartment.unwrap_or_default();
649
650 if kind == Some(ProviderKind::Aws) && resolved_regions.trim().is_empty() {
652 eprintln!("{}", crate::messages::cli::AWS_REGIONS_REQUIRED);
653 std::process::exit(1);
654 }
655 if kind == Some(ProviderKind::Scaleway) && resolved_regions.trim().is_empty() {
656 eprintln!("{}", crate::messages::cli::SCALEWAY_REGIONS_REQUIRED);
657 std::process::exit(1);
658 }
659 if kind == Some(ProviderKind::Azure) {
660 if resolved_regions.trim().is_empty() {
661 eprintln!("{}", crate::messages::cli::AZURE_REGIONS_REQUIRED);
662 std::process::exit(1);
663 }
664 for sub in resolved_regions
665 .split(',')
666 .map(|s| s.trim())
667 .filter(|s| !s.is_empty())
668 {
669 if !providers::azure::is_valid_subscription_id(sub) {
670 eprintln!(
671 "{}",
672 crate::messages::cli::azure_subscription_id_invalid(sub)
673 );
674 std::process::exit(1);
675 }
676 }
677 }
678 if kind == Some(ProviderKind::Gcp) && resolved_project.trim().is_empty() {
680 eprintln!("{}", crate::messages::cli::GCP_PROJECT_REQUIRED);
681 std::process::exit(1);
682 }
683 if kind == Some(ProviderKind::Oracle) && resolved_compartment.trim().is_empty() {
685 eprintln!("{}", crate::messages::cli::ORACLE_COMPARTMENT_REQUIRED);
686 std::process::exit(1);
687 }
688
689 let mut config = providers::config::ProviderConfig::load(env.paths());
690
691 let id: providers::config::ProviderConfigId = match label.as_deref() {
697 Some(l) => {
698 if let Err(e) = providers::config::validate_label(l) {
699 eprintln!("{}", crate::messages::cli::invalid_label_flag(&e));
700 std::process::exit(1);
701 }
702 providers::config::ProviderConfigId::labeled(provider.clone(), l)
703 }
704 None => providers::config::ProviderConfigId::bare(provider.clone()),
705 };
706
707 let existing = config.sections_for_provider(&provider);
710 let has_bare = existing.iter().any(|s| s.id.label.is_none());
711 let has_labeled = existing.iter().any(|s| s.id.label.is_some());
712 if id.label.is_none() && has_labeled {
713 eprintln!("{}", crate::messages::cli::add_requires_label(&provider));
714 std::process::exit(1);
715 }
716 if id.label.is_some() && has_bare {
717 eprintln!(
718 "{}",
719 crate::messages::cli::add_label_collides_with_bare(&provider)
720 );
721 std::process::exit(1);
722 }
723
724 let section = providers::config::ProviderSection {
725 id: id.clone(),
726 token,
727 alias_prefix,
728 user,
729 identity_file,
730 url: url.unwrap_or_default(),
731 verify_tls: !no_verify_tls,
732 auto_sync: resolved_auto_sync,
733 profile: resolved_profile,
734 regions: resolved_regions,
735 project: resolved_project,
736 compartment: resolved_compartment,
737 filter: filter.unwrap_or_default(),
738 vault_role: String::new(),
739 vault_addr: String::new(),
740 };
741
742 let saved_record = section.log_record();
744 config.set_section(section);
745 config.save().map_err(|e| {
746 log::warn!("[config] Save failed for [{}]: {}", id, e);
747 anyhow::anyhow!("Failed to save: {}", e)
748 })?;
749 log::debug!("[purple] provider saved: [{}] {}", id, saved_record);
750 println!("{}", crate::messages::cli::saved_config(&id.to_string()));
751 Ok(())
752 }
753 ProviderCommands::List => {
754 let config = providers::config::ProviderConfig::load(env.paths());
755 let sections = config.configured_providers();
756 if sections.is_empty() {
757 println!("{}", crate::messages::cli::NO_PROVIDERS);
758 } else {
759 for s in sections {
760 let display_name = providers::provider_display_name(s.provider());
761 let label_suffix = match &s.id.label {
762 Some(l) => format!(" ({})", l),
763 None => String::new(),
764 };
765 println!(
766 " {:<24} {}-*{:>8}",
767 format!("{}{}", display_name, label_suffix),
768 s.alias_prefix,
769 s.user
770 );
771 }
772 }
773 Ok(())
774 }
775 ProviderCommands::Remove { provider } => {
776 let id: providers::config::ProviderConfigId = match provider.parse() {
779 Ok(id) => id,
780 Err(e) => {
781 eprintln!("{}: {}", provider, e);
782 std::process::exit(1);
783 }
784 };
785 let mut config = providers::config::ProviderConfig::load(env.paths());
786 let removed = match &id.label {
787 Some(_) => {
788 if config.section_by_id(&id).is_none() {
789 eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
790 std::process::exit(1);
791 }
792 config.remove_section_by_id(&id);
793 1
794 }
795 None => {
796 let count = config.sections_for_provider(&id.provider).len();
797 if count == 0 {
798 eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
799 std::process::exit(1);
800 }
801 config.remove_section(&id.provider);
802 count
803 }
804 };
805 config.save().map_err(|e| {
806 log::warn!("[config] Save failed removing [{}]: {}", id, e);
807 anyhow::anyhow!("Failed to save: {}", e)
808 })?;
809 log::debug!(
810 "[purple] provider removed: [{}], {} section(s)",
811 id,
812 removed
813 );
814 if removed == 1 {
815 println!("{}", crate::messages::cli::removed_config(&provider));
816 } else {
817 println!(
818 "{}",
819 crate::messages::cli::removed_configs(&provider, removed)
820 );
821 }
822 Ok(())
823 }
824 }
825}
826
827pub fn handle_tunnel_command(mut config: SshConfigFile, command: TunnelCommands) -> Result<()> {
828 log::info!("[purple] cli tunnel: dispatch");
829 match command {
830 TunnelCommands::List { alias } => {
831 if let Some(alias) = alias {
832 if !config.has_host(&alias) {
834 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
835 std::process::exit(1);
836 }
837 let rules = config.find_tunnel_directives(&alias);
838 if rules.is_empty() {
839 println!("{}", crate::messages::cli::no_tunnels_for(&alias));
840 } else {
841 println!("{}", crate::messages::cli::tunnels_for(&alias));
842 for rule in &rules {
843 println!(" {}", rule.display());
844 }
845 }
846 } else {
847 let entries = config.host_entries();
849 let with_tunnels: Vec<_> = entries.iter().filter(|e| e.tunnel_count > 0).collect();
850 if with_tunnels.is_empty() {
851 println!("{}", crate::messages::cli::NO_TUNNELS);
852 } else {
853 for (i, host) in with_tunnels.iter().enumerate() {
854 if i > 0 {
855 println!();
856 }
857 println!("{}:", host.alias);
858 for rule in config.find_tunnel_directives(&host.alias) {
859 println!(" {}", rule.display());
860 }
861 }
862 }
863 }
864 Ok(())
865 }
866 TunnelCommands::Add { alias, forward } => {
867 if !config.has_host(&alias) {
868 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
869 std::process::exit(1);
870 }
871 if config.is_included_host(&alias) {
872 eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
873 std::process::exit(1);
874 }
875 let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
876 eprintln!("{}", e);
877 std::process::exit(1);
878 });
879 let key = rule.tunnel_type.directive_key();
880 let value = rule.to_directive_value();
881 if config.has_forward(&alias, key, &value) {
883 eprintln!("{}", crate::messages::cli::forward_exists(&forward, &alias));
884 std::process::exit(1);
885 }
886 config.add_forward(&alias, key, &value);
887 log::debug!(
888 "[config] cli tunnel add: writing ssh config (alias={})",
889 alias
890 );
891 if let Err(e) = config.write() {
892 log::warn!("[purple] cli tunnel add: write failed: {}", e);
893 eprintln!("{}", crate::messages::cli::save_config_failed(&e));
894 std::process::exit(1);
895 }
896 log::info!(
897 "[purple] cli tunnel add: forward={} alias={}",
898 forward,
899 alias
900 );
901 println!("{}", crate::messages::cli::added_forward(&forward, &alias));
902 Ok(())
903 }
904 TunnelCommands::Remove { alias, forward } => {
905 if !config.has_host(&alias) {
906 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
907 std::process::exit(1);
908 }
909 if config.is_included_host(&alias) {
910 eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
911 std::process::exit(1);
912 }
913 let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
914 eprintln!("{}", e);
915 std::process::exit(1);
916 });
917 let key = rule.tunnel_type.directive_key();
918 let value = rule.to_directive_value();
919 let removed = config.remove_forward(&alias, key, &value);
920 if !removed {
921 eprintln!(
922 "{}",
923 crate::messages::cli::forward_not_found(&forward, &alias)
924 );
925 std::process::exit(1);
926 }
927 log::debug!(
928 "[config] cli tunnel remove: writing ssh config (alias={})",
929 alias
930 );
931 if let Err(e) = config.write() {
932 log::warn!("[purple] cli tunnel remove: write failed: {}", e);
933 eprintln!("{}", crate::messages::cli::save_config_failed(&e));
934 std::process::exit(1);
935 }
936 log::info!(
937 "[purple] cli tunnel remove: forward={} alias={}",
938 forward,
939 alias
940 );
941 println!(
942 "{}",
943 crate::messages::cli::removed_forward(&forward, &alias)
944 );
945 Ok(())
946 }
947 TunnelCommands::Start { alias } => {
948 log::info!("[purple] cli tunnel start: alias={}", alias);
949 if !config.has_host(&alias) {
950 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
951 std::process::exit(1);
952 }
953 let tunnels = config.find_tunnel_directives(&alias);
954 if tunnels.is_empty() {
955 log::warn!("[purple] cli tunnel start: no forwards for alias={}", alias);
956 eprintln!("{}", crate::messages::cli::no_forwards(&alias));
957 std::process::exit(1);
958 }
959 println!("{}", crate::messages::cli::starting_tunnel(&alias));
960 let status = std::process::Command::new("ssh")
962 .arg("-F")
963 .arg(&config.path)
964 .arg("-N")
965 .arg("--")
966 .arg(&alias)
967 .status()
968 .map_err(|e| anyhow::anyhow!("Failed to start ssh: {}", e))?;
969 let code = status.code().unwrap_or(1);
970 std::process::exit(code);
971 }
972 }
973}
974
975pub fn prompt_hidden_input(prompt: &str) -> Result<Option<String>> {
977 eprint!("{}", prompt);
978 crossterm::terminal::enable_raw_mode()?;
979 let mut input = String::new();
980 loop {
981 if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
982 match key.code {
983 crossterm::event::KeyCode::Enter => break,
984 crossterm::event::KeyCode::Char(c) => {
985 input.push(c);
986 eprint!("*");
987 }
988 crossterm::event::KeyCode::Backspace if input.pop().is_some() => {
989 eprint!("\x08 \x08");
990 }
991 crossterm::event::KeyCode::Esc => {
992 crossterm::terminal::disable_raw_mode()?;
993 eprintln!();
994 return Ok(None);
995 }
996 _ => {}
997 }
998 }
999 }
1000 crossterm::terminal::disable_raw_mode()?;
1001 eprintln!();
1002 Ok(Some(input))
1003}
1004
1005pub fn handle_password_command(
1011 env: &crate::runtime::env::Env,
1012 command: PasswordCommands,
1013) -> Result<()> {
1014 log::info!("[purple] cli password: dispatch");
1015 match command {
1016 PasswordCommands::Set { alias } => {
1017 let password =
1018 match prompt_hidden_input(&crate::messages::askpass::password_prompt(&alias))? {
1019 Some(p) if !p.is_empty() => p,
1020 Some(_) => {
1021 eprintln!("{}", crate::messages::cli::PASSWORD_EMPTY);
1022 std::process::exit(1);
1023 }
1024 None => {
1025 eprintln!("{}", crate::messages::cli::CANCELLED);
1026 std::process::exit(1);
1027 }
1028 };
1029
1030 askpass::store_in_keychain(env, &alias, &password)?;
1031 println!(
1032 "Password stored for {}. Set 'keychain' as password source to use it.",
1033 alias
1034 );
1035 Ok(())
1036 }
1037 PasswordCommands::Remove { alias } => {
1038 askpass::remove_from_keychain(env, &alias)?;
1039 println!("{}", crate::messages::cli::password_removed(&alias));
1040 Ok(())
1041 }
1042 }
1043}
1044
1045pub fn handle_snippet_command(
1046 env: &crate::runtime::env::Env,
1047 config: SshConfigFile,
1048 command: SnippetCommands,
1049 config_path: &Path,
1050) -> Result<()> {
1051 log::info!("[purple] cli snippet: dispatch");
1052 match command {
1053 SnippetCommands::List => {
1054 let store = snippet::SnippetStore::load(env.paths());
1055 if store.snippets.is_empty() {
1056 println!("{}", crate::messages::cli::NO_SNIPPETS);
1057 } else {
1058 for s in &store.snippets {
1059 if s.description.is_empty() {
1060 println!(" {} {}", s.name, s.command);
1061 } else {
1062 println!(" {} {} ({})", s.name, s.command, s.description);
1063 }
1064 }
1065 }
1066 Ok(())
1067 }
1068 SnippetCommands::Add {
1069 name,
1070 command,
1071 description,
1072 } => {
1073 if let Err(e) = snippet::validate_name(&name) {
1074 eprintln!("{}", e);
1075 std::process::exit(1);
1076 }
1077 if let Err(e) = snippet::validate_command(&command) {
1078 eprintln!("{}", e);
1079 std::process::exit(1);
1080 }
1081 if let Some(ref desc) = description {
1082 if desc.contains(|c: char| c.is_control()) {
1083 eprintln!("{}", crate::messages::cli::DESCRIPTION_CONTROL_CHARS);
1084 std::process::exit(1);
1085 }
1086 }
1087 let mut store = snippet::SnippetStore::load(env.paths());
1088 let is_update = store.get(&name).is_some();
1089 store.set(snippet::Snippet {
1090 name: name.clone(),
1091 command,
1092 description: description.unwrap_or_default(),
1093 });
1094 store.save()?;
1095 if is_update {
1096 println!("{}", crate::messages::cli::snippet_updated(&name));
1097 } else {
1098 println!("{}", crate::messages::cli::snippet_added(&name));
1099 }
1100 Ok(())
1101 }
1102 SnippetCommands::Remove { name } => {
1103 let mut store = snippet::SnippetStore::load(env.paths());
1104 if store.get(&name).is_none() {
1105 eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1106 std::process::exit(1);
1107 }
1108 store.remove(&name);
1109 store.save()?;
1110 println!("{}", crate::messages::cli::snippet_removed(&name));
1111 Ok(())
1112 }
1113 SnippetCommands::Run {
1114 name,
1115 alias,
1116 tag,
1117 all,
1118 parallel,
1119 } => {
1120 let store = snippet::SnippetStore::load(env.paths());
1121 let snip = match store.get(&name) {
1122 Some(s) => s.clone(),
1123 None => {
1124 eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1125 std::process::exit(1);
1126 }
1127 };
1128
1129 let entries = config.host_entries();
1130
1131 let targets: Vec<&HostEntry> = if let Some(ref alias) = alias {
1133 match entries.iter().find(|h| h.alias == *alias) {
1134 Some(h) => vec![h],
1135 None => {
1136 eprintln!("{}", crate::messages::cli::host_not_found(alias));
1137 std::process::exit(1);
1138 }
1139 }
1140 } else if let Some(ref tag_filter) = tag {
1141 let matched: Vec<_> = entries
1142 .iter()
1143 .filter(|h| h.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_filter)))
1144 .collect();
1145 if matched.is_empty() {
1146 eprintln!("{}", crate::messages::cli::no_hosts_with_tag(tag_filter));
1147 std::process::exit(1);
1148 }
1149 matched
1150 } else if all {
1151 entries.iter().collect()
1152 } else {
1153 eprintln!("{}", crate::messages::cli::SPECIFY_TARGET);
1154 std::process::exit(1);
1155 };
1156
1157 if targets.len() == 1 {
1158 let host = targets[0];
1160 let askpass = host
1161 .askpass
1162 .clone()
1163 .or_else(|| preferences::load_askpass_default(env.paths()));
1164 super::ensure_proton_login(env, askpass.as_deref());
1165 let bw_session = super::ensure_bw_session(env, None, askpass.as_deref());
1166 super::ensure_keychain_password(env, &host.alias, askpass.as_deref());
1167 match snippet::run_snippet(
1168 &host.alias,
1169 config_path,
1170 env,
1171 &snip.command,
1172 askpass.as_deref(),
1173 bw_session.as_deref(),
1174 false,
1175 false,
1176 ) {
1177 Ok(r) => {
1178 if !r.status.success() {
1179 std::process::exit(r.status.code().unwrap_or(1));
1180 }
1181 }
1182 Err(e) => {
1183 eprintln!("{}", crate::messages::cli::operation_failed(&e));
1184 std::process::exit(1);
1185 }
1186 }
1187 } else if parallel {
1188 use std::sync::mpsc;
1190 use std::thread;
1191 let (tx, rx) = mpsc::channel();
1192 let max_concurrent: usize = 20;
1193 let (slot_tx, slot_rx) = mpsc::channel();
1194 for _ in 0..max_concurrent {
1195 let _ = slot_tx.send(());
1196 }
1197 let config_path = config_path.to_path_buf();
1198 let any_bw = targets.iter().any(|h| {
1200 let askpass = h
1201 .askpass
1202 .clone()
1203 .or_else(|| preferences::load_askpass_default(env.paths()));
1204 askpass.as_deref().unwrap_or("").starts_with("bw:")
1205 });
1206 let bw_session = if any_bw {
1207 let bw_askpass = targets
1208 .iter()
1209 .find_map(|h| h.askpass.as_ref().filter(|a| a.starts_with("bw:")))
1210 .cloned()
1211 .or_else(|| preferences::load_askpass_default(env.paths()));
1212 super::ensure_bw_session(env, None, bw_askpass.as_deref())
1213 } else {
1214 None
1215 };
1216 let target_askpass: Vec<Option<String>> =
1220 targets.iter().map(|h| h.askpass.clone()).collect();
1221 if let Some(askpass) = select_proton_askpass(
1222 &target_askpass,
1223 preferences::load_askpass_default(env.paths()),
1224 ) {
1225 super::ensure_proton_login(env, Some(&askpass));
1226 }
1227 let targets_info: Vec<_> = targets
1228 .iter()
1229 .map(|h| {
1230 let askpass = h
1231 .askpass
1232 .clone()
1233 .or_else(|| preferences::load_askpass_default(env.paths()));
1234 super::ensure_keychain_password(env, &h.alias, askpass.as_deref());
1235 (h.alias.clone(), askpass)
1236 })
1237 .collect();
1238 let command = snip.command.clone();
1239 let env = std::sync::Arc::new(env.clone());
1240 thread::spawn(move || {
1241 for (alias, askpass) in targets_info {
1242 let _ = slot_rx.recv();
1243 let slot_tx = slot_tx.clone();
1244 let tx = tx.clone();
1245 let config_path = config_path.clone();
1246 let env = std::sync::Arc::clone(&env);
1247 let command = command.clone();
1248 let bw_session = bw_session.clone();
1249 thread::spawn(move || {
1250 let result = snippet::run_snippet(
1251 &alias,
1252 &config_path,
1253 &env,
1254 &command,
1255 askpass.as_deref(),
1256 bw_session.as_deref(),
1257 true,
1258 false,
1259 );
1260 let _ = tx.send((alias, result));
1261 let _ = slot_tx.send(());
1262 });
1263 }
1264 });
1265
1266 let host_count = targets.len();
1267 for _ in 0..host_count {
1268 if let Ok((alias, result)) = rx.recv() {
1269 match result {
1270 Ok(r) => {
1271 for line in r.stdout.lines() {
1272 println!("[{}] {}", alias, line);
1273 }
1274 for line in r.stderr.lines() {
1275 eprintln!("[{}] {}", alias, line);
1276 }
1277 }
1278 Err(e) => {
1279 eprintln!("{}", crate::messages::cli::host_failed(&alias, &e))
1280 }
1281 }
1282 }
1283 }
1284 } else {
1285 let mut bw_session: Option<String> = None;
1287 for host in &targets {
1288 let askpass = host
1289 .askpass
1290 .clone()
1291 .or_else(|| preferences::load_askpass_default(env.paths()));
1292 super::ensure_proton_login(env, askpass.as_deref());
1293 if let Some(token) =
1294 super::ensure_bw_session(env, bw_session.as_deref(), askpass.as_deref())
1295 {
1296 bw_session = Some(token);
1297 }
1298 super::ensure_keychain_password(env, &host.alias, askpass.as_deref());
1299 println!("{}", crate::messages::cli::host_separator(&host.alias));
1300 match snippet::run_snippet(
1301 &host.alias,
1302 config_path,
1303 env,
1304 &snip.command,
1305 askpass.as_deref(),
1306 bw_session.as_deref(),
1307 false,
1308 false,
1309 ) {
1310 Ok(r) => {
1311 if !r.status.success() {
1312 eprintln!(
1313 "{}",
1314 crate::messages::cli::exited_with_code(
1315 r.status.code().unwrap_or(1)
1316 )
1317 );
1318 }
1319 }
1320 Err(e) => {
1321 eprintln!("{}", crate::messages::cli::host_failed(&host.alias, &e))
1322 }
1323 }
1324 println!();
1325 }
1326 }
1327 Ok(())
1328 }
1329 }
1330}
1331
1332pub fn handle_logs_command(tail: bool, clear: bool, env: &crate::runtime::env::Env) -> Result<()> {
1333 let path = logging::log_path(env.paths()).context("Could not determine log path")?;
1334 if clear {
1335 if path.exists() {
1336 std::fs::remove_file(&path)?;
1337 println!("{}", crate::messages::cli::log_deleted(&path.display()));
1338 } else {
1339 println!("{}", crate::messages::cli::no_log_file(&path.display()));
1340 }
1341 } else if tail {
1342 let status = std::process::Command::new("tail")
1343 .args(["-f", &path.to_string_lossy()])
1344 .status()
1345 .context("Failed to run tail")?;
1346 std::process::exit(status.code().unwrap_or(1));
1347 } else {
1348 println!("{}", path.display());
1349 }
1350 Ok(())
1351}
1352
1353pub fn handle_theme_command(env: &crate::runtime::env::Env, command: ThemeCommands) -> Result<()> {
1354 log::info!("[purple] cli theme: dispatch");
1355 match command {
1356 ThemeCommands::List => {
1357 let current =
1358 preferences::load_theme(env.paths()).unwrap_or_else(|| "Purple".to_string());
1359 println!("{}", crate::messages::cli::BUILTIN_THEMES);
1360 for theme in ui::theme::ThemeDef::builtins() {
1361 let marker = if theme.name.eq_ignore_ascii_case(¤t) {
1362 "*"
1363 } else {
1364 " "
1365 };
1366 println!(" {} {}", marker, theme.name);
1367 }
1368 let custom = ui::theme::ThemeDef::load_custom(env.paths());
1369 if !custom.is_empty() {
1370 println!("{}", crate::messages::cli::CUSTOM_THEMES);
1371 for theme in &custom {
1372 let marker = if theme.name.eq_ignore_ascii_case(¤t) {
1373 "*"
1374 } else {
1375 " "
1376 };
1377 println!(" {} {}", marker, theme.name);
1378 }
1379 }
1380 }
1381 ThemeCommands::Set { name } => {
1382 let found = ui::theme::ThemeDef::find_builtin(&name).or_else(|| {
1383 ui::theme::ThemeDef::load_custom(env.paths())
1384 .into_iter()
1385 .find(|t| t.name.eq_ignore_ascii_case(&name))
1386 });
1387 match found {
1388 Some(theme) => {
1389 preferences::save_theme(env.paths(), &theme.name)?;
1390 println!("{}", crate::messages::cli::theme_set(&theme.name));
1391 }
1392 None => {
1393 anyhow::bail!("Unknown theme: {}", name);
1394 }
1395 }
1396 }
1397 }
1398 Ok(())
1399}
1400
1401pub fn handle_vault_sign_command(
1402 env: &crate::runtime::env::Env,
1403 mut config: SshConfigFile,
1404 alias: Option<String>,
1405 all: bool,
1406 cli_vault_addr: Option<String>,
1407) -> Result<()> {
1408 log::info!(
1409 "[purple] cli vault sign: alias={:?} all={} vault_addr={:?}",
1410 alias,
1411 all,
1412 cli_vault_addr
1413 );
1414 if let Some(ref addr) = cli_vault_addr {
1415 if !vault_ssh::is_valid_vault_addr(addr) {
1416 anyhow::bail!(
1417 "Invalid --vault-addr value. Must be non-empty, no whitespace or control chars."
1418 );
1419 }
1420 }
1421 let provider_config = providers::config::ProviderConfig::load(env.paths());
1422 let entries = config.host_entries();
1423
1424 if all {
1425 let mut signed = 0u32;
1426 let mut failed = 0u32;
1427 let mut skipped = 0u32;
1428
1429 for entry in &entries {
1430 let role = match vault_ssh::resolve_vault_role(
1431 entry.vault_ssh.as_deref(),
1432 entry.provider.as_deref(),
1433 entry.provider_label.as_deref(),
1434 &provider_config,
1435 ) {
1436 Some(r) => r,
1437 None => {
1438 skipped += 1;
1439 continue;
1440 }
1441 };
1442
1443 let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &entry.identity_file) {
1444 Ok(p) => p,
1445 Err(e) => {
1446 println!("{}", crate::messages::cli::skipping_host(&entry.alias, &e));
1447 failed += 1;
1448 continue;
1449 }
1450 };
1451 let cert_path =
1452 vault_ssh::resolve_cert_path(env.paths(), &entry.alias, &entry.certificate_file)?;
1453 let status = vault_ssh::check_cert_validity(env, &cert_path);
1454
1455 if !vault_ssh::needs_renewal(&status) {
1456 skipped += 1;
1457 continue;
1458 }
1459
1460 let resolved_addr = cli_vault_addr.clone().or_else(|| {
1462 vault_ssh::resolve_vault_addr(
1463 entry.vault_addr.as_deref(),
1464 entry.provider.as_deref(),
1465 entry.provider_label.as_deref(),
1466 &provider_config,
1467 )
1468 });
1469 print!("{}", crate::messages::cli::vault_signing_host(&entry.alias));
1470 match vault_ssh::sign_certificate(
1471 env,
1472 &role,
1473 &pubkey,
1474 &entry.alias,
1475 resolved_addr.as_deref(),
1476 ) {
1477 Ok(result) => {
1478 println!("\u{2713}");
1479 if should_write_certificate_file(&entry.certificate_file) {
1482 let updated = config.set_host_certificate_file(
1483 &entry.alias,
1484 &result.cert_path.to_string_lossy(),
1485 );
1486 if !updated {
1487 eprintln!(
1488 "{}",
1489 crate::messages::cli::vault_sign_host_block_gone(&entry.alias)
1490 );
1491 }
1492 }
1493 signed += 1;
1494 }
1495 Err(e) => {
1496 println!("{}", crate::messages::cli::vault_sign_failed(&e));
1497 failed += 1;
1498 }
1499 }
1500 }
1501 if signed > 0 {
1502 if let Err(e) = config.write() {
1503 eprintln!("{}", crate::messages::cli::vault_config_update_warning(&e));
1504 }
1505 }
1506 println!(
1507 "\nSigned: {}, failed: {}, skipped (valid): {}",
1508 signed, failed, skipped
1509 );
1510 if failed > 0 {
1511 std::process::exit(1);
1512 }
1513 } else if let Some(alias) = alias {
1514 let entry = entries
1515 .iter()
1516 .find(|h| h.alias == alias)
1517 .with_context(|| format!("Host '{}' not found", alias))?;
1518
1519 let role = vault_ssh::resolve_vault_role(
1520 entry.vault_ssh.as_deref(),
1521 entry.provider.as_deref(),
1522 entry.provider_label.as_deref(),
1523 &provider_config,
1524 )
1525 .with_context(|| crate::messages::cli::vault_no_role(&alias))?;
1526
1527 let pubkey = vault_ssh::resolve_pubkey_path(env.paths(), &entry.identity_file)?;
1528 let resolved_addr = cli_vault_addr.clone().or_else(|| {
1529 vault_ssh::resolve_vault_addr(
1530 entry.vault_addr.as_deref(),
1531 entry.provider.as_deref(),
1532 entry.provider_label.as_deref(),
1533 &provider_config,
1534 )
1535 });
1536 let result =
1537 vault_ssh::sign_certificate(env, &role, &pubkey, &alias, resolved_addr.as_deref())?;
1538 if should_write_certificate_file(&entry.certificate_file) {
1542 let updated =
1543 config.set_host_certificate_file(&alias, &result.cert_path.to_string_lossy());
1544 if !updated {
1545 anyhow::bail!(
1552 "Host '{}' disappeared from ssh config before CertificateFile could be written. Cert saved to {}.",
1553 alias,
1554 result.cert_path.display()
1555 );
1556 }
1557 config
1558 .write()
1559 .with_context(|| "Failed to update SSH config with CertificateFile")?;
1560 }
1561 println!(
1562 "{}",
1563 crate::messages::cli::vault_cert_signed(&result.cert_path.display())
1564 );
1565 } else {
1566 anyhow::bail!("Provide a host alias or use --all");
1567 }
1568 Ok(())
1569}
1570
1571pub fn select_proton_askpass(
1577 target_askpass: &[Option<String>],
1578 default: Option<String>,
1579) -> Option<String> {
1580 let any_proton = target_askpass.iter().any(|a| {
1581 let resolved = a.clone().or_else(|| default.clone());
1582 resolved.as_deref().unwrap_or("").starts_with("proton:")
1583 });
1584 if !any_proton {
1585 return None;
1586 }
1587 target_askpass
1588 .iter()
1589 .find_map(|a| a.as_ref().filter(|s| s.starts_with("proton:")))
1590 .cloned()
1591 .or(default)
1592}
1593
1594pub fn run_whats_new(since: Option<&str>) -> Result<String> {
1595 use crate::changelog::{self, EntryKind};
1596 use semver::Version;
1597
1598 let current = Version::parse(env!("CARGO_PKG_VERSION"))
1599 .with_context(|| "failed to parse current version")?;
1600 let last = match since {
1601 Some(s) => Some(Version::parse(s).with_context(|| format!("invalid --since version {s}"))?),
1602 None => None,
1603 };
1604
1605 let sections = changelog::cached();
1606 let shown = changelog::versions_to_show(sections, last.as_ref(), ¤t, sections.len());
1607
1608 let mut out = String::new();
1609 out.push_str(crate::messages::cli::whats_new::HEADER);
1610 out.push_str("\n\n");
1611 for section in shown {
1612 out.push_str(&format!("## {}", section.version));
1613 if let Some(date) = §ion.date {
1614 out.push_str(&format!(" - {}", date));
1615 }
1616 out.push('\n');
1617 for entry in §ion.entries {
1618 let prefix = match entry.kind {
1619 EntryKind::Feature => "+ ",
1620 EntryKind::Change => "~ ",
1621 EntryKind::Fix => "! ",
1622 };
1623 out.push_str(prefix);
1624 out.push_str(&entry.text);
1625 out.push('\n');
1626 }
1627 out.push('\n');
1628 }
1629 Ok(out)
1630}
1631
1632#[cfg(test)]
1633mod whats_new_tests {
1634 use super::*;
1635
1636 #[test]
1637 fn whats_new_cli_outputs_header() {
1638 let output = run_whats_new(None).unwrap();
1639 assert!(output.contains("purple release notes"));
1640 }
1641
1642 #[test]
1643 fn whats_new_cli_filters_by_since() {
1644 let output = run_whats_new(Some("999.0.0")).unwrap();
1645 assert!(!output.contains("## "));
1646 }
1647
1648 #[test]
1649 fn whats_new_cli_returns_error_on_bad_version() {
1650 let result = run_whats_new(Some("not-a-version"));
1651 assert!(result.is_err());
1652 }
1653}
1654
1655#[cfg(test)]
1656mod select_proton_askpass_tests {
1657 use super::*;
1658
1659 #[test]
1660 fn returns_none_when_no_target_uses_proton_and_no_default() {
1661 let targets = vec![
1662 Some("bw:foo".to_string()),
1663 Some("keychain".to_string()),
1664 None,
1665 ];
1666 assert_eq!(select_proton_askpass(&targets, None), None);
1667 }
1668
1669 #[test]
1670 fn returns_none_when_no_target_uses_proton_and_default_is_not_proton() {
1671 let targets = vec![Some("bw:foo".to_string()), None];
1672 let default = Some("keychain".to_string());
1673 assert_eq!(select_proton_askpass(&targets, default), None);
1674 }
1675
1676 #[test]
1677 fn returns_proton_value_when_one_target_uses_proton() {
1678 let targets = vec![
1679 Some("bw:other".to_string()),
1680 Some("proton:Vault/Item/p".to_string()),
1681 None,
1682 ];
1683 assert_eq!(
1684 select_proton_askpass(&targets, None),
1685 Some("proton:Vault/Item/p".to_string())
1686 );
1687 }
1688
1689 #[test]
1690 fn prefers_first_per_host_proton_value_over_default() {
1691 let targets = vec![
1692 Some("proton:First/Item/p".to_string()),
1693 Some("proton:Second/Item/p".to_string()),
1694 ];
1695 let default = Some("proton:Default/Item/p".to_string());
1696 assert_eq!(
1697 select_proton_askpass(&targets, default),
1698 Some("proton:First/Item/p".to_string())
1699 );
1700 }
1701
1702 #[test]
1703 fn falls_back_to_default_when_no_per_host_proton_value_but_default_is_proton() {
1704 let targets = vec![None, Some("bw:foo".to_string()), None];
1705 let default = Some("proton:Default/Item/p".to_string());
1706 assert_eq!(
1707 select_proton_askpass(&targets, default),
1708 Some("proton:Default/Item/p".to_string())
1709 );
1710 }
1711
1712 #[test]
1713 fn handles_all_proton_targets() {
1714 let targets = vec![
1715 Some("proton:A/x/p".to_string()),
1716 Some("proton:B/y/p".to_string()),
1717 ];
1718 assert_eq!(
1719 select_proton_askpass(&targets, None),
1720 Some("proton:A/x/p".to_string())
1721 );
1722 }
1723
1724 #[test]
1725 fn handles_empty_target_list_with_proton_default() {
1726 let default = Some("proton:Default/Item/p".to_string());
1727 assert_eq!(select_proton_askpass(&[], default), None);
1728 }
1729
1730 #[test]
1731 fn handles_empty_target_list_with_no_default() {
1732 assert_eq!(select_proton_askpass(&[], None), None);
1733 }
1734
1735 #[test]
1736 fn empty_string_askpass_does_not_match_proton() {
1737 let targets = vec![Some(String::new()), Some("bw:foo".to_string())];
1738 assert_eq!(select_proton_askpass(&targets, None), None);
1739 }
1740}