mise 2026.8.14

Dev tools, env vars, and tasks in one CLI
#![allow(unknown_lints)]
#![deny(dead_code_pub_in_binary, unreachable_pub)]
// eyre 0.6.12 emits a trailing semicolon from bail!, which nightly rejects.
#![allow(semicolon_in_expressions_from_macros)]

use std::{
    panic,
    process::ExitCode,
    sync::atomic::{AtomicBool, Ordering},
    time::Duration,
};

use crate::cli::Cli;
use crate::cli::version::VERSION;
use color_eyre::{Section, SectionExt};
use eyre::Report;
use indoc::indoc;

#[cfg(test)]
#[macro_use]
mod test;

#[macro_use]
mod output;

#[macro_use]
mod hint;

#[macro_use]
mod timings;

#[macro_use]
mod cmd;

mod agecrypt;
mod aqua;
mod backend;
pub(crate) mod build_time;
mod cache;
mod cli;
mod config;
mod deps;
pub(crate) mod deps_graph;
mod direnv;
mod dirs;
pub(crate) mod duration;
mod env;
mod env_diff;
mod errors;
mod exit;
#[cfg_attr(windows, path = "fake_asdf_windows.rs")]
mod fake_asdf;
mod file;
pub(crate) mod forgejo;
mod fuzzy;
mod git;
pub(crate) mod github;
pub(crate) mod gitlab;
mod gpg;
mod hash;
mod hook_env;
mod hooks;
mod http;
mod install_before;
mod install_context;
mod jobs;
mod lock_file;
mod lockfile;
pub(crate) mod logger;
pub(crate) mod maplit;
mod migrate;
mod minisign;
mod netrc;
mod oci;
pub(crate) mod parallel;
mod path;
mod path_env;
mod platform;
mod plugins;
mod rand;
mod redactions;
mod registry;
mod remote_source;
pub(crate) mod result;
mod runtime_symlinks;
mod sandbox;
mod semver;
mod shell;
mod shims;
mod shorthands;
mod sops;
mod sysconfig;
mod system;
pub(crate) mod task;
pub(crate) mod tera;
pub(crate) mod timeout;
mod tokens;
mod toml;
mod toolset;
mod ui;
mod uv;
mod versions_host;
mod watch_files;
mod wildcard;

pub(crate) use crate::exit::request as request_exit;
pub(crate) use crate::result::Result;
use crate::ui::multi_progress_report::MultiProgressReport;

fn main() -> ExitCode {
    // Cargo invokes the Rust cache wrapper hundreds or thousands of times per
    // build. Dispatch on argv0 before runtime, logging, clap, or config startup.
    if cache::session::is_rustc_shim() {
        return cache::session::run_rustc_shim();
    }
    // Same reason, different caller: `self-replace` spawns a copy of this binary under a generated
    // name to finish an update, and when its own init hook does not intercept that, mise would run
    // its shim path and report the generated name as a broken shim. There is nothing for `main` to
    // do here — the copy exists to be deleted — so leave before anything else starts.
    #[cfg(windows)]
    if env::invoked_as_self_replace_helper() {
        return ExitCode::SUCCESS;
    }
    // Embedded aube lifecycle shims re-exec this binary with private commands
    // (notably `__node-gyp-bootstrap`). Hand those to aube before mise's
    // naked-run rewrite / clap parser can claim them.
    let early_args = env::args_safe();
    if let Some(code) = backend::aube_host::try_run_embedded_cli(&early_args) {
        return exit::status(code);
    }
    let nprocs = std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or_default();
    // Tokio spawns every worker thread eagerly when the runtime is built, so
    // the default worker count is startup cost paid by every invocation —
    // clone + stack + TLS per thread before any work happens. Async I/O
    // doesn't need a worker per core (blocking work uses tokio's separate
    // on-demand pool), so cap the default on many-core machines. An explicit
    // MISE_JOBS still raises it without limit.
    let threads = crate::env::MISE_JOBS
        .unwrap_or_else(|| nprocs.min(16))
        .max(8);
    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .worker_threads(threads)
        .build()
    {
        Ok(runtime) => runtime,
        Err(err) => {
            eprintln!("Error: {err:?}");
            return exit::status(1);
        }
    };
    let result = runtime.block_on(main_());
    let (code, requested_exit) = match result {
        Ok(()) => (0, false),
        Err(err) => match exit::requested_exit_code(&err) {
            Some(code) => {
                exit::kill_all();
                (code, true)
            }
            None => {
                eprintln!("Error: {err:?}");
                (1, false)
            }
        },
    };
    if requested_exit {
        // Blocking tasks cannot be cancelled and may ignore the signals sent
        // above. Bound the wait so an intentional exit cannot hang forever.
        runtime.shutdown_timeout(Duration::from_secs(1));
    } else {
        drop(runtime);
    }
    if code == 0 {
        ExitCode::SUCCESS
    } else {
        exit::status(code)
    }
}

async fn main_() -> eyre::Result<()> {
    // Configure color-eyre based on color preferences
    let hook_builder = if *env::CLICOLOR == Some(false) {
        // Use blank theme (no colors) when colors are disabled
        color_eyre::config::HookBuilder::new().theme(color_eyre::config::Theme::new())
    } else {
        color_eyre::config::HookBuilder::default()
    };
    let (panic_hook, eyre_hook) = hook_builder.into_hooks();
    eyre_hook.install()?;
    install_panic_hook(panic_hook);
    measure!("main", {
        let args = env::args_safe();
        match Cli::run(&args)
            .await
            .with_section(|| VERSION.to_string().header("Version:"))
        {
            Ok(()) => Ok(()),
            Err(err) => handle_err(err),
        }?;
    });
    if let Some(mpr) = MultiProgressReport::try_get() {
        mpr.stop()?;
    }
    Ok(())
}

