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