Skip to main content

purple_ssh/
cli.rs

1//! CLI subcommand handlers. Each function handles one clap subcommand
2//! (provider, tunnel, password, snippet, add, import, sync, logs, theme,
3//! vault sign) and runs outside the TUI in a non-interactive terminal context.
4
5use 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    // Reject control characters in alias, hostname, user and key
56    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    // Reject whitespace in hostname and user (matches TUI validation)
70    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    // The positional argument accepts either a bare provider name (sync ALL
177    // configs of that provider) or a labeled identifier `provider:label`
178    // (sync exactly that one config). No explicit flag form.
179    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 &sections {
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                // Not a hard failure: unknown provider contributes no changes,
228                // so other providers' successful results should still be written.
229                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            &section.token,
252            &std::sync::atomic::AtomicBool::new(false),
253            env,
254            &progress,
255        );
256        let summary = last_summary.into_inner();
257        // Complete the Syncing line: TTY overwrites with summary; non-TTY appends.
258        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, // suppress stale marking when partial failures occurred
304            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            // Migrate per-host state keyed by alias for every host the
343            // sync renamed. Tied to the successful config write: a
344            // skipped or failed write must not move history/recents
345            // to a new alias that did not land in `~/.ssh/config`.
346            if !all_renames.is_empty() {
347                log::info!(
348                    "[purple] cli sync: migrating per-host state for {} rename(s)",
349                    all_renames.len()
350                );
351                crate::app::migrate_renames_persistent_state(env.paths(), &all_renames);
352            }
353        }
354    }
355
356    if any_failures {
357        log::warn!("[purple] cli sync: completed with failures (exit 1)");
358        std::process::exit(1);
359    }
360
361    log::info!("[purple] cli sync: completed successfully");
362    Ok(())
363}
364
365pub fn handle_provider_command(
366    env: &crate::runtime::env::Env,
367    command: ProviderCommands,
368) -> Result<()> {
369    log::info!("[purple] cli provider: dispatch");
370    match command {
371        ProviderCommands::Add {
372            provider,
373            token,
374            token_stdin,
375            mut prefix,
376            mut user,
377            mut key,
378            url,
379            mut profile,
380            mut regions,
381            mut project,
382            mut compartment,
383            no_verify_tls,
384            verify_tls,
385            auto_sync,
386            no_auto_sync,
387            label,
388        } => {
389            let p = match providers::get_provider(&provider) {
390                Some(p) => p,
391                None => {
392                    eprintln!(
393                        "Never heard of '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip, teleport.",
394                        provider
395                    );
396                    std::process::exit(1);
397                }
398            };
399            // provider is validated above, so from_str always returns Some here.
400            let kind = provider.parse::<ProviderKind>().ok();
401
402            // --url, --no-verify-tls and --verify-tls are Proxmox-only; clear them for other providers
403            let mut token = token;
404            let mut url = url;
405            let mut no_verify_tls = no_verify_tls;
406            let mut verify_tls = verify_tls;
407            if kind != Some(ProviderKind::Proxmox) {
408                if url.is_some() {
409                    eprintln!("{}", crate::messages::cli::WARN_URL_NOT_USED);
410                    url = None;
411                }
412                if no_verify_tls {
413                    eprintln!("{}", crate::messages::cli::WARN_NO_VERIFY_TLS_NOT_USED);
414                    no_verify_tls = false;
415                }
416                if verify_tls {
417                    eprintln!("{}", crate::messages::cli::WARN_VERIFY_TLS_NOT_USED);
418                    verify_tls = false;
419                }
420            }
421            // --profile is AWS-only, --regions is AWS/Scaleway/GCP/Azure, --project is GCP-only
422            if kind != Some(ProviderKind::Aws) && profile.is_some() {
423                eprintln!("{}", crate::messages::cli::WARN_PROFILE_NOT_USED);
424                profile = None;
425            }
426            if !kind.is_some_and(ProviderKind::accepts_cli_regions) && regions.is_some() {
427                eprintln!("{}", crate::messages::cli::WARN_REGIONS_NOT_USED);
428                regions = None;
429            }
430            if kind != Some(ProviderKind::Gcp) && project.is_some() {
431                eprintln!("{}", crate::messages::cli::WARN_PROJECT_NOT_USED);
432                project = None;
433            }
434            if kind != Some(ProviderKind::Oracle) && compartment.is_some() {
435                eprintln!("{}", crate::messages::cli::WARN_COMPARTMENT_NOT_USED);
436                compartment = None;
437            }
438
439            // When updating an existing section, fall back to stored values for fields not supplied
440            let existing_section = providers::config::ProviderConfig::load(env.paths())
441                .section(&provider)
442                .cloned();
443
444            if let Some(ref existing) = existing_section {
445                // URL fallback only applies to Proxmox (only provider that uses the url field)
446                if kind == Some(ProviderKind::Proxmox) && url.is_none() && !existing.url.is_empty()
447                {
448                    url = Some(existing.url.clone());
449                }
450                if token.is_none()
451                    && !token_stdin
452                    && env.purple_token().is_none()
453                    && !existing.token.is_empty()
454                {
455                    token = Some(existing.token.clone());
456                }
457                if prefix.is_none() {
458                    prefix = Some(existing.alias_prefix.clone());
459                }
460                if user.is_none() {
461                    user = Some(existing.user.clone());
462                }
463                if key.is_none() && !existing.identity_file.is_empty() {
464                    key = Some(existing.identity_file.clone());
465                }
466                // Preserve verify_tls=false unless the user explicitly overrides it either way
467                if !no_verify_tls && !verify_tls && !existing.verify_tls {
468                    no_verify_tls = true;
469                }
470                // AWS: fall back to stored profile/regions
471                if kind == Some(ProviderKind::Aws)
472                    && profile.is_none()
473                    && !existing.profile.is_empty()
474                {
475                    profile = Some(existing.profile.clone());
476                }
477                // Providers that accept --regions: fall back to stored regions
478                if kind.is_some_and(ProviderKind::accepts_cli_regions)
479                    && regions.is_none()
480                    && !existing.regions.is_empty()
481                {
482                    regions = Some(existing.regions.clone());
483                }
484                // GCP: fall back to stored project
485                if kind == Some(ProviderKind::Gcp)
486                    && project.is_none()
487                    && !existing.project.is_empty()
488                {
489                    project = Some(existing.project.clone());
490                }
491                // Oracle: fall back to stored compartment
492                if kind == Some(ProviderKind::Oracle)
493                    && compartment.is_none()
494                    && !existing.compartment.is_empty()
495                {
496                    compartment = Some(existing.compartment.clone());
497                }
498            }
499
500            // Proxmox requires --url
501            if kind == Some(ProviderKind::Proxmox) {
502                if url.is_none() || url.as_deref().unwrap_or("").trim().is_empty() {
503                    eprintln!("{}", crate::messages::cli::PROXMOX_URL_REQUIRED);
504                    std::process::exit(1);
505                }
506                let u = url.as_deref().unwrap();
507                if !u.to_ascii_lowercase().starts_with("https://") {
508                    eprintln!("{}", crate::messages::cli::PROVIDER_URL_REQUIRES_HTTPS);
509                    std::process::exit(1);
510                }
511            }
512
513            // AWS allows empty token when --profile is set
514            let aws_has_profile = kind == Some(ProviderKind::Aws)
515                && profile.as_deref().is_some_and(|p| !p.trim().is_empty());
516            let token = if aws_has_profile
517                && token.is_none()
518                && !token_stdin
519                && env.purple_token().is_none()
520            {
521                String::new()
522            } else {
523                match super::resolve_token(env, token, token_stdin) {
524                    Ok(t) => t,
525                    Err(e) => {
526                        eprintln!("{}", e);
527                        std::process::exit(1);
528                    }
529                }
530            };
531
532            if token.trim().is_empty()
533                && !aws_has_profile
534                && !matches!(kind, Some(ProviderKind::Tailscale | ProviderKind::Teleport))
535            {
536                if kind == Some(ProviderKind::Gcp) {
537                    eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_GCP);
538                } else if kind == Some(ProviderKind::Oracle) {
539                    eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_ORACLE);
540                } else {
541                    eprintln!(
542                        "{}",
543                        crate::messages::cli::provider_token_required(
544                            providers::provider_display_name(&provider)
545                        )
546                    );
547                }
548                std::process::exit(1);
549            }
550
551            let alias_prefix = prefix.unwrap_or_else(|| p.short_label().to_string());
552            if crate::ssh_config::model::is_host_pattern(&alias_prefix) {
553                eprintln!("{}", crate::messages::cli::ALIAS_PREFIX_INVALID);
554                std::process::exit(1);
555            }
556
557            let user = user.unwrap_or_else(|| "root".to_string());
558            let identity_file = key.unwrap_or_default();
559
560            // Reject control characters in all fields (prevents INI injection)
561            let url_value = url.clone().unwrap_or_default();
562            let profile_value = profile.clone().unwrap_or_default();
563            let regions_value = regions.clone().unwrap_or_default();
564            let project_value = project.clone().unwrap_or_default();
565            let compartment_value = compartment.clone().unwrap_or_default();
566            for (value, name) in [
567                (&url_value, "URL"),
568                (&token, "Token"),
569                (&alias_prefix, "Alias prefix"),
570                (&user, "User"),
571                (&identity_file, "Identity file"),
572                (&profile_value, "Profile"),
573                (&project_value, "Project"),
574                (&regions_value, "Regions"),
575                (&compartment_value, "Compartment"),
576            ] {
577                if value.chars().any(|c| c.is_control()) {
578                    eprintln!("{}", crate::messages::cli::control_chars(name));
579                    std::process::exit(1);
580                }
581            }
582            if user.contains(char::is_whitespace) {
583                eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
584                std::process::exit(1);
585            }
586
587            // Resolve auto_sync: explicit flags > existing config > provider default
588            let resolved_auto_sync = if auto_sync {
589                true
590            } else if no_auto_sync {
591                false
592            } else if let Some(ref existing) = existing_section {
593                existing.auto_sync
594            } else {
595                kind != Some(ProviderKind::Proxmox)
596            };
597
598            let resolved_profile = profile.unwrap_or_default();
599            let resolved_regions = regions.unwrap_or_default();
600            let resolved_project = project.unwrap_or_default();
601            let resolved_compartment = compartment.unwrap_or_default();
602
603            // AWS/Scaleway/Azure requires at least one region/zone/subscription
604            if kind == Some(ProviderKind::Aws) && resolved_regions.trim().is_empty() {
605                eprintln!("{}", crate::messages::cli::AWS_REGIONS_REQUIRED);
606                std::process::exit(1);
607            }
608            if kind == Some(ProviderKind::Scaleway) && resolved_regions.trim().is_empty() {
609                eprintln!("{}", crate::messages::cli::SCALEWAY_REGIONS_REQUIRED);
610                std::process::exit(1);
611            }
612            if kind == Some(ProviderKind::Azure) {
613                if resolved_regions.trim().is_empty() {
614                    eprintln!("{}", crate::messages::cli::AZURE_REGIONS_REQUIRED);
615                    std::process::exit(1);
616                }
617                for sub in resolved_regions
618                    .split(',')
619                    .map(|s| s.trim())
620                    .filter(|s| !s.is_empty())
621                {
622                    if !providers::azure::is_valid_subscription_id(sub) {
623                        eprintln!(
624                            "{}",
625                            crate::messages::cli::azure_subscription_id_invalid(sub)
626                        );
627                        std::process::exit(1);
628                    }
629                }
630            }
631            // GCP requires --project
632            if kind == Some(ProviderKind::Gcp) && resolved_project.trim().is_empty() {
633                eprintln!("{}", crate::messages::cli::GCP_PROJECT_REQUIRED);
634                std::process::exit(1);
635            }
636            // Oracle requires --compartment
637            if kind == Some(ProviderKind::Oracle) && resolved_compartment.trim().is_empty() {
638                eprintln!("{}", crate::messages::cli::ORACLE_COMPARTMENT_REQUIRED);
639                std::process::exit(1);
640            }
641
642            let mut config = providers::config::ProviderConfig::load(env.paths());
643
644            // Resolve the target ProviderConfigId given --label and the
645            // provider's existing config layout. Rules:
646            //   --label X:    add/update [provider:X]; refuse mix with bare
647            //   no --label:   single bare config OR the only labeled config
648            //                 (if 2+ labeled exist, error: ambiguous)
649            let id: providers::config::ProviderConfigId = match label.as_deref() {
650                Some(l) => {
651                    if let Err(e) = providers::config::validate_label(l) {
652                        eprintln!("{}", crate::messages::cli::invalid_label_flag(&e));
653                        std::process::exit(1);
654                    }
655                    providers::config::ProviderConfigId::labeled(provider.clone(), l)
656                }
657                None => providers::config::ProviderConfigId::bare(provider.clone()),
658            };
659
660            // Refuse to mix bare and labeled configs for the same provider:
661            // mirrors the parser invariant.
662            let existing = config.sections_for_provider(&provider);
663            let has_bare = existing.iter().any(|s| s.id.label.is_none());
664            let has_labeled = existing.iter().any(|s| s.id.label.is_some());
665            if id.label.is_none() && has_labeled {
666                eprintln!("{}", crate::messages::cli::add_requires_label(&provider));
667                std::process::exit(1);
668            }
669            if id.label.is_some() && has_bare {
670                eprintln!(
671                    "{}",
672                    crate::messages::cli::add_label_collides_with_bare(&provider)
673                );
674                std::process::exit(1);
675            }
676
677            let section = providers::config::ProviderSection {
678                id: id.clone(),
679                token,
680                alias_prefix,
681                user,
682                identity_file,
683                url: url.unwrap_or_default(),
684                verify_tls: !no_verify_tls,
685                auto_sync: resolved_auto_sync,
686                profile: resolved_profile,
687                regions: resolved_regions,
688                project: resolved_project,
689                compartment: resolved_compartment,
690                vault_role: String::new(),
691                vault_addr: String::new(),
692            };
693
694            config.set_section(section);
695            config
696                .save()
697                .map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
698            println!("{}", crate::messages::cli::saved_config(&id.to_string()));
699            Ok(())
700        }
701        ProviderCommands::List => {
702            let config = providers::config::ProviderConfig::load(env.paths());
703            let sections = config.configured_providers();
704            if sections.is_empty() {
705                println!("{}", crate::messages::cli::NO_PROVIDERS);
706            } else {
707                for s in sections {
708                    let display_name = providers::provider_display_name(s.provider());
709                    let label_suffix = match &s.id.label {
710                        Some(l) => format!(" ({})", l),
711                        None => String::new(),
712                    };
713                    println!(
714                        "  {:<24} {}-*{:>8}",
715                        format!("{}{}", display_name, label_suffix),
716                        s.alias_prefix,
717                        s.user
718                    );
719                }
720            }
721            Ok(())
722        }
723        ProviderCommands::Remove { provider } => {
724            // Accept either `digitalocean` (remove all configs of that
725            // provider) or `digitalocean:work` (remove only that one).
726            let id: providers::config::ProviderConfigId = match provider.parse() {
727                Ok(id) => id,
728                Err(e) => {
729                    eprintln!("{}: {}", provider, e);
730                    std::process::exit(1);
731                }
732            };
733            let mut config = providers::config::ProviderConfig::load(env.paths());
734            let removed = match &id.label {
735                Some(_) => {
736                    if config.section_by_id(&id).is_none() {
737                        eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
738                        std::process::exit(1);
739                    }
740                    config.remove_section_by_id(&id);
741                    1
742                }
743                None => {
744                    let count = config.sections_for_provider(&id.provider).len();
745                    if count == 0 {
746                        eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
747                        std::process::exit(1);
748                    }
749                    config.remove_section(&id.provider);
750                    count
751                }
752            };
753            config
754                .save()
755                .map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
756            if removed == 1 {
757                println!("{}", crate::messages::cli::removed_config(&provider));
758            } else {
759                println!(
760                    "{}",
761                    crate::messages::cli::removed_configs(&provider, removed)
762                );
763            }
764            Ok(())
765        }
766    }
767}
768
769pub fn handle_tunnel_command(mut config: SshConfigFile, command: TunnelCommands) -> Result<()> {
770    log::info!("[purple] cli tunnel: dispatch");
771    match command {
772        TunnelCommands::List { alias } => {
773            if let Some(alias) = alias {
774                // Show tunnels for a specific host
775                if !config.has_host(&alias) {
776                    eprintln!("{}", crate::messages::cli::host_not_found(&alias));
777                    std::process::exit(1);
778                }
779                let rules = config.find_tunnel_directives(&alias);
780                if rules.is_empty() {
781                    println!("{}", crate::messages::cli::no_tunnels_for(&alias));
782                } else {
783                    println!("{}", crate::messages::cli::tunnels_for(&alias));
784                    for rule in &rules {
785                        println!("  {}", rule.display());
786                    }
787                }
788            } else {
789                // Show all hosts with tunnels
790                let entries = config.host_entries();
791                let with_tunnels: Vec<_> = entries.iter().filter(|e| e.tunnel_count > 0).collect();
792                if with_tunnels.is_empty() {
793                    println!("{}", crate::messages::cli::NO_TUNNELS);
794                } else {
795                    for (i, host) in with_tunnels.iter().enumerate() {
796                        if i > 0 {
797                            println!();
798                        }
799                        println!("{}:", host.alias);
800                        for rule in config.find_tunnel_directives(&host.alias) {
801                            println!("  {}", rule.display());
802                        }
803                    }
804                }
805            }
806            Ok(())
807        }
808        TunnelCommands::Add { alias, forward } => {
809            if !config.has_host(&alias) {
810                eprintln!("{}", crate::messages::cli::host_not_found(&alias));
811                std::process::exit(1);
812            }
813            if config.is_included_host(&alias) {
814                eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
815                std::process::exit(1);
816            }
817            let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
818                eprintln!("{}", e);
819                std::process::exit(1);
820            });
821            let key = rule.tunnel_type.directive_key();
822            let value = rule.to_directive_value();
823            // Check for duplicate forward
824            if config.has_forward(&alias, key, &value) {
825                eprintln!("{}", crate::messages::cli::forward_exists(&forward, &alias));
826                std::process::exit(1);
827            }
828            config.add_forward(&alias, key, &value);
829            log::debug!(
830                "[config] cli tunnel add: writing ssh config (alias={})",
831                alias
832            );
833            if let Err(e) = config.write() {
834                log::warn!("[purple] cli tunnel add: write failed: {}", e);
835                eprintln!("{}", crate::messages::cli::save_config_failed(&e));
836                std::process::exit(1);
837            }
838            log::info!(
839                "[purple] cli tunnel add: forward={} alias={}",
840                forward,
841                alias
842            );
843            println!("{}", crate::messages::cli::added_forward(&forward, &alias));
844            Ok(())
845        }
846        TunnelCommands::Remove { alias, forward } => {
847            if !config.has_host(&alias) {
848                eprintln!("{}", crate::messages::cli::host_not_found(&alias));
849                std::process::exit(1);
850            }
851            if config.is_included_host(&alias) {
852                eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
853                std::process::exit(1);
854            }
855            let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
856                eprintln!("{}", e);
857                std::process::exit(1);
858            });
859            let key = rule.tunnel_type.directive_key();
860            let value = rule.to_directive_value();
861            let removed = config.remove_forward(&alias, key, &value);
862            if !removed {
863                eprintln!(
864                    "{}",
865                    crate::messages::cli::forward_not_found(&forward, &alias)
866                );
867                std::process::exit(1);
868            }
869            log::debug!(
870                "[config] cli tunnel remove: writing ssh config (alias={})",
871                alias
872            );
873            if let Err(e) = config.write() {
874                log::warn!("[purple] cli tunnel remove: write failed: {}", e);
875                eprintln!("{}", crate::messages::cli::save_config_failed(&e));
876                std::process::exit(1);
877            }
878            log::info!(
879                "[purple] cli tunnel remove: forward={} alias={}",
880                forward,
881                alias
882            );
883            println!(
884                "{}",
885                crate::messages::cli::removed_forward(&forward, &alias)
886            );
887            Ok(())
888        }
889        TunnelCommands::Start { alias } => {
890            log::info!("[purple] cli tunnel start: alias={}", alias);
891            if !config.has_host(&alias) {
892                eprintln!("{}", crate::messages::cli::host_not_found(&alias));
893                std::process::exit(1);
894            }
895            let tunnels = config.find_tunnel_directives(&alias);
896            if tunnels.is_empty() {
897                log::warn!("[purple] cli tunnel start: no forwards for alias={}", alias);
898                eprintln!("{}", crate::messages::cli::no_forwards(&alias));
899                std::process::exit(1);
900            }
901            println!("{}", crate::messages::cli::starting_tunnel(&alias));
902            // Run ssh -N in foreground with inherited stdio
903            let status = std::process::Command::new("ssh")
904                .arg("-F")
905                .arg(&config.path)
906                .arg("-N")
907                .arg("--")
908                .arg(&alias)
909                .status()
910                .map_err(|e| anyhow::anyhow!("Failed to start ssh: {}", e))?;
911            let code = status.code().unwrap_or(1);
912            std::process::exit(code);
913        }
914    }
915}
916
917/// Read a line of input with echo disabled. Returns None if the user presses Esc.
918pub fn prompt_hidden_input(prompt: &str) -> Result<Option<String>> {
919    eprint!("{}", prompt);
920    crossterm::terminal::enable_raw_mode()?;
921    let mut input = String::new();
922    loop {
923        if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
924            match key.code {
925                crossterm::event::KeyCode::Enter => break,
926                crossterm::event::KeyCode::Char(c) => {
927                    input.push(c);
928                    eprint!("*");
929                }
930                crossterm::event::KeyCode::Backspace if input.pop().is_some() => {
931                    eprint!("\x08 \x08");
932                }
933                crossterm::event::KeyCode::Esc => {
934                    crossterm::terminal::disable_raw_mode()?;
935                    eprintln!();
936                    return Ok(None);
937                }
938                _ => {}
939            }
940        }
941    }
942    crossterm::terminal::disable_raw_mode()?;
943    eprintln!();
944    Ok(Some(input))
945}
946
947/// Resolve the current on-disk mtime of a host's Vault SSH certificate.
948///
949/// Used by the `CertCheckResult` handler so every cache entry carries a
950/// mtime alongside its status, enabling mtime-based lazy invalidation when
951/// an external actor (CLI, another purple instance) rewrites the cert.
952pub fn handle_password_command(
953    env: &crate::runtime::env::Env,
954    command: PasswordCommands,
955) -> Result<()> {
956    log::info!("[purple] cli password: dispatch");
957    match command {
958        PasswordCommands::Set { alias } => {
959            let password =
960                match prompt_hidden_input(&crate::messages::askpass::password_prompt(&alias))? {
961                    Some(p) if !p.is_empty() => p,
962                    Some(_) => {
963                        eprintln!("{}", crate::messages::cli::PASSWORD_EMPTY);
964                        std::process::exit(1);
965                    }
966                    None => {
967                        eprintln!("{}", crate::messages::cli::CANCELLED);
968                        std::process::exit(1);
969                    }
970                };
971
972            askpass::store_in_keychain(env, &alias, &password)?;
973            println!(
974                "Password stored for {}. Set 'keychain' as password source to use it.",
975                alias
976            );
977            Ok(())
978        }
979        PasswordCommands::Remove { alias } => {
980            askpass::remove_from_keychain(env, &alias)?;
981            println!("{}", crate::messages::cli::password_removed(&alias));
982            Ok(())
983        }
984    }
985}
986
987pub fn handle_snippet_command(
988    env: &crate::runtime::env::Env,
989    config: SshConfigFile,
990    command: SnippetCommands,
991    config_path: &Path,
992) -> Result<()> {
993    log::info!("[purple] cli snippet: dispatch");
994    match command {
995        SnippetCommands::List => {
996            let store = snippet::SnippetStore::load(env.paths());
997            if store.snippets.is_empty() {
998                println!("{}", crate::messages::cli::NO_SNIPPETS);
999            } else {
1000                for s in &store.snippets {
1001                    if s.description.is_empty() {
1002                        println!("  {}  {}", s.name, s.command);
1003                    } else {
1004                        println!("  {}  {}  ({})", s.name, s.command, s.description);
1005                    }
1006                }
1007            }
1008            Ok(())
1009        }
1010        SnippetCommands::Add {
1011            name,
1012            command,
1013            description,
1014        } => {
1015            if let Err(e) = snippet::validate_name(&name) {
1016                eprintln!("{}", e);
1017                std::process::exit(1);
1018            }
1019            if let Err(e) = snippet::validate_command(&command) {
1020                eprintln!("{}", e);
1021                std::process::exit(1);
1022            }
1023            if let Some(ref desc) = description {
1024                if desc.contains(|c: char| c.is_control()) {
1025                    eprintln!("{}", crate::messages::cli::DESCRIPTION_CONTROL_CHARS);
1026                    std::process::exit(1);
1027                }
1028            }
1029            let mut store = snippet::SnippetStore::load(env.paths());
1030            let is_update = store.get(&name).is_some();
1031            store.set(snippet::Snippet {
1032                name: name.clone(),
1033                command,
1034                description: description.unwrap_or_default(),
1035            });
1036            store.save()?;
1037            if is_update {
1038                println!("{}", crate::messages::cli::snippet_updated(&name));
1039            } else {
1040                println!("{}", crate::messages::cli::snippet_added(&name));
1041            }
1042            Ok(())
1043        }
1044        SnippetCommands::Remove { name } => {
1045            let mut store = snippet::SnippetStore::load(env.paths());
1046            if store.get(&name).is_none() {
1047                eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1048                std::process::exit(1);
1049            }
1050            store.remove(&name);
1051            store.save()?;
1052            println!("{}", crate::messages::cli::snippet_removed(&name));
1053            Ok(())
1054        }
1055        SnippetCommands::Run {
1056            name,
1057            alias,
1058            tag,
1059            all,
1060            parallel,
1061        } => {
1062            let store = snippet::SnippetStore::load(env.paths());
1063            let snip = match store.get(&name) {
1064                Some(s) => s.clone(),
1065                None => {
1066                    eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1067                    std::process::exit(1);
1068                }
1069            };
1070
1071            let entries = config.host_entries();
1072
1073            // Determine target hosts
1074            let targets: Vec<&HostEntry> = if let Some(ref alias) = alias {
1075                match entries.iter().find(|h| h.alias == *alias) {
1076                    Some(h) => vec![h],
1077                    None => {
1078                        eprintln!("{}", crate::messages::cli::host_not_found(alias));
1079                        std::process::exit(1);
1080                    }
1081                }
1082            } else if let Some(ref tag_filter) = tag {
1083                let matched: Vec<_> = entries
1084                    .iter()
1085                    .filter(|h| h.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_filter)))
1086                    .collect();
1087                if matched.is_empty() {
1088                    eprintln!("{}", crate::messages::cli::no_hosts_with_tag(tag_filter));
1089                    std::process::exit(1);
1090                }
1091                matched
1092            } else if all {
1093                entries.iter().collect()
1094            } else {
1095                eprintln!("{}", crate::messages::cli::SPECIFY_TARGET);
1096                std::process::exit(1);
1097            };
1098
1099            if targets.len() == 1 {
1100                // Single host: run directly
1101                let host = targets[0];
1102                let askpass = host
1103                    .askpass
1104                    .clone()
1105                    .or_else(|| preferences::load_askpass_default(env.paths()));
1106                super::ensure_proton_login(env, askpass.as_deref());
1107                let bw_session = super::ensure_bw_session(env, None, askpass.as_deref());
1108                super::ensure_keychain_password(env, &host.alias, askpass.as_deref());
1109                match snippet::run_snippet(
1110                    &host.alias,
1111                    config_path,
1112                    env,
1113                    &snip.command,
1114                    askpass.as_deref(),
1115                    bw_session.as_deref(),
1116                    false,
1117                    false,
1118                ) {
1119                    Ok(r) => {
1120                        if !r.status.success() {
1121                            std::process::exit(r.status.code().unwrap_or(1));
1122                        }
1123                    }
1124                    Err(e) => {
1125                        eprintln!("{}", crate::messages::cli::operation_failed(&e));
1126                        std::process::exit(1);
1127                    }
1128                }
1129            } else if parallel {
1130                // Multi-host parallel
1131                use std::sync::mpsc;
1132                use std::thread;
1133                let (tx, rx) = mpsc::channel();
1134                let max_concurrent: usize = 20;
1135                let (slot_tx, slot_rx) = mpsc::channel();
1136                for _ in 0..max_concurrent {
1137                    let _ = slot_tx.send(());
1138                }
1139                let config_path = config_path.to_path_buf();
1140                // Resolve BW session if any target uses Bitwarden
1141                let any_bw = targets.iter().any(|h| {
1142                    let askpass = h
1143                        .askpass
1144                        .clone()
1145                        .or_else(|| preferences::load_askpass_default(env.paths()));
1146                    askpass.as_deref().unwrap_or("").starts_with("bw:")
1147                });
1148                let bw_session = if any_bw {
1149                    let bw_askpass = targets
1150                        .iter()
1151                        .find_map(|h| h.askpass.as_ref().filter(|a| a.starts_with("bw:")))
1152                        .cloned()
1153                        .or_else(|| preferences::load_askpass_default(env.paths()));
1154                    super::ensure_bw_session(env, None, bw_askpass.as_deref())
1155                } else {
1156                    None
1157                };
1158                // Resolve Proton Pass login if any target uses it. Proton Pass
1159                // persists its session on disk; we do not propagate a token, we
1160                // only ensure the user is logged in once before the batch starts.
1161                let target_askpass: Vec<Option<String>> =
1162                    targets.iter().map(|h| h.askpass.clone()).collect();
1163                if let Some(askpass) = select_proton_askpass(
1164                    &target_askpass,
1165                    preferences::load_askpass_default(env.paths()),
1166                ) {
1167                    super::ensure_proton_login(env, Some(&askpass));
1168                }
1169                let targets_info: Vec<_> = targets
1170                    .iter()
1171                    .map(|h| {
1172                        let askpass = h
1173                            .askpass
1174                            .clone()
1175                            .or_else(|| preferences::load_askpass_default(env.paths()));
1176                        super::ensure_keychain_password(env, &h.alias, askpass.as_deref());
1177                        (h.alias.clone(), askpass)
1178                    })
1179                    .collect();
1180                let command = snip.command.clone();
1181                let env = std::sync::Arc::new(env.clone());
1182                thread::spawn(move || {
1183                    for (alias, askpass) in targets_info {
1184                        let _ = slot_rx.recv();
1185                        let slot_tx = slot_tx.clone();
1186                        let tx = tx.clone();
1187                        let config_path = config_path.clone();
1188                        let env = std::sync::Arc::clone(&env);
1189                        let command = command.clone();
1190                        let bw_session = bw_session.clone();
1191                        thread::spawn(move || {
1192                            let result = snippet::run_snippet(
1193                                &alias,
1194                                &config_path,
1195                                &env,
1196                                &command,
1197                                askpass.as_deref(),
1198                                bw_session.as_deref(),
1199                                true,
1200                                false,
1201                            );
1202                            let _ = tx.send((alias, result));
1203                            let _ = slot_tx.send(());
1204                        });
1205                    }
1206                });
1207
1208                let host_count = targets.len();
1209                for _ in 0..host_count {
1210                    if let Ok((alias, result)) = rx.recv() {
1211                        match result {
1212                            Ok(r) => {
1213                                for line in r.stdout.lines() {
1214                                    println!("[{}] {}", alias, line);
1215                                }
1216                                for line in r.stderr.lines() {
1217                                    eprintln!("[{}] {}", alias, line);
1218                                }
1219                            }
1220                            Err(e) => {
1221                                eprintln!("{}", crate::messages::cli::host_failed(&alias, &e))
1222                            }
1223                        }
1224                    }
1225                }
1226            } else {
1227                // Multi-host sequential
1228                let mut bw_session: Option<String> = None;
1229                for host in &targets {
1230                    let askpass = host
1231                        .askpass
1232                        .clone()
1233                        .or_else(|| preferences::load_askpass_default(env.paths()));
1234                    super::ensure_proton_login(env, askpass.as_deref());
1235                    if let Some(token) =
1236                        super::ensure_bw_session(env, bw_session.as_deref(), askpass.as_deref())
1237                    {
1238                        bw_session = Some(token);
1239                    }
1240                    super::ensure_keychain_password(env, &host.alias, askpass.as_deref());
1241                    println!("{}", crate::messages::cli::host_separator(&host.alias));
1242                    match snippet::run_snippet(
1243                        &host.alias,
1244                        config_path,
1245                        env,
1246                        &snip.command,
1247                        askpass.as_deref(),
1248                        bw_session.as_deref(),
1249                        false,
1250                        false,
1251                    ) {
1252                        Ok(r) => {
1253                            if !r.status.success() {
1254                                eprintln!(
1255                                    "{}",
1256                                    crate::messages::cli::exited_with_code(
1257                                        r.status.code().unwrap_or(1)
1258                                    )
1259                                );
1260                            }
1261                        }
1262                        Err(e) => {
1263                            eprintln!("{}", crate::messages::cli::host_failed(&host.alias, &e))
1264                        }
1265                    }
1266                    println!();
1267                }
1268            }
1269            Ok(())
1270        }
1271    }
1272}
1273
1274pub fn handle_logs_command(tail: bool, clear: bool, env: &crate::runtime::env::Env) -> Result<()> {
1275    let path = logging::log_path(env.paths()).context("Could not determine log path")?;
1276    if clear {
1277        if path.exists() {
1278            std::fs::remove_file(&path)?;
1279            println!("{}", crate::messages::cli::log_deleted(&path.display()));
1280        } else {
1281            println!("{}", crate::messages::cli::no_log_file(&path.display()));
1282        }
1283    } else if tail {
1284        let status = std::process::Command::new("tail")
1285            .args(["-f", &path.to_string_lossy()])
1286            .status()
1287            .context("Failed to run tail")?;
1288        std::process::exit(status.code().unwrap_or(1));
1289    } else {
1290        println!("{}", path.display());
1291    }
1292    Ok(())
1293}
1294
1295pub fn handle_theme_command(env: &crate::runtime::env::Env, command: ThemeCommands) -> Result<()> {
1296    log::info!("[purple] cli theme: dispatch");
1297    match command {
1298        ThemeCommands::List => {
1299            let current =
1300                preferences::load_theme(env.paths()).unwrap_or_else(|| "Purple".to_string());
1301            println!("{}", crate::messages::cli::BUILTIN_THEMES);
1302            for theme in ui::theme::ThemeDef::builtins() {
1303                let marker = if theme.name.eq_ignore_ascii_case(&current) {
1304                    "*"
1305                } else {
1306                    " "
1307                };
1308                println!("  {} {}", marker, theme.name);
1309            }
1310            let custom = ui::theme::ThemeDef::load_custom(env.paths());
1311            if !custom.is_empty() {
1312                println!("{}", crate::messages::cli::CUSTOM_THEMES);
1313                for theme in &custom {
1314                    let marker = if theme.name.eq_ignore_ascii_case(&current) {
1315                        "*"
1316                    } else {
1317                        " "
1318                    };
1319                    println!("  {} {}", marker, theme.name);
1320                }
1321            }
1322        }
1323        ThemeCommands::Set { name } => {
1324            let found = ui::theme::ThemeDef::find_builtin(&name).or_else(|| {
1325                ui::theme::ThemeDef::load_custom(env.paths())
1326                    .into_iter()
1327                    .find(|t| t.name.eq_ignore_ascii_case(&name))
1328            });
1329            match found {
1330                Some(theme) => {
1331                    preferences::save_theme(env.paths(), &theme.name)?;
1332                    println!("{}", crate::messages::cli::theme_set(&theme.name));
1333                }
1334                None => {
1335                    anyhow::bail!("Unknown theme: {}", name);
1336                }
1337            }
1338        }
1339    }
1340    Ok(())
1341}
1342
1343pub fn handle_vault_sign_command(
1344    env: &crate::runtime::env::Env,
1345    mut config: SshConfigFile,
1346    alias: Option<String>,
1347    all: bool,
1348    cli_vault_addr: Option<String>,
1349) -> Result<()> {
1350    log::info!(
1351        "[purple] cli vault sign: alias={:?} all={} vault_addr={:?}",
1352        alias,
1353        all,
1354        cli_vault_addr
1355    );
1356    if let Some(ref addr) = cli_vault_addr {
1357        if !vault_ssh::is_valid_vault_addr(addr) {
1358            anyhow::bail!(
1359                "Invalid --vault-addr value. Must be non-empty, no whitespace or control chars."
1360            );
1361        }
1362    }
1363    let provider_config = providers::config::ProviderConfig::load(env.paths());
1364    let entries = config.host_entries();
1365
1366    if all {
1367        let mut signed = 0u32;
1368        let mut failed = 0u32;
1369        let mut skipped = 0u32;
1370
1371        for entry in &entries {
1372            let role = match vault_ssh::resolve_vault_role(
1373                entry.vault_ssh.as_deref(),
1374                entry.provider.as_deref(),
1375                entry.provider_label.as_deref(),
1376                &provider_config,
1377            ) {
1378                Some(r) => r,
1379                None => {
1380                    skipped += 1;
1381                    continue;
1382                }
1383            };
1384
1385            let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &entry.identity_file) {
1386                Ok(p) => p,
1387                Err(e) => {
1388                    println!("{}", crate::messages::cli::skipping_host(&entry.alias, &e));
1389                    failed += 1;
1390                    continue;
1391                }
1392            };
1393            let cert_path =
1394                vault_ssh::resolve_cert_path(env.paths(), &entry.alias, &entry.certificate_file)?;
1395            let status = vault_ssh::check_cert_validity(env, &cert_path);
1396
1397            if !vault_ssh::needs_renewal(&status) {
1398                skipped += 1;
1399                continue;
1400            }
1401
1402            // Flag beats per-host beats provider default.
1403            let resolved_addr = cli_vault_addr.clone().or_else(|| {
1404                vault_ssh::resolve_vault_addr(
1405                    entry.vault_addr.as_deref(),
1406                    entry.provider.as_deref(),
1407                    entry.provider_label.as_deref(),
1408                    &provider_config,
1409                )
1410            });
1411            print!("{}", crate::messages::cli::vault_signing_host(&entry.alias));
1412            match vault_ssh::sign_certificate(
1413                env,
1414                &role,
1415                &pubkey,
1416                &entry.alias,
1417                resolved_addr.as_deref(),
1418            ) {
1419                Ok(result) => {
1420                    println!("\u{2713}");
1421                    // Honor the same invariant as the TUI paths: never
1422                    // overwrite a user-set CertificateFile.
1423                    if should_write_certificate_file(&entry.certificate_file) {
1424                        let updated = config.set_host_certificate_file(
1425                            &entry.alias,
1426                            &result.cert_path.to_string_lossy(),
1427                        );
1428                        if !updated {
1429                            eprintln!(
1430                                "{}",
1431                                crate::messages::cli::vault_sign_host_block_gone(&entry.alias)
1432                            );
1433                        }
1434                    }
1435                    signed += 1;
1436                }
1437                Err(e) => {
1438                    println!("{}", crate::messages::cli::vault_sign_failed(&e));
1439                    failed += 1;
1440                }
1441            }
1442        }
1443        if signed > 0 {
1444            if let Err(e) = config.write() {
1445                eprintln!("{}", crate::messages::cli::vault_config_update_warning(&e));
1446            }
1447        }
1448        println!(
1449            "\nSigned: {}, failed: {}, skipped (valid): {}",
1450            signed, failed, skipped
1451        );
1452        if failed > 0 {
1453            std::process::exit(1);
1454        }
1455    } else if let Some(alias) = alias {
1456        let entry = entries
1457            .iter()
1458            .find(|h| h.alias == alias)
1459            .with_context(|| format!("Host '{}' not found", alias))?;
1460
1461        let role = vault_ssh::resolve_vault_role(
1462            entry.vault_ssh.as_deref(),
1463            entry.provider.as_deref(),
1464            entry.provider_label.as_deref(),
1465            &provider_config,
1466        )
1467        .with_context(|| crate::messages::cli::vault_no_role(&alias))?;
1468
1469        let pubkey = vault_ssh::resolve_pubkey_path(env.paths(), &entry.identity_file)?;
1470        let resolved_addr = cli_vault_addr.clone().or_else(|| {
1471            vault_ssh::resolve_vault_addr(
1472                entry.vault_addr.as_deref(),
1473                entry.provider.as_deref(),
1474                entry.provider_label.as_deref(),
1475                &provider_config,
1476            )
1477        });
1478        let result =
1479            vault_ssh::sign_certificate(env, &role, &pubkey, &alias, resolved_addr.as_deref())?;
1480        // Honor the same invariant as the TUI paths: never overwrite a
1481        // user-set CertificateFile. Only write the directive (and the
1482        // SSH config) when the host has none yet.
1483        if should_write_certificate_file(&entry.certificate_file) {
1484            let updated =
1485                config.set_host_certificate_file(&alias, &result.cert_path.to_string_lossy());
1486            if !updated {
1487                // Host disappeared between the `entries` snapshot and
1488                // the config mutation. In the single-host CLI path
1489                // both reads happen back-to-back in the same process,
1490                // so this is effectively unreachable — but surface it
1491                // loudly if the invariant ever breaks instead of
1492                // silently writing a cert nobody references.
1493                anyhow::bail!(
1494                    "Host '{}' disappeared from ssh config before CertificateFile could be written. Cert saved to {}.",
1495                    alias,
1496                    result.cert_path.display()
1497                );
1498            }
1499            config
1500                .write()
1501                .with_context(|| "Failed to update SSH config with CertificateFile")?;
1502        }
1503        println!(
1504            "{}",
1505            crate::messages::cli::vault_cert_signed(&result.cert_path.display())
1506        );
1507    } else {
1508        anyhow::bail!("Provide a host alias or use --all");
1509    }
1510    Ok(())
1511}
1512
1513/// Pick the askpass value that drives a single Proton Pass pre-flight call for
1514/// a batch of hosts. Returns `Some(value)` if any host uses a `proton:` source
1515/// (per-host override OR the global default), preferring the first `proton:`
1516/// value by position in the slice before falling back to the default. Returns
1517/// `None` when no host in the batch uses Proton Pass.
1518pub fn select_proton_askpass(
1519    target_askpass: &[Option<String>],
1520    default: Option<String>,
1521) -> Option<String> {
1522    let any_proton = target_askpass.iter().any(|a| {
1523        let resolved = a.clone().or_else(|| default.clone());
1524        resolved.as_deref().unwrap_or("").starts_with("proton:")
1525    });
1526    if !any_proton {
1527        return None;
1528    }
1529    target_askpass
1530        .iter()
1531        .find_map(|a| a.as_ref().filter(|s| s.starts_with("proton:")))
1532        .cloned()
1533        .or(default)
1534}
1535
1536pub fn run_whats_new(since: Option<&str>) -> Result<String> {
1537    use crate::changelog::{self, EntryKind};
1538    use semver::Version;
1539
1540    let current = Version::parse(env!("CARGO_PKG_VERSION"))
1541        .with_context(|| "failed to parse current version")?;
1542    let last = match since {
1543        Some(s) => Some(Version::parse(s).with_context(|| format!("invalid --since version {s}"))?),
1544        None => None,
1545    };
1546
1547    let sections = changelog::cached();
1548    let shown = changelog::versions_to_show(sections, last.as_ref(), &current, sections.len());
1549
1550    let mut out = String::new();
1551    out.push_str(crate::messages::cli::whats_new::HEADER);
1552    out.push_str("\n\n");
1553    for section in shown {
1554        out.push_str(&format!("## {}", section.version));
1555        if let Some(date) = &section.date {
1556            out.push_str(&format!(" - {}", date));
1557        }
1558        out.push('\n');
1559        for entry in &section.entries {
1560            let prefix = match entry.kind {
1561                EntryKind::Feature => "+ ",
1562                EntryKind::Change => "~ ",
1563                EntryKind::Fix => "! ",
1564            };
1565            out.push_str(prefix);
1566            out.push_str(&entry.text);
1567            out.push('\n');
1568        }
1569        out.push('\n');
1570    }
1571    Ok(out)
1572}
1573
1574#[cfg(test)]
1575mod whats_new_tests {
1576    use super::*;
1577
1578    #[test]
1579    fn whats_new_cli_outputs_header() {
1580        let output = run_whats_new(None).unwrap();
1581        assert!(output.contains("purple release notes"));
1582    }
1583
1584    #[test]
1585    fn whats_new_cli_filters_by_since() {
1586        let output = run_whats_new(Some("999.0.0")).unwrap();
1587        assert!(!output.contains("## "));
1588    }
1589
1590    #[test]
1591    fn whats_new_cli_returns_error_on_bad_version() {
1592        let result = run_whats_new(Some("not-a-version"));
1593        assert!(result.is_err());
1594    }
1595}
1596
1597#[cfg(test)]
1598mod select_proton_askpass_tests {
1599    use super::*;
1600
1601    #[test]
1602    fn returns_none_when_no_target_uses_proton_and_no_default() {
1603        let targets = vec![
1604            Some("bw:foo".to_string()),
1605            Some("keychain".to_string()),
1606            None,
1607        ];
1608        assert_eq!(select_proton_askpass(&targets, None), None);
1609    }
1610
1611    #[test]
1612    fn returns_none_when_no_target_uses_proton_and_default_is_not_proton() {
1613        let targets = vec![Some("bw:foo".to_string()), None];
1614        let default = Some("keychain".to_string());
1615        assert_eq!(select_proton_askpass(&targets, default), None);
1616    }
1617
1618    #[test]
1619    fn returns_proton_value_when_one_target_uses_proton() {
1620        let targets = vec![
1621            Some("bw:other".to_string()),
1622            Some("proton:Vault/Item/p".to_string()),
1623            None,
1624        ];
1625        assert_eq!(
1626            select_proton_askpass(&targets, None),
1627            Some("proton:Vault/Item/p".to_string())
1628        );
1629    }
1630
1631    #[test]
1632    fn prefers_first_per_host_proton_value_over_default() {
1633        let targets = vec![
1634            Some("proton:First/Item/p".to_string()),
1635            Some("proton:Second/Item/p".to_string()),
1636        ];
1637        let default = Some("proton:Default/Item/p".to_string());
1638        assert_eq!(
1639            select_proton_askpass(&targets, default),
1640            Some("proton:First/Item/p".to_string())
1641        );
1642    }
1643
1644    #[test]
1645    fn falls_back_to_default_when_no_per_host_proton_value_but_default_is_proton() {
1646        let targets = vec![None, Some("bw:foo".to_string()), None];
1647        let default = Some("proton:Default/Item/p".to_string());
1648        assert_eq!(
1649            select_proton_askpass(&targets, default),
1650            Some("proton:Default/Item/p".to_string())
1651        );
1652    }
1653
1654    #[test]
1655    fn handles_all_proton_targets() {
1656        let targets = vec![
1657            Some("proton:A/x/p".to_string()),
1658            Some("proton:B/y/p".to_string()),
1659        ];
1660        assert_eq!(
1661            select_proton_askpass(&targets, None),
1662            Some("proton:A/x/p".to_string())
1663        );
1664    }
1665
1666    #[test]
1667    fn handles_empty_target_list_with_proton_default() {
1668        let default = Some("proton:Default/Item/p".to_string());
1669        assert_eq!(select_proton_askpass(&[], default), None);
1670    }
1671
1672    #[test]
1673    fn handles_empty_target_list_with_no_default() {
1674        assert_eq!(select_proton_askpass(&[], None), None);
1675    }
1676
1677    #[test]
1678    fn empty_string_askpass_does_not_match_proton() {
1679        let targets = vec![Some(String::new()), Some("bw:foo".to_string())];
1680        assert_eq!(select_proton_askpass(&targets, None), None);
1681    }
1682}