fn handle_err(err: Report) -> eyre::Result<()> {
    // Startup queues warnings found before the logger exists and flushes them once CLI flags are
    // known. Half a dozen steps sit between those two points and any of them can fail — a bad flag,
    // an unusable `--cd`, an untrusted config — so this is where a queued diagnostic gets its last
    // chance to be seen. Before the exit-code check: a requested exit leaves just as finally.
    //
    // Re-init the logger first so the flush below runs at whatever level is knowable now.
    // `auto_update` and `trust_active_config` fail *after* `add_cli_matches` but before startup
    // reaches its second `logger::init()`, so without this `--quiet` would not reach them. It
    // cannot help a command whose flags never parsed; `MISE_QUIET` still can, since that is read
    // from the environment during the first settings build. If the settings cannot be built at all,
    // `init` leaves the level where it is rather than resetting it to the default.
    crate::logger::init();
    crate::config::Settings::flush_pending_warnings_before_exit();
    if exit::requested_exit_code(&err).is_some() {
        return Err(err);
    }
    if let Some(err) = err.downcast_ref::<std::io::Error>()
        && err.kind() == std::io::ErrorKind::BrokenPipe
    {
        return Ok(());
    }
    if is_interrupted_io_error(&err) {
        stop_multi_progress();
        return Err(request_exit(130));
    }

    // Check for miette diagnostic errors and render them specially
    if let Some(diagnostic) = err.downcast_ref::<config::config_file::diagnostic::MiseDiagnostic>()
    {
        safe_eprintln!("{}", diagnostic.render());
        return Err(request_exit(1));
    }

    show_github_rate_limit_err(&err);
    if *env::MISE_FRIENDLY_ERROR {
        display_friendly_err(&err);
        return Err(request_exit(1));
    }
    let async_backtrace = async_backtrace::taskdump_tree(true);
    Err(err.section(async_backtrace.header("Async Tasks")))
}

fn show_github_rate_limit_err(err: &Report) {
    let msg = format!("{err:?}");
    if msg.contains("HTTP status client error (403 Forbidden) for url (https://api.github.com") {
        warn!(
            "GitHub API returned a 403 Forbidden error. This is most commonly caused by exceeding the rate limit, though other causes (e.g. insufficient token permissions) are possible."
        );
        if github::resolve_token("github.com").is_none() {
            warn!(indoc!(
                r#"No GitHub token was found, so mise is making unauthenticated requests to GitHub which have a much lower rate limit.
                   Create a token at https://github.com/settings/tokens (no scopes required) and set it as GITHUB_TOKEN in your environment.
                   See https://mise.jdx.dev/dev-tools/github-tokens.html for all supported token sources (env vars, gh CLI, credential_command, etc.)."#
            ));
        }
    }
}

fn display_friendly_err(err: &Report) {
    for err in err.chain() {
        error!("{err}");
    }
    error!("Version: {}", *VERSION);
    let msg = ui::style::edim("Run with --verbose or MISE_VERBOSE=1 for more information");
    error!("{msg}");
}

fn is_interrupted_io_error(err: &Report) -> bool {
    err.chain().any(|cause| {
        cause
            .downcast_ref::<std::io::Error>()
            .is_some_and(|e| e.kind() == std::io::ErrorKind::Interrupted)
    })
}

fn stop_multi_progress() {
    if let Some(mpr) = MultiProgressReport::try_get() {
        let _ = mpr.stop();
    }
}

static ASYNC_PANIC_OCCURRED: AtomicBool = AtomicBool::new(false);

fn install_panic_hook(panic_hook: color_eyre::config::PanicHook) {
    panic::set_hook(Box::new(move |panic_info| {
        // Serious release builds abort after this hook returns, so destructors
        // and catch_unwind cleanup will not run. Terminate registered child
        // process trees synchronously while we still can.
        #[cfg(panic = "abort")]
        cmd::kill_all_on_panic();

        if tokio::runtime::Handle::try_current().is_ok()
            && !ASYNC_PANIC_OCCURRED.swap(true, Ordering::SeqCst)
        {
            let bt = async_backtrace::backtrace();
            let mut bt_buffer = String::new();
            if let Some(bt) = bt {
                let locations = &*bt;
                for (index, loc) in locations.iter().enumerate() {
                    bt_buffer.push_str(&format!("{index:3}: {loc:?}\n"));
                }
            } else {
                bt_buffer.push_str("[no accessible async backtrace]");
            }
            // An aborting panic cannot wait for every running task to reach a
            // frame boundary: some may be blocked on the panicking task, and
            // the process must return from this hook to reach abort.
            let all = async_backtrace::taskdump_tree(cfg!(panic = "unwind"));
            // A panic hook must never panic: a panic while the hook runs
            // aborts the process with SIGABRT.
            safe_eprintln!(
                "=== Async Backtrace (panic occurred in tokio runtime) ===\n\
                {bt_buffer}\n\
                ------- TASK DUMP TREE -------\n\
                {all}\n\
                === End Async Backtrace ===\n"
            );
        }

        // color_eyre's own panic hook prints its report with eprintln!, which
        // panics when stderr is unwritable — render the report ourselves
        // instead of chaining to it
        safe_eprintln!("{}", panic_hook.panic_report(panic_info));
    }));
}

#[cfg(test)]
mod tests {
    use super::*;
    use eyre::eyre;

    #[test]
    fn detects_interrupted_io_error() {
        assert!(is_interrupted_io_error(&eyre!(std::io::Error::new(
            std::io::ErrorKind::Interrupted,
            "user cancelled"
        ))));
        assert!(!is_interrupted_io_error(&eyre!(std::io::Error::other(
            "user cancelled"
        ))));
    }
}