Skip to main content

purple_ssh/
cli.rs

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