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