Skip to main content

purple_ssh/runtime/
launcher.rs

1// Process bootstrap and the few CLI/TUI entry points that were small enough
2// to live next to fn main while big enough to clutter it. Lives in the lib
3// so integration tests and future entry points (e.g. an embedded TUI) can
4// reach the same surface.
5
6use std::path::Path;
7
8use anyhow::Result;
9use clap::CommandFactory;
10use clap_complete::generate;
11
12use crate::app::App;
13use crate::cli_args::{Cli, Commands, VaultCommands};
14use crate::runtime::helpers::{
15    apply_saved_sort, ensure_bw_session, ensure_keychain_password, ensure_proton_login,
16    ensure_vault_ssh_chain_if_needed, expand_user_path, resolve_config_path,
17};
18use crate::ssh_config::model::SshConfigFile;
19use crate::tui_loop::run_tui;
20use crate::{
21    askpass, cli, connection, demo, history, key_activity, logging, mcp, messages, preferences,
22    providers, snippet, ui, update,
23};
24
25/// Bootstrap the process after `Cli::parse()`. Owns theme init, logging
26/// init, the subcommand dispatch table and the TUI launch path. Returns
27/// when the chosen mode exits (subcommand return, direct-connect exit,
28/// TUI quit).
29pub fn run(cli: Cli) -> Result<()> {
30    // Resolve the process environment once, at the edge. Everything downstream
31    // takes this snapshot rather than reading `std::env` / `dirs::home_dir`.
32    let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
33
34    ui::theme::init(&env);
35
36    // Determine if this is a CLI subcommand (log to stderr too) or TUI (file only)
37    let is_cli_subcommand = cli.command.is_some() || cli.list || cli.connect.is_some();
38    logging::init(cli.verbose, is_cli_subcommand);
39
40    if let Some(ref name) = cli.theme {
41        if let Some(theme) = ui::theme::ThemeDef::find_builtin(name).or_else(|| {
42            ui::theme::ThemeDef::load_custom()
43                .into_iter()
44                .find(|t| t.name.eq_ignore_ascii_case(name))
45        }) {
46            ui::theme::set_theme(theme);
47        } else {
48            anyhow::bail!("Unknown theme: {}", name);
49        }
50    }
51
52    // Shell completions (no config file needed)
53    if let Some(shell) = cli.completions {
54        let mut cmd = Cli::command();
55        generate(shell, &mut cmd, "purple", &mut std::io::stdout());
56        return Ok(());
57    }
58
59    if cli.demo {
60        let mut app = demo::build_demo_app();
61        demo::seed_whats_new_toast(&mut app);
62        demo::seed_tunnel_live_snapshots(&mut app);
63        return run_tui(app);
64    }
65
66    // Provider and Update subcommands don't need SSH config
67    if let Some(Commands::Provider { command }) = cli.command {
68        return cli::handle_provider_command(&env, command);
69    }
70    if let Some(Commands::Update) = cli.command {
71        return update::self_update();
72    }
73    if let Some(Commands::Password { command }) = cli.command {
74        return cli::handle_password_command(&env, command);
75    }
76    if let Some(Commands::Mcp {
77        read_only,
78        no_audit,
79        audit_log,
80    }) = cli.command
81    {
82        let config_path = resolve_config_path(&cli.config)?;
83        let audit_log_path = if no_audit {
84            None
85        } else if let Some(path) = audit_log {
86            Some(expand_user_path(&path)?)
87        } else {
88            mcp::default_audit_log_path()
89        };
90        let options = mcp::McpOptions {
91            read_only,
92            audit_log_path,
93        };
94        return mcp::run(&config_path, options);
95    }
96    if let Some(Commands::Logs { tail, clear }) = cli.command {
97        return cli::handle_logs_command(tail, clear);
98    }
99    if let Some(Commands::Theme { command }) = cli.command {
100        return cli::handle_theme_command(&env, command);
101    }
102    if let Some(Commands::WhatsNew { since }) = &cli.command {
103        let output = cli::run_whats_new(since.as_deref())?;
104        print!("{}", output);
105        return Ok(());
106    }
107
108    let config_path = resolve_config_path(&cli.config)?;
109    let mut config = SshConfigFile::parse(&config_path)?;
110    let repaired_groups = config.repair_absorbed_group_comments();
111    let orphaned_headers = config.remove_all_orphaned_group_headers();
112
113    write_startup_banner(&config, &config_path, cli.verbose, &env);
114
115    // Handle subcommands that need SSH config
116    match cli.command {
117        Some(Commands::Add { target, alias, key }) => {
118            return cli::handle_quick_add(config, &target, alias.as_deref(), key.as_deref());
119        }
120        Some(Commands::Import {
121            file,
122            known_hosts,
123            group,
124        }) => {
125            return cli::handle_import(
126                &env,
127                config,
128                file.as_deref(),
129                known_hosts,
130                group.as_deref(),
131            );
132        }
133        Some(Commands::Sync {
134            provider,
135            dry_run,
136            remove,
137        }) => {
138            return cli::handle_sync(&env, config, provider.as_deref(), dry_run, remove);
139        }
140        Some(Commands::Tunnel { command }) => {
141            return cli::handle_tunnel_command(config, command);
142        }
143        Some(Commands::Snippet { command }) => {
144            return cli::handle_snippet_command(&env, config, command, &config_path);
145        }
146        Some(Commands::Vault {
147            command:
148                VaultCommands::Sign {
149                    alias,
150                    all,
151                    vault_addr: cli_vault_addr,
152                },
153        }) => {
154            return cli::handle_vault_sign_command(&env, config, alias, all, cli_vault_addr);
155        }
156        Some(Commands::Provider { .. })
157        | Some(Commands::Update)
158        | Some(Commands::Password { .. })
159        | Some(Commands::Mcp { .. })
160        | Some(Commands::Theme { .. })
161        | Some(Commands::Logs { .. })
162        | Some(Commands::WhatsNew { .. }) => unreachable!(),
163        None => {}
164    }
165
166    // Direct connect mode (--connect)
167    if let Some(alias) = cli.connect {
168        run_direct_connect(alias, &mut config, &config_path, &env)?;
169    }
170
171    // List mode
172    if cli.list {
173        print_host_list(&config);
174        return Ok(());
175    }
176
177    // Positional argument: exact match → connect, otherwise → TUI with filter
178    if let Some(ref alias) = cli.alias {
179        return run_positional_alias(
180            alias,
181            config,
182            &config_path,
183            repaired_groups,
184            orphaned_headers,
185            env,
186        );
187    }
188
189    // Interactive TUI mode
190    let mut app = App::with_env(config, std::sync::Arc::clone(&env));
191    app.post_init();
192    apply_saved_sort(&mut app);
193    if repaired_groups > 0 || orphaned_headers > 0 {
194        app.notify(messages::config_repaired(repaired_groups, orphaned_headers));
195    }
196    run_tui(app)
197}
198
199/// Collect environment + config metadata and write a startup banner to the
200/// log file. Runs once at process start so support bundles always show
201/// the SSH config path, active providers, askpass sources and Vault
202/// posture under which purple ran.
203fn write_startup_banner(
204    config: &SshConfigFile,
205    config_path: &Path,
206    verbose: bool,
207    env: &crate::runtime::env::Env,
208) {
209    let level_str = logging::level_name(verbose);
210    let provider_config = providers::config::ProviderConfig::load();
211
212    let provider_names: Vec<String> = provider_config
213        .sections
214        .iter()
215        .map(|s| s.provider().to_string())
216        .collect();
217
218    let askpass_sources: Vec<String> = config
219        .host_entries()
220        .iter()
221        .filter_map(|h| h.askpass.as_ref())
222        .map(|s| s.to_string())
223        .collect::<std::collections::BTreeSet<_>>()
224        .into_iter()
225        .collect();
226
227    let vault_ssh_info = {
228        let has_host_level = config.host_entries().iter().any(|h| h.vault_ssh.is_some());
229        let has_provider_level = provider_config
230            .sections
231            .iter()
232            .any(|s| !s.vault_role.is_empty());
233        if has_host_level || has_provider_level {
234            let addr = config
235                .host_entries()
236                .iter()
237                .find_map(|h| h.vault_addr.clone())
238                .or_else(|| {
239                    provider_config
240                        .sections
241                        .iter()
242                        .find(|s| !s.vault_addr.is_empty())
243                        .map(|s| s.vault_addr.clone())
244                })
245                .or_else(|| env.vault_addr().map(str::to_string))
246                .unwrap_or_else(|| "not set".to_string());
247            Some(format!("enabled (addr={addr})"))
248        } else {
249            None
250        }
251    };
252
253    let ssh_version = logging::detect_ssh_version();
254    let term = env.term().unwrap_or("unset").to_string();
255    let colorterm = env.colorterm().unwrap_or("unset").to_string();
256    let theme = preferences::load_theme(env.paths()).unwrap_or_else(|| "Purple".to_string());
257    let hosts = config.host_entries().len();
258    let patterns = config.pattern_entries().len();
259    let snippets = snippet::SnippetStore::load().snippets.len();
260    let proxy_env = collect_proxy_env(env);
261
262    logging::write_banner(&logging::BannerInfo {
263        version: env!("CARGO_PKG_VERSION"),
264        config_path: &config_path.display().to_string(),
265        providers: &provider_names,
266        askpass_sources: &askpass_sources,
267        vault_ssh_info: vault_ssh_info.as_deref(),
268        ssh_version: &ssh_version,
269        term: &term,
270        colorterm: &colorterm,
271        level: &level_str,
272        theme: &theme,
273        hosts,
274        patterns,
275        snippets,
276        proxy_env: &proxy_env,
277    });
278}
279
280/// Build a compact string describing the proxy-related env vars in effect.
281/// Returns `"none"` when none of HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY
282/// are set. Only var names are recorded; values may contain credentials.
283fn collect_proxy_env(env: &crate::runtime::env::Env) -> String {
284    let set = env.active_proxy_vars();
285    if set.is_empty() {
286        "none".to_string()
287    } else {
288        set.join(",")
289    }
290}
291
292/// Direct-connect mode (`purple --connect <alias>`): resolve askpass and
293/// Vault SSH, run `ssh` inline and exit with its status code. Never
294/// returns on success. Always calls `std::process::exit`.
295fn run_direct_connect(
296    alias: String,
297    config: &mut SshConfigFile,
298    config_path: &Path,
299    env: &crate::runtime::env::Env,
300) -> Result<()> {
301    let provider_config = providers::config::ProviderConfig::load();
302    let host_entry = config.host_entries().into_iter().find(|h| h.alias == alias);
303    if host_entry.is_some() {
304        if let Some((msg, _is_error)) =
305            ensure_vault_ssh_chain_if_needed(env, &alias, config_path, &provider_config, config)
306        {
307            eprintln!("{}", msg);
308        }
309    }
310    let askpass = host_entry
311        .as_ref()
312        .and_then(|h| h.askpass.clone())
313        .or_else(|| preferences::load_askpass_default(env.paths()));
314    ensure_proton_login(env, askpass.as_deref());
315    let bw_session = ensure_bw_session(env, None, askpass.as_deref());
316    ensure_keychain_password(env, &alias, askpass.as_deref());
317    let result = connection::connect(
318        &alias,
319        config_path,
320        askpass.as_deref(),
321        bw_session.as_deref(),
322        false,
323    )?;
324    let code = result.status.code().unwrap_or(1);
325    if code != 255 {
326        history::ConnectionHistory::load().record(&alias);
327        key_activity::KeyActivityLog::record_oneshot(&alias, key_activity::now_secs());
328    }
329    askpass::cleanup_marker(&alias);
330    std::process::exit(code);
331}
332
333/// Positional-alias mode (`purple <alias>`): if the alias is an exact
334/// match, connect directly. Otherwise open the TUI with the alias
335/// pre-filled as a search filter.
336fn run_positional_alias(
337    alias: &str,
338    mut config: SshConfigFile,
339    config_path: &Path,
340    repaired_groups: usize,
341    orphaned_headers: usize,
342    env: std::sync::Arc<crate::runtime::env::Env>,
343) -> Result<()> {
344    let host_opt = config
345        .host_entries()
346        .iter()
347        .find(|h| h.alias == *alias)
348        .cloned();
349    if let Some(host) = host_opt {
350        let provider_config = providers::config::ProviderConfig::load();
351        if let Some((msg, _is_error)) = ensure_vault_ssh_chain_if_needed(
352            &env,
353            &host.alias,
354            config_path,
355            &provider_config,
356            &mut config,
357        ) {
358            eprintln!("{}", msg);
359        }
360        let alias = host.alias.clone();
361        let askpass = host
362            .askpass
363            .clone()
364            .or_else(|| preferences::load_askpass_default(env.paths()));
365        ensure_proton_login(&env, askpass.as_deref());
366        let bw_session = ensure_bw_session(&env, None, askpass.as_deref());
367        ensure_keychain_password(&env, &alias, askpass.as_deref());
368        print!("{}", messages::cli::beaming_up(&alias));
369        let result = connection::connect(
370            &alias,
371            config_path,
372            askpass.as_deref(),
373            bw_session.as_deref(),
374            false,
375        )?;
376        let code = result.status.code().unwrap_or(1);
377        if code != 255 {
378            history::ConnectionHistory::load().record(&alias);
379            key_activity::KeyActivityLog::record_oneshot(&alias, key_activity::now_secs());
380        }
381        askpass::cleanup_marker(&alias);
382        std::process::exit(code);
383    }
384
385    // No exact match. Open TUI with search pre-filled.
386    let mut app = App::with_env(config, env);
387    app.post_init();
388    apply_saved_sort(&mut app);
389    if repaired_groups > 0 || orphaned_headers > 0 {
390        app.notify(messages::config_repaired(repaired_groups, orphaned_headers));
391    }
392    app.start_search_with(alias);
393    if app.search.filtered_indices().is_empty() {
394        app.notify(messages::no_exact_match(alias));
395    }
396    run_tui(app)
397}
398
399/// Plain-text host listing for `purple --list`. Prints `alias user@host:port`
400/// rows or the NO_HOSTS marker when the config has no Host blocks.
401fn print_host_list(config: &SshConfigFile) {
402    let entries = config.host_entries();
403    if entries.is_empty() {
404        println!("{}", messages::cli::NO_HOSTS);
405        return;
406    }
407    for host in &entries {
408        let user = if host.user.is_empty() {
409            String::new()
410        } else {
411            format!("{}@", host.user)
412        };
413        let port = if host.port == 22 {
414            String::new()
415        } else {
416            format!(":{}", host.port)
417        };
418        println!("{:<20} {}{}{}", host.alias, user, host.hostname, port);
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    // Builds each scenario as an injected Env. No process-global mutation, so
427    // no lock and no serialization against other tests.
428    #[test]
429    fn collect_proxy_env_reports_set_vars_and_none() {
430        use crate::runtime::env::Env;
431
432        assert_eq!(collect_proxy_env(&Env::for_test("/tmp/x")), "none");
433
434        let one = Env::for_test("/tmp/x").with_var("HTTPS_PROXY", "http://proxy.example:3128");
435        assert_eq!(collect_proxy_env(&one), "HTTPS_PROXY");
436
437        let two = one.clone().with_var("NO_PROXY", "localhost,127.0.0.1");
438        assert_eq!(collect_proxy_env(&two), "HTTPS_PROXY,NO_PROXY");
439
440        // Empty value counts as unset.
441        let empty_https = two.with_var("HTTPS_PROXY", "");
442        assert_eq!(collect_proxy_env(&empty_https), "NO_PROXY");
443    }
444}