diff --git a/build.rs b/build.rs index 86ad53a15c..6261f792b2 100644 --- a/build.rs +++ b/build.rs @@ -414,5 +414,51 @@ pub static SETTINGS_META: Lazy> = Lazy::new .to_string(), ); + // Generate MisercSettings struct for early initialization settings + lines.push( + r#" +/// Settings that can be set in .miserc.toml for early initialization. +/// These settings affect config file discovery and must be loaded before +/// the main config files are parsed. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct MisercSettings {"# + .to_string(), + ); + + for (key, props) in &settings { + let props = props.as_table().unwrap(); + // Only include settings with rc = true + if props + .get("rc") + .is_some_and(|v| v.as_bool().unwrap_or(false)) + { + if let Some(description) = props.get("description") { + lines.push(format!(" /// {}", description.as_str().unwrap())); + } + let type_ = props + .get("rust_type") + .map(|rt| rt.as_str().unwrap()) + .or(props.get("type").map(|t| match t.as_str().unwrap() { + "Bool" => "bool", + "String" => "String", + "Integer" => "i64", + "Url" => "String", + "Path" => "PathBuf", + "Duration" => "String", + "ListString" => "Vec", + "ListPath" => "Vec", + "SetString" => "BTreeSet", + "IndexMap" => "IndexMap", + t => panic!("Unknown type: {t}"), + })); + if let Some(type_) = type_ { + // All miserc settings are optional + let type_ = format!("Option<{type_}>"); + lines.push(format!(" pub {key}: {type_},")); + } + } + } + lines.push("}".to_string()); + fs::write(&dest_path, lines.join("\n")).unwrap(); } diff --git a/docs/configuration/environments.md b/docs/configuration/environments.md index 039b8e9834..22e98bb410 100644 --- a/docs/configuration/environments.md +++ b/docs/configuration/environments.md @@ -1,9 +1,35 @@ # Config Environments It's possible to have separate `mise.toml` files in the same directory for different -environments like `development` and `production`. To enable, either set the `-E,--env` option or `MISE_ENV` environment -variable to an environment like `development` or `production`. mise will then look for a `mise.{MISE_ENV}.toml` file -in the current directory, parent directories and the `MISE_CONFIG_DIR` directory. +environments like `development` and `production`. To enable, set `MISE_ENV` to an +environment like `development` or `production` using one of these methods: + +- CLI flag: `-E development` or `--env development` +- Environment variable: `MISE_ENV=development` +- `.miserc.toml` file: `env = ["development"]` + +mise will then look for a `mise.{MISE_ENV}.toml` file in the current directory, +parent directories and the `MISE_CONFIG_DIR` directory. + +## Setting MISE_ENV in .miserc.toml + +You can set `MISE_ENV` in a `.miserc.toml` file, which is loaded very early before +other config files are discovered. This allows you to commit your environment +configuration to version control: + +```toml +# .miserc.toml +env = ["development"] +``` + +File locations searched (in order of precedence): + +1. `.miserc.toml` in current directory and parent directories +2. `~/.config/mise/miserc.toml` (global) +3. `/etc/mise/miserc.toml` (system) + +Note: `MISE_ENV` cannot be set in `mise.toml` because it determines which config +files to load in the first place. mise will also look for "local" files like `mise.local.toml` and `mise.{MISE_ENV}.local.toml` in the current directory and parent directories. diff --git a/e2e/config/test_miserc b/e2e/config/test_miserc new file mode 100644 index 0000000000..5f8105fd28 --- /dev/null +++ b/e2e/config/test_miserc @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2209 + +# Test that .miserc.toml can set MISE_ENV + +echo "tools.dummy = '1'" >mise.toml +echo "tools.dummy = '2'" >mise.test.toml +echo 'env = ["test"]' >.miserc.toml + +# .miserc.toml should set MISE_ENV=test, loading mise.test.toml +assert "mise ls dummy" "dummy 2.0.0 (missing) ~/workdir/mise.test.toml 2" + +# Env var should override .miserc.toml +MISE_ENV=ci assert "mise ls dummy" "dummy 1.1.0 (missing) ~/workdir/mise.toml 1" + +# Test ceiling_paths in .miserc.toml - subdirectory should pick up parent miserc +rm -f .miserc.toml +echo 'env = ["test"]' >.miserc.toml +mkdir -p subdir +cd subdir || exit 1 + +# Should still pick up parent .miserc.toml and mise.test.toml +assert "mise ls dummy" "dummy 2.0.0 (missing) ~/workdir/mise.test.toml 2" + +cd .. || exit 1 diff --git a/schema/mise-settings.json b/schema/mise-settings.json index f2abcbc276..f9eae87dbc 100644 --- a/schema/mise-settings.json +++ b/schema/mise-settings.json @@ -99,6 +99,10 @@ ], "description": "How to parse the environment variable value" }, + "rc": { + "type": "boolean", + "description": "Whether this setting can be set in .miserc.toml for early initialization" + }, "rust_type": { "type": "string", "description": "Rust type used internally for this setting" diff --git a/schema/mise.json b/schema/mise.json index 1849091949..974ce25a87 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -489,6 +489,14 @@ "description": "Path to change to after launching mise", "type": "string" }, + "ceiling_paths": { + "default": [], + "description": "Directories where mise stops searching for config files.", + "type": "array", + "items": { + "type": "string" + } + }, "ci": { "default": "false", "description": "Set to true if running in a CI environment", @@ -855,7 +863,7 @@ }, "override_config_filenames": { "default": [], - "description": "If set, mise will ignore default config files like `mise.toml` and use these filenames instead. This must be an env var.", + "description": "If set, mise will ignore default config files like `mise.toml` and use these filenames instead.", "type": "array", "items": { "type": "string" @@ -863,7 +871,7 @@ }, "override_tool_versions_filenames": { "default": [], - "description": "If set, mise will ignore .tool-versions files and use these filenames instead. Can be set to `none` to disable .tool-versions. This must be an env var.", + "description": "If set, mise will ignore .tool-versions files and use these filenames instead. Can be set to `none` to disable .tool-versions.", "type": "array", "items": { "type": "string" diff --git a/schema/miserc.json b/schema/miserc.json new file mode 100644 index 0000000000..e83aa27cac --- /dev/null +++ b/schema/miserc.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "mise rc config", + "description": "Early initialization settings for mise. These settings are loaded before the main config files.", + "type": "object", + "additionalProperties": false, + "properties": { + "ceiling_paths": { + "default": [], + "description": "Directories where mise stops searching for config files.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "default": [], + "description": "Env to use for mise..toml files.", + "type": "array", + "items": { + "type": "string" + } + }, + "ignored_config_paths": { + "default": [], + "description": "This is a list of config paths that mise will ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "override_config_filenames": { + "default": [], + "description": "If set, mise will ignore default config files like `mise.toml` and use these filenames instead.", + "type": "array", + "items": { + "type": "string" + } + }, + "override_tool_versions_filenames": { + "default": [], + "description": "If set, mise will ignore .tool-versions files and use these filenames instead. Can be set to `none` to disable .tool-versions.", + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/settings.toml b/settings.toml index 108b130109..6f5f9b3200 100644 --- a/settings.toml +++ b/settings.toml @@ -233,6 +233,27 @@ hide = true optional = true type = "Path" +[ceiling_paths] +default = [] +description = "Directories where mise stops searching for config files." +docs = """ +Directories where mise stops searching for config files. By default, mise +will search from the current directory up to the root of the filesystem. + +Setting this to a list of directories will stop the search when one of +those directories is reached. This is useful to prevent mise from searching +outside of a project directory. + +This is an early-init setting: it must be set in `.miserc.toml`, environment +variables, or CLI flags. Setting it in `mise.toml` will have no effect because +config file discovery has already occurred by the time `mise.toml` is read. +""" +env = "MISE_CEILING_PATHS" +parse_env = "list_by_colon" +rc = true +rust_type = "BTreeSet" +type = "ListPath" + [ci] default = "false" description = "Set to true if running in a CI environment" @@ -373,9 +394,14 @@ to use this feature. Multiple envs can be set by separating them with a comma, e.g. `MISE_ENV=ci,test`. They will be read in order, with the last one taking precedence. + +This is an early-init setting: it must be set in `.miserc.toml`, environment +variables, or CLI flags (`-E`/`--env`). Setting it in `mise.toml` will have no +effect because `MISE_ENV` determines which config files to load. """ env = "MISE_ENV" parse_env = "list_by_comma" +rc = true type = "ListString" [env_file] @@ -658,8 +684,16 @@ type = "SetString" [ignored_config_paths] default = [] description = "This is a list of config paths that mise will ignore." +docs = """ +This is a list of config paths that mise will ignore. + +This is an early-init setting: it must be set in `.miserc.toml`, environment +variables, or CLI flags. Setting it in `mise.toml` will have no effect because +config file discovery has already occurred by the time `mise.toml` is read. +""" env = "MISE_IGNORED_CONFIG_PATHS" parse_env = "list_by_colon" +rc = true rust_type = "BTreeSet" type = "ListPath" @@ -907,16 +941,34 @@ type = "String" [override_config_filenames] default = [] -description = "If set, mise will ignore default config files like `mise.toml` and use these filenames instead. This must be an env var." +description = "If set, mise will ignore default config files like `mise.toml` and use these filenames instead." +docs = """ +If set, mise will ignore default config files like `mise.toml` and use these +filenames instead. + +This is an early-init setting: it must be set in `.miserc.toml`, environment +variables, or CLI flags. Setting it in `mise.toml` will have no effect because +config file discovery has already occurred by the time `mise.toml` is read. +""" env = "MISE_OVERRIDE_CONFIG_FILENAMES" parse_env = "list_by_colon" +rc = true type = "ListString" [override_tool_versions_filenames] default = [] -description = "If set, mise will ignore .tool-versions files and use these filenames instead. Can be set to `none` to disable .tool-versions. This must be an env var." +description = "If set, mise will ignore .tool-versions files and use these filenames instead. Can be set to `none` to disable .tool-versions." +docs = """ +If set, mise will ignore .tool-versions files and use these filenames instead. +Can be set to `none` to disable .tool-versions entirely. + +This is an early-init setting: it must be set in `.miserc.toml`, environment +variables, or CLI flags. Setting it in `mise.toml` will have no effect because +config file discovery has already occurred by the time `mise.toml` is read. +""" env = "MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES" parse_env = "list_by_colon" +rc = true type = "ListString" [paranoid] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d40be06e51..a5d722b482 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -557,6 +557,11 @@ fn preprocess_args_for_naked_run(cmd: &clap::Command, args: &[String]) -> Vec) -> Result<()> { crate::env::ARGS.write().unwrap().clone_from(args); + // Load .miserc.toml early, before MISE_ENV and other early settings are accessed. + // This allows setting MISE_ENV in a config file instead of only via env vars. + if let Err(err) = crate::config::miserc::init() { + warn!("Failed to load .miserc.toml: {err}"); + } if *crate::env::MISE_TOOL_STUB && args.len() >= 2 { tool_stub::short_circuit_stub(&args[2..]).await?; } diff --git a/src/config/miserc.rs b/src/config/miserc.rs new file mode 100644 index 0000000000..0febb48897 --- /dev/null +++ b/src/config/miserc.rs @@ -0,0 +1,179 @@ +//! Early initialization settings from .miserc.toml +//! +//! This module handles loading settings that need to be known before the main +//! config files are parsed. The primary use case is setting MISE_ENV, which +//! determines which environment-specific config files (e.g., mise.development.toml) +//! to load. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use eyre::Result; + +use crate::config::settings::MisercSettings; +use crate::dirs; +use crate::env; +use crate::file; + +static MISERC: OnceLock = OnceLock::new(); + +/// Initialize miserc settings by loading .miserc.toml files. +/// This must be called early in the initialization process, before +/// MISE_ENV or other early settings are accessed. +pub fn init() -> Result<()> { + let settings = load_miserc_settings()?; + let _ = MISERC.set(settings); + Ok(()) +} + +/// Get the loaded miserc settings, or default if not initialized. +pub fn get() -> &'static MisercSettings { + MISERC.get_or_init(|| load_miserc_settings().unwrap_or_default()) +} + +/// Get the MISE_ENV value from miserc, if set. +pub fn get_env() -> Option<&'static Vec> { + get().env.as_ref() +} + +/// Get the ceiling_paths value from miserc, if set. +pub fn get_ceiling_paths() -> Option<&'static BTreeSet> { + get().ceiling_paths.as_ref() +} + +/// Get the ignored_config_paths value from miserc, if set. +pub fn get_ignored_config_paths() -> Option<&'static BTreeSet> { + get().ignored_config_paths.as_ref() +} + +/// Get the override_config_filenames value from miserc, if set. +pub fn get_override_config_filenames() -> Option<&'static Vec> { + get().override_config_filenames.as_ref() +} + +/// Get the override_tool_versions_filenames value from miserc, if set. +pub fn get_override_tool_versions_filenames() -> Option<&'static Vec> { + get().override_tool_versions_filenames.as_ref() +} + +/// Load and merge all miserc settings files. +/// Precedence (highest to lowest): +/// 1. Local .miserc.toml (closest to cwd wins) +/// 2. Global ~/.config/mise/miserc.toml +/// 3. System /etc/mise/miserc.toml +fn load_miserc_settings() -> Result { + let mut merged = MisercSettings::default(); + + // Load in reverse precedence order so later loads override earlier ones + let files = find_miserc_files(); + + for path in files.into_iter().rev() { + if let Ok(content) = file::read_to_string(&path) { + match toml::from_str::(&content) { + Ok(settings) => { + merge_settings(&mut merged, settings); + } + Err(e) => { + warn!("Failed to parse {}: {}", path.display(), e); + } + } + } + } + + Ok(merged) +} + +/// Merge source settings into target, where source values override target. +fn merge_settings(target: &mut MisercSettings, source: MisercSettings) { + if source.env.is_some() { + target.env = source.env; + } + if source.ceiling_paths.is_some() { + target.ceiling_paths = source.ceiling_paths; + } + if source.ignored_config_paths.is_some() { + target.ignored_config_paths = source.ignored_config_paths; + } + if source.override_config_filenames.is_some() { + target.override_config_filenames = source.override_config_filenames; + } + if source.override_tool_versions_filenames.is_some() { + target.override_tool_versions_filenames = source.override_tool_versions_filenames; + } +} + +/// Find all miserc.toml files in order of precedence (highest first). +fn find_miserc_files() -> Vec { + let mut files = Vec::new(); + + // Local hierarchy: .miserc.toml in cwd and ancestors + // Use raw std::env to avoid depending on our lazy statics + if let Ok(cwd) = std::env::current_dir() { + // Walk up the directory tree, but stop at home or root + let home: &Path = &dirs::HOME; + for dir in cwd.ancestors() { + let path = dir.join(".miserc.toml"); + if path.is_file() { + files.push(path); + } + // Stop at home directory to avoid searching too far + if dir == home || dir.parent().is_none() { + break; + } + } + } + + // Global: ~/.config/mise/miserc.toml + let global_path = dirs::CONFIG.join("miserc.toml"); + if global_path.is_file() { + files.push(global_path); + } + + // System: /etc/mise/miserc.toml (or MISE_SYSTEM_DIR) + let system_dir = env::var("MISE_SYSTEM_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/etc/mise")); + let system_path = system_dir.join("miserc.toml"); + if system_path.is_file() { + files.push(system_path); + } + + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merge_settings() { + let mut target = MisercSettings { + env: Some(vec!["base".to_string()]), + ..Default::default() + }; + + let source = MisercSettings { + env: Some(vec!["override".to_string()]), + ..Default::default() + }; + + merge_settings(&mut target, source); + + assert_eq!(target.env, Some(vec!["override".to_string()])); + } + + #[test] + fn test_parse_miserc() { + let content = r#" +env = ["development", "local"] +ceiling_paths = ["/home/user"] +"#; + let settings: MisercSettings = toml::from_str(content).unwrap(); + assert_eq!( + settings.env, + Some(vec!["development".to_string(), "local".to_string()]) + ); + assert!(settings.ceiling_paths.is_some()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 231020f50a..684efb6cd4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,6 +33,7 @@ use crate::{backend, dirs, env, file, lockfile, registry, runtime_symlinks, shim pub mod config_file; pub mod env_directive; +pub mod miserc; pub mod settings; pub mod tracking; diff --git a/src/env.rs b/src/env.rs index 1c4fab2c44..62298c140c 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,4 +1,5 @@ use crate::Result; +use crate::config::miserc; use crate::env_diff::{EnvDiff, EnvDiffOperation, EnvDiffPatches, EnvMap}; use crate::file::replace_path; use crate::shell::ShellType; @@ -141,12 +142,16 @@ pub static MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES: Lazy>> Lazy::new(|| match var("MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES") { Ok(v) if v == "none" => Some([].into()), Ok(v) => Some(v.split(':').map(|s| s.to_string()).collect()), - Err(_) => Default::default(), + Err(_) => { + miserc::get_override_tool_versions_filenames().map(|v| v.iter().cloned().collect()) + } }); pub static MISE_OVERRIDE_CONFIG_FILENAMES: Lazy> = Lazy::new(|| match var("MISE_OVERRIDE_CONFIG_FILENAMES") { Ok(v) => v.split(':').map(|s| s.to_string()).collect(), - Err(_) => Default::default(), + Err(_) => miserc::get_override_config_filenames() + .map(|v| v.iter().cloned().collect()) + .unwrap_or_default(), }); pub static MISE_ENV: Lazy> = Lazy::new(|| environment(&ARGS.read().unwrap())); pub static MISE_GLOBAL_CONFIG_FILE: Lazy> = @@ -165,6 +170,10 @@ pub static MISE_IGNORED_CONFIG_PATHS: Lazy> = Lazy::new(|| { .map(replace_path) .collect() }) + .or_else(|| { + miserc::get_ignored_config_paths() + .map(|paths| paths.iter().cloned().map(replace_path).collect()) + }) .unwrap_or_default() }); pub static MISE_CEILING_PATHS: Lazy> = Lazy::new(|| { @@ -176,6 +185,10 @@ pub static MISE_CEILING_PATHS: Lazy> = Lazy::new(|| { .map(replace_path) .collect() }) + .or_else(|| { + miserc::get_ceiling_paths() + .map(|paths| paths.iter().cloned().map(replace_path).collect()) + }) .unwrap_or_default() }); pub static MISE_USE_TOML: Lazy = Lazy::new(|| !var_is_false("MISE_USE_TOML")); @@ -589,6 +602,7 @@ fn environment(args: &[String]) -> Vec { let arg_defs = HashSet::from(["--profile", "-P", "--env", "-E"]); // Get environment value from args or env vars + // Precedence: CLI args > env vars > .miserc.toml if *IS_RUNNING_AS_SHIM { // When running as shim, ignore command line args and use env vars only None @@ -604,14 +618,26 @@ fn environment(args: &[String]) -> Vec { } }) } - .or_else(|| var("MISE_ENV").ok()) - .or_else(|| var("MISE_PROFILE").ok()) - .or_else(|| var("MISE_ENVIRONMENT").ok()) + .map(|s| { + s.split(',') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + .or_else(|| { + var("MISE_ENV") + .ok() + .or_else(|| var("MISE_PROFILE").ok()) + .or_else(|| var("MISE_ENVIRONMENT").ok()) + .map(|s| { + s.split(',') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + }) + .or_else(|| miserc::get_env().cloned()) .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect() } fn log_file_level() -> Option { diff --git a/xtasks/render/schema.ts b/xtasks/render/schema.ts index 5b20544fcb..9d386e2045 100755 --- a/xtasks/render/schema.ts +++ b/xtasks/render/schema.ts @@ -13,6 +13,7 @@ type Props = { default?: unknown; deprecated?: string; enum?: [string, ...string[]][]; + rc?: boolean; }; type SettingsToml = Record>; @@ -141,3 +142,28 @@ child_process.execSync( ); child_process.execSync("prettier --write schema/mise-task.json"); fs.unlinkSync("schema/mise-task.json.tmp"); + +// Generate .miserc.toml schema with only rc=true settings +const misercSettings: Record = {}; + +for (const key in doc) { + const props = doc[key]; + if (hasSubkeys(props) && props.rc === true) { + misercSettings[key] = buildElement(key, props); + } +} + +const misercSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "mise rc config", + description: + "Early initialization settings for mise. These settings are loaded before the main config files.", + type: "object", + additionalProperties: false, + properties: misercSettings, +}; + +fs.writeFileSync("schema/miserc.json.tmp", JSON.stringify(misercSchema)); +child_process.execSync("jq . < schema/miserc.json.tmp > schema/miserc.json"); +child_process.execSync("prettier --write schema/miserc.json"); +fs.unlinkSync("schema/miserc.json.tmp");