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