Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new: Support loading .env files. #659

Merged
merged 5 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
#### 🚀 Updates

- Added `--on-init` option to `proto activate`, which will trigger the activation hook immediately in the shell, instead of waiting for a directory/prompt change to occur.
- Added support for loading `.env` files through the special `env.file` and `tools.*.env.file` settings.
```toml
[env]
file = ".env"
```

#### 🐞 Fixes

Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 10 additions & 9 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ repository = "https://github.com/moonrepo/proto"

[dependencies]
proto_pdk_api = { version = "0.24.3", path = "../pdk-api", features = [
"schematic",
"schematic",
] }
proto_shim = { version = "0.5.0", path = "../shim" }
version_spec = { version = "0.7.0", path = "../version-spec", features = [
"schematic",
"schematic",
] }
warpgate = { version = "0.19.0", path = "../warpgate", features = [
"schematic",
"schematic",
] }
clap = { workspace = true, optional = true }
convert_case = "0.6.0"
dotenvy = "0.15.7"
indexmap = { workspace = true }
miette = { workspace = true }
minisign-verify = "0.2.2"
Expand All @@ -28,12 +29,12 @@ regex = { workspace = true }
reqwest = { workspace = true }
rustc-hash = { workspace = true }
schematic = { workspace = true, features = [
"config",
"env",
"toml",
"type_indexmap",
"type_url",
"validate",
"config",
"env",
"toml",
"type_indexmap",
"type_url",
"validate",
] }
semver = { workspace = true }
serde = { workspace = true }
Expand Down
24 changes: 24 additions & 0 deletions crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,28 @@ pub enum ProtoError {
#[source]
error: Box<std::io::Error>,
},

#[diagnostic(code(proto::env::missing_file))]
#[error(
"The .env file {} does not exist. This was configured as {} in the config {}.",
.path.style(Style::Path),
.config.style(Style::File),
.config_path.style(Style::Path),
)]
MissingEnvFile {
path: PathBuf,
config: String,
config_path: PathBuf,
},

#[diagnostic(code(proto::env::parse_failed))]
#[error(
"Failed to parse .env file {}.",
.path.style(Style::Path),
)]
EnvFileParseFailed {
path: PathBuf,
#[source]
error: Box<dotenvy::Error>,
},
}
136 changes: 123 additions & 13 deletions crates/core/src/proto_config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::error::ProtoError;
use crate::helpers::ENV_VAR_SUB;
use indexmap::IndexMap;
use once_cell::sync::OnceCell;
Expand All @@ -9,10 +10,12 @@ use schematic::{
};
use serde::{Deserialize, Serialize};
use starbase_styles::color;
use starbase_utils::fs::FsError;
use starbase_utils::json::JsonValue;
use starbase_utils::toml::TomlValue;
use starbase_utils::{fs, toml};
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::fmt::Debug;
use std::hash::Hash;
use std::path::{Path, PathBuf};
Expand All @@ -24,6 +27,7 @@ use warpgate::{HttpOptions, Id, PluginLocator, UrlLocator};
pub const PROTO_CONFIG_NAME: &str = ".prototools";
pub const SCHEMA_PLUGIN_KEY: &str = "internal-schema";
pub const PROTO_PLUGIN_KEY: &str = "proto";
pub const ENV_FILE_KEY: &str = "file";

fn merge_tools(
mut prev: BTreeMap<Id, PartialProtoToolConfig>,
Expand Down Expand Up @@ -131,6 +135,12 @@ derive_enum!(
}
);

#[derive(Clone, Debug, PartialEq)]
pub struct EnvFile {
pub path: PathBuf,
pub weight: usize,
}

