Skip to main content

easy_install/
lib.rs

1mod artifact;
2mod ci;
3mod config;
4mod download;
5mod env;
6pub mod error;
7mod install;
8mod manfiest;
9mod optimize;
10mod tool;
11mod types;
12
13use crate::error::{err, Result};
14use crate::tool::expand_path;
15#[cfg(windows)]
16use crate::error::Context;
17use clap::{ArgAction, CommandFactory, Parser, Subcommand};
18use config::PersistentConfig;
19use github_proxy::Proxy;
20use guess_target::Target;
21use tool::add_output_to_path;
22
23#[derive(Debug, Clone)]
24pub struct InstallConfig {
25    pub dir: Option<String>,
26    pub name: Vec<String>,
27    pub alias: Option<String>,
28    pub target: Option<Target>,
29    pub retry: usize,
30    pub proxy: Proxy,
31    pub timeout: u64,
32    pub strip: bool,
33    pub upx: bool,
34    pub quiet: bool,
35    pub no_path: bool,
36    pub fuzzy: bool,
37    pub regex: Option<String>,
38}
39
40impl Default for InstallConfig {
41    fn default() -> Self {
42        Self {
43            dir: None,
44            name: Vec::new(),
45            alias: None,
46            target: None,
47            retry: 3,
48            proxy: Proxy::Github,
49            timeout: 600,
50            strip: false,
51            upx: false,
52            quiet: false,
53            no_path: false,
54            fuzzy: false,
55            regex: None,
56        }
57    }
58}
59
60impl InstallConfig {
61    /// Load configuration from persistent config file
62    /// Returns InstallConfig with values from config file, or defaults if not set
63    pub fn load() -> Self {
64        let persistent_config = PersistentConfig::load();
65
66        Self {
67            dir: persistent_config.dir,
68            name: Vec::new(),
69            alias: None,
70            target: persistent_config.target,
71            retry: persistent_config.retry.unwrap_or(3) as usize,
72            proxy: persistent_config.proxy.unwrap_or(Proxy::Github),
73            timeout: persistent_config.timeout.unwrap_or(600),
74            strip: persistent_config.strip.unwrap_or(false),
75            upx: persistent_config.upx.unwrap_or(false),
76            quiet: false,
77            no_path: false,
78            fuzzy: false,
79            regex: None,
80        }
81    }
82
83    pub fn get_local_target(&self) -> Vec<Target> {
84        if let Some(t) = self.target {
85            return vec![t];
86        }
87        guess_target::get_local_target().to_vec()
88    }
89}
90
91#[derive(Debug, Clone, Subcommand)]
92pub enum ConfigSubcommand {
93    /// View or set proxy configuration
94    Proxy {
95        /// Proxy value to set (omit to view current value)
96        value: Option<Proxy>,
97    },
98    /// View or set installation directory
99    Dir {
100        /// Directory path to set (omit to view current value)
101        value: Option<String>,
102    },
103    /// View or set target platform
104    Target {
105        /// Target platform to set (omit to view current value)
106        value: Option<Target>,
107    },
108    /// View or set network timeout in seconds
109    Timeout {
110        /// Timeout in seconds (omit to view current value)
111        value: Option<u64>,
112    },
113    /// View or set retry count
114    Retry {
115        /// Number of retries (omit to view current value)
116        value: Option<u64>,
117    },
118    /// View or set UPX compression
119    Upx {
120        /// Enable or disable UPX compression (omit to view current value)
121        value: Option<bool>,
122    },
123    /// View or set strip debug symbols
124    Strip {
125        /// Enable or disable stripping debug symbols (omit to view current value)
126        value: Option<bool>,
127    },
128}
129
130#[derive(Debug, Clone, Subcommand)]
131pub enum Command {
132    /// Manage configuration settings
133    Config {
134        #[command(subcommand)]
135        subcmd: Option<ConfigSubcommand>,
136    },
137    /// Generate shell completion scripts
138    Completions {
139        /// Shell type to generate completions for
140        #[arg(value_enum)]
141        shell: clap_complete::Shell,
142    },
143    /// Upgrade crash to the latest version
144    Upgrade,
145}
146
147const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
148const GIT_HASH: &str = git_version::git_version!(fallback = "unknown");
149const BUILD_TARGET: &str = env!("BUILD_TARGET");
150const VERSION: &str = const_str::concat!(CARGO_PKG_VERSION, " ", GIT_HASH, " ", BUILD_TARGET);
151
152#[derive(Parser, Debug, Clone)]
153#[command(name="ei", version=VERSION, about, long_about)]
154pub struct Args {
155    #[command(subcommand)]
156    pub cmd: Option<Command>,
157
158    /// GitHub repo (owner/repo), release URL, or artifact URL
159    #[arg(default_value_t = String::new())]
160    pub url: String,
161
162    /// Installation directory for downloaded binaries
163    #[arg(short, long, help = "Installation directory")]
164    pub dir: Option<String>,
165
166    /// Skip adding installed binaries to PATH
167    #[arg(long, global = true, default_value_t = false, action = ArgAction::SetTrue, help = "Skip adding installed binaries to PATH")]
168    pub no_path: bool,
169
170    /// Filter artifacts by name (comma-separated, word-boundary match)
171    ///
172    /// Matches filenames whose stem starts with the given name followed
173    /// by a separator (`-`, `_`, `.`) or end-of-string. This prevents
174    /// e.g. `--name qjs` from matching `qjsc-linux-x86`.
175    #[arg(
176        long,
177        value_delimiter = ',',
178        help = "Filter artifacts by name (comma-separated, word-boundary match)"
179    )]
180    pub name: Vec<String>,
181
182    /// Rename the installed binary
183    #[arg(long, help = "Rename the installed binary")]
184    pub alias: Option<String>,
185
186    /// Target platform (e.g., x86_64-unknown-linux-gnu)
187    #[arg(long, help = "Target platform (auto-detected if not specified)")]
188    pub target: Option<Target>,
189
190    /// Number of retry attempts for failed downloads
191    #[arg(
192        long,
193        global = true,
194        default_value_t = 3,
195        help = "Number of retry attempts"
196    )]
197    pub retry: usize,
198
199    /// GitHub proxy to use (github, ghproxy, etc.)
200    #[arg(long, global = true, help = "GitHub proxy to use")]
201    pub proxy: Option<Proxy>,
202
203    /// Network request timeout in seconds
204    #[arg(long, global = true, help = "Network request timeout in seconds")]
205    pub timeout: Option<u64>,
206
207    /// Strip debug symbols from executable
208    #[arg(
209        long,
210        global = true,
211        help = "Strip debug symbols from executable",
212        default_missing_value = "true",
213        num_args = 0..=1,
214    )]
215    pub strip: Option<bool>,
216
217    /// Compress executable with UPX
218    #[arg(
219        long,
220        global = true,
221        help = "Compress executable with UPX",
222        default_missing_value = "true",
223        num_args = 0..=1,
224    )]
225    pub upx: Option<bool>,
226
227    /// Suppress all output messages
228    #[arg(
229        long,
230        short,
231        global = true,
232        help = "Suppress all output messages",
233        default_missing_value = "true",
234        num_args = 0..=1,
235        default_value_t = false
236    )]
237    pub quiet: bool,
238
239    /// Use fuzzy target matching (match arch+os, ignoring abi)
240    ///
241    /// By default ei requires an exact target triple match (including the
242    /// abi, e.g. gnu vs musl). Enable this to fall back to a (arch, os)
243    /// match when no exact match is found, so assets whose filenames omit
244    /// the abi (e.g. "mihomo-linux-amd64.tar.gz" parsed as gnu) can still
245    /// be selected when you requested musl.
246    #[arg(
247        long,
248        help = "Use fuzzy target matching (match arch+os, ignoring abi)",
249        default_missing_value = "true",
250        num_args = 0..=1,
251        default_value_t = false
252    )]
253    pub fuzzy: bool,
254
255    /// Regex pattern to match against the original GitHub asset filenames
256    ///
257    /// When supplied, the regex is matched directly against each asset's
258    /// original filename (e.g. "mpv-v0.41.0-dev-g4c220ffd9-x86_64-pc-windows-msvc.zip"),
259    /// bypassing `guess_target` entirely. This gives you full control over
260    /// which asset to select when filenames use non-standard naming that
261    /// confuses automatic target detection.
262    ///
263    /// The regex must match exactly one asset; matching zero or multiple
264    /// is an error. Examples:
265    ///   --regex "x86_64-pc-windows-msvc"        (match a specific triple)
266    ///   --regex "macos-15-arm"                   (match a macOS variant)
267    ///   --regex "aarch64-pc-windows-msvc\\.zip$" (anchor to file extension)
268    #[arg(
269        long,
270        help = "Regex to match asset filenames directly (bypasses guess_target)"
271    )]
272    pub regex: Option<String>,
273}
274
275impl Default for Args {
276    fn default() -> Self {
277        Self {
278            cmd: None,
279            url: "".to_string(),
280            dir: None,
281            no_path: false,
282            name: vec![],
283            alias: None,
284            target: None,
285            retry: 3,
286            proxy: None,
287            timeout: None,
288            strip: None,
289            upx: None,
290            quiet: false,
291            fuzzy: false,
292            regex: None,
293        }
294    }
295}
296
297impl From<Args> for InstallConfig {
298    fn from(value: Args) -> Self {
299        let persistent_config = PersistentConfig::load();
300
301        let proxy = value
302            .proxy
303            .or(persistent_config.proxy)
304            .unwrap_or(Proxy::Github);
305
306        let timeout = value.timeout.or(persistent_config.timeout).unwrap_or(600);
307
308        let dir = value.dir.clone().or(persistent_config.dir);
309
310        let target = value.target.or(persistent_config.target);
311        let strip = value.strip.or(persistent_config.strip).unwrap_or(false);
312        let upx = value.upx.or(persistent_config.upx).unwrap_or(false);
313
314        InstallConfig {
315            dir,
316            name: value.name.clone(),
317            alias: value.alias.clone(),
318            target,
319            retry: value.retry,
320            proxy,
321            timeout,
322            strip,
323            upx,
324            quiet: value.quiet,
325            no_path: value.no_path,
326            fuzzy: value.fuzzy,
327            regex: value.regex,
328        }
329    }
330}
331
332pub async fn run_main(args: Args) -> Result<()> {
333    // Handle completions subcommand
334    if let Some(Command::Completions { shell }) = args.cmd {
335        return handle_completions_command(shell);
336    }
337
338    if let Some(Command::Upgrade) = args.cmd {
339        return handle_upgrade(args.into()).await;
340    }
341
342    // Handle config subcommand
343    if let Some(Command::Config { subcmd }) = args.cmd {
344        let quiet = args.quiet;
345        return handle_config_command(subcmd, quiet);
346    }
347
348    // Regular install command
349    let url = args.url.clone();
350
351    if url.is_empty() {
352        let s = Args::command().render_help();
353        println!("{s}");
354        return Ok(());
355    }
356    let config = args.into();
357    ei(&url, &config).await?;
358    Ok(())
359}
360
361pub async fn ei(url: &str, config: &InstallConfig) -> Result<()> {
362    let output = install::install(url, config).await?;
363    if !config.no_path {
364        add_output_to_path(&output, config);
365    }
366    if output.is_empty() && !config.quiet {
367        println!("No file installed from {url}");
368    }
369    Ok(())
370}
371
372fn handle_completions_command(shell: clap_complete::Shell) -> Result<()> {
373    use clap_complete::generate;
374    use std::io;
375
376    let mut cmd = Args::command();
377    let bin_name = cmd.get_name().to_string();
378
379    generate(shell, &mut cmd, bin_name, &mut io::stdout());
380    Ok(())
381}
382
383async fn handle_upgrade(user_config: InstallConfig) -> Result<()> {
384    let exe = std::env::current_exe()?;
385    let dir = exe
386        .parent()
387        .ok_or_else(|| err!("ei dir not found"))?;
388
389    // Merge user CLI options (--no-path, --quiet, --proxy, etc.) with
390    // upgrade-specific overrides: dir = exe directory, alias = "ei".
391    let config = InstallConfig {
392        dir: Some(dir.to_string_lossy().to_string()),
393        alias: Some("ei".to_string()),
394        ..user_config
395    };
396
397    #[cfg(windows)]
398    {
399        // Windows locks running executables — they can be renamed but not
400        // overwritten. Rename the old binary first, then install the new
401        // one. Rollback on failure so the user isn't left without ei.
402        let old_exe = exe.with_extension("exe.old");
403        let _ = std::fs::remove_file(&old_exe);
404        std::fs::rename(&exe, &old_exe).context("Failed to rename running ei.exe for upgrade")?;
405
406        match ei("easy-install/easy-install", &config).await {
407            Ok(()) => {
408                let _ = std::fs::remove_file(&old_exe);
409                Ok(())
410            }
411            Err(e) => {
412                let _ = std::fs::remove_file(&exe);
413                std::fs::rename(&old_exe, &exe)?;
414                Err(e)
415            }
416        }
417    }
418
419    #[cfg(not(windows))]
420    {
421        ei("easy-install/easy-install", &config).await
422    }
423}
424
425fn handle_config_command(subcmd: Option<ConfigSubcommand>, quiet: bool) -> Result<()> {
426    let mut config = PersistentConfig::load();
427
428    let Some(subcmd) = subcmd else {
429        if !quiet {
430            config.display();
431        }
432        return Ok(());
433    };
434
435    match subcmd {
436        ConfigSubcommand::Proxy { value } => {
437            let current = config
438                .proxy
439                .map_or("not set (default: Github)".to_string(), |p| {
440                    format!("{p:?}")
441                });
442            apply_config(
443                &mut config,
444                quiet,
445                value,
446                PersistentConfig::set_proxy,
447                |v| format!("{v:?}"),
448                "Proxy",
449                current,
450            )?
451        }
452        ConfigSubcommand::Dir { value } => {
453            let current = config.dir.as_deref().unwrap_or("not set").to_string();
454            apply_config(
455                &mut config,
456                quiet,
457                value,
458                |c, v| c.set_dir(expand_path(&v)),
459                |v: &String| v.to_string(),
460                "Directory",
461                current,
462            )?
463        }
464        ConfigSubcommand::Target { value } => {
465            let current = config
466                .target
467                .map_or("not set (auto-detect)".to_string(), |t| {
468                    t.to_str().to_string()
469                });
470            apply_config(
471                &mut config,
472                quiet,
473                value,
474                PersistentConfig::set_target,
475                |v| v.to_str().to_string(),
476                "Target",
477                current,
478            )?
479        }
480        ConfigSubcommand::Timeout { value } => {
481            let current = config
482                .timeout
483                .map_or("not set (default: 600 seconds)".to_string(), |t| {
484                    format!("{t} seconds")
485                });
486            apply_config(
487                &mut config,
488                quiet,
489                value,
490                PersistentConfig::set_timeout,
491                |v: &u64| v.to_string(),
492                "Timeout",
493                current,
494            )?
495        }
496        ConfigSubcommand::Retry { value } => {
497            let current = config
498                .retry
499                .map_or("not set (default: 3)".to_string(), |t| format!("{t}"));
500            apply_config(
501                &mut config,
502                quiet,
503                value,
504                PersistentConfig::set_retry,
505                |v: &u64| v.to_string(),
506                "Retry",
507                current,
508            )?
509        }
510        ConfigSubcommand::Upx { value } => {
511            let current = config
512                .upx
513                .map_or("not set (default: false)".to_string(), |t| format!("{t}"));
514            apply_config(
515                &mut config,
516                quiet,
517                value,
518                PersistentConfig::set_upx,
519                |v: &bool| v.to_string(),
520                "Upx",
521                current,
522            )?
523        }
524        ConfigSubcommand::Strip { value } => {
525            let current = config
526                .strip
527                .map_or("not set (default: false)".to_string(), |t| format!("{t}"));
528            apply_config(
529                &mut config,
530                quiet,
531                value,
532                PersistentConfig::set_strip,
533                |v: &bool| v.to_string(),
534                "Strip",
535                current,
536            )?
537        }
538    }
539
540    Ok(())
541}
542
543/// Generic set-or-show helper for `config` subcommands: if a value is
544/// provided, apply it, persist, and confirm; otherwise print the current
545/// value.
546fn apply_config<T>(
547    config: &mut PersistentConfig,
548    quiet: bool,
549    value: Option<T>,
550    setter: impl FnOnce(&mut PersistentConfig, T),
551    fmt_val: impl Fn(&T) -> String,
552    label: &str,
553    current: String,
554) -> Result<()> {
555    if let Some(v) = value {
556        let msg = format!("{label} set to: {}", fmt_val(&v));
557        setter(config, v);
558        config.save_quiet(quiet)?;
559        if !quiet {
560            println!("{msg}");
561        }
562    } else if !quiet {
563        println!("Current {label}: {current}");
564    }
565    Ok(())
566}