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