#[derive(Clone, Config, Debug, PartialEq, Serialize)]
#[serde(untagged)]
pub enum EnvVar {
Expand Down Expand Up @@ -174,6 +184,10 @@ pub struct ProtoToolConfig {
#[setting(merge = merge_fxhashmap)]
#[serde(flatten, skip_serializing_if = "FxHashMap::is_empty")]
pub config: FxHashMap<String, JsonValue>,

#[setting(exclude, merge = merge::append_vec)]
#[serde(skip)]
_env_files: Vec<EnvFile>,
}

#[derive(Clone, Config, Debug, Serialize)]
Expand Down Expand Up @@ -240,6 +254,10 @@ pub struct ProtoConfig {
#[setting(merge = merge_fxhashmap)]
#[serde(flatten, skip_serializing)]
pub unknown: FxHashMap<String, TomlValue>,

#[setting(exclude, merge = merge::append_vec)]
#[serde(skip)]
_env_files: Vec<EnvFile>,
}

impl ProtoConfig {
Expand Down Expand Up @@ -402,7 +420,6 @@ impl ProtoConfig {

debug!(file = ?path, "Loading {}", PROTO_CONFIG_NAME);

let config_path = path.to_string_lossy();
let config_content = if with_lock {
fs::read_file_with_lock(path)?
} else {
Expand All @@ -415,7 +432,7 @@ impl ProtoConfig {

config.validate(&(), true).map_err(|error| match error {
ConfigError::Validator { error, .. } => ConfigError::Validator {
location: config_path.to_string(),
location: path.to_string_lossy().to_string(),
error,
help: Some(color::muted_light("https://moonrepo.dev/docs/proto/config")),
},
Expand Down Expand Up @@ -454,7 +471,7 @@ impl ProtoConfig {

if !error.errors.is_empty() {
return Err(ConfigError::Validator {
location: config_path.to_string(),
location: path.to_string_lossy().to_string(),
error: Box::new(error),
help: Some(color::muted_light("https://moonrepo.dev/docs/proto/config")),
}
Expand All @@ -463,32 +480,72 @@ impl ProtoConfig {
}

// Update file paths to be absolute
let make_absolute = |file: &PathBuf| {
fn make_absolute<T: AsRef<OsStr>>(file: T, current_path: &Path) -> PathBuf {
let file = PathBuf::from(file.as_ref());

if file.is_absolute() {
file.to_owned()
} else if let Some(dir) = path.parent() {
file
} else if let Some(dir) = current_path.parent() {
dir.join(file)
} else {
PathBuf::from("/").join(file)
}
};
}

if let Some(plugins) = &mut config.plugins {
for locator in plugins.values_mut() {
if let PluginLocator::File(ref mut inner) = locator {
inner.path = Some(make_absolute(&inner.get_unresolved_path()));
inner.path = Some(make_absolute(inner.get_unresolved_path(), path));
}
}
}

if let Some(settings) = &mut config.settings {
if let Some(http) = &mut settings.http {
if let Some(root_cert) = &mut http.root_cert {
*root_cert = make_absolute(root_cert);
*root_cert = make_absolute(&root_cert, path);
}
}
}

let push_env_file = |env_map: Option<&mut IndexMap<String, PartialEnvVar>>,
file_list: &mut Option<Vec<EnvFile>>,
extra_weight: usize|
-> miette::Result<()> {
if let Some(map) = env_map {
if let Some(PartialEnvVar::Value(env_file)) = map.get(ENV_FILE_KEY) {
let list = file_list.get_or_insert(vec![]);
let env_file_path = make_absolute(env_file, path);

if !env_file_path.exists() {
return Err(ProtoError::MissingEnvFile {
path: env_file_path,
config: env_file.to_owned(),
config_path: path.to_path_buf(),
}
.into());
}

list.push(EnvFile {
path: env_file_path,
weight: (path.to_str().map_or(0, |p| p.len()) * 10) + extra_weight,
});
}

map.shift_remove(ENV_FILE_KEY);
}

Ok(())
};

if let Some(tools) = &mut config.tools {
for tool in tools.values_mut() {
push_env_file(tool.env.as_mut(), &mut tool._env_files, 5)?;
}
}

push_env_file(config.env.as_mut(), &mut config._env_files, 0)?;

Ok(config)
}

Expand Down Expand Up @@ -521,25 +578,49 @@ impl ProtoConfig {
Self::save_to(dir, config)
}

pub fn get_env_files(&self, filter_id: Option<&Id>) -> Vec<&PathBuf> {
let mut paths: Vec<&EnvFile> = self._env_files.iter().collect();

if let Some(id) = filter_id {
if let Some(tool_config) = self.tools.get(id) {
paths.extend(&tool_config._env_files);
}
}

// Sort by weight so that we persist the order of env files
// when layers across directories exist!
paths.sort_by(|a, d| a.weight.cmp(&d.weight));

// Then only return the paths
paths.into_iter().map(|file| &file.path).collect()
}

// We don't use a `BTreeMap` for env vars, so that variable interpolation
// and order of declaration can work correctly!
pub fn get_env_vars(
&self,
filter_id: Option<&Id>,
) -> miette::Result<IndexMap<String, Option<String>>> {
let env_files = self.get_env_files(filter_id);

let mut base_vars = IndexMap::new();
base_vars.extend(self.env.iter());
base_vars.extend(self.load_env_files(&env_files)?);
base_vars.extend(self.env.clone());

if let Some(id) = filter_id {
if let Some(tool_config) = self.tools.get(id) {
base_vars.extend(tool_config.env.iter())
base_vars.extend(tool_config.env.clone())
}
}

let mut vars = IndexMap::<String, Option<String>>::new();

for (key, value) in base_vars {
let key_exists = std::env::var(key).is_ok_and(|v| !v.is_empty());
if key == ENV_FILE_KEY {
continue;
}

let key_exists = std::env::var(&key).is_ok_and(|v| !v.is_empty());
let value = value.to_value();

// Don't override parent inherited vars
Expand All @@ -564,7 +645,36 @@ impl ProtoConfig {
.to_string()
});

vars.insert(key.to_owned(), value);
vars.insert(key, value);
}

Ok(vars)
}

pub fn load_env_files(&self, paths: &[&PathBuf]) -> miette::Result<IndexMap<String, EnvVar>> {
let mut vars = IndexMap::default();

let map_error = |error: dotenvy::Error, path: &Path| -> miette::Report {
match error {
dotenvy::Error::Io(inner) => FsError::Read {
path: path.to_path_buf(),
error: Box::new(inner),
}
.into(),
other => ProtoError::EnvFileParseFailed {
path: path.to_path_buf(),
error: Box::new(other),
}
.into(),
}
};

for path in paths {
for item in dotenvy::from_path_iter(path).map_err(|error| map_error(error, path))? {
let (key, value) = item.map_err(|error| map_error(error, path))?;

vars.insert(key, EnvVar::Value(value));
}
}

Ok(vars)
Expand Down
Loading