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