Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
dist
node_modules
.pnp.*
*.pyc

flamegraph.svg
profile.json.gz
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.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dashmap = "6"
clipanion = { git = "https://github.com/arcanis/clipanion-rs.git", features = ["serde", "tokens"] }
colored = "3.0.0"
dialoguer = "0.11.0"
dotenvy = "0.15.7"
divan = { version = "4.2.0", package = "codspeed-divan-compat" }
ecow = "0.2.6"
erased-serde = "0.4.6"
Expand Down
1 change: 1 addition & 0 deletions packages/zpm-config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ edition = "2024"

[dependencies]
convert_case = { workspace = true }
dotenvy = { workspace = true }
serde_yaml = { workspace = true }
serde = { workspace = true, features = ["derive"] }
shellexpand = { workspace = true }
Expand Down
13 changes: 13 additions & 0 deletions packages/zpm-config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@
"description": "The folder where the global cache will be stored",
"default": "Path::home_dir().unwrap().unwrap().with_join_str(\".yarn/zpm\")"
},
"initScope": {
"type": "string",
"description": "Scope used when creating packages via the init command",
"default": "yarnpkg"
},
"injectEnvironmentFiles": {
"type": "array",
"description": "Array of .env files which will get injected into any subprocess spawned by Yarn",
"items": {
"type": "string"
},
"default": [".env.yarn?"]
},
"httpRetry": {
"type": "usize",
"description": "The number of times to retry a network request",
Expand Down
219 changes: 168 additions & 51 deletions packages/zpm-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ pub struct Configuration {
pub settings: Settings,
pub user_config_path: Option<Path>,
pub project_config_path: Option<Path>,
pub env_files: BTreeMap<String, String>,
}

#[derive(thiserror::Error, Debug, Clone)]
Expand All @@ -816,6 +817,12 @@ pub enum ConfigurationError {

#[error(transparent)]
SerdeError(#[from] Arc<serde_yaml::Error>),

#[error("Environment file not found: {0}")]
EnvironmentFileNotFound(String),

#[error("Invalid environment file line: {0}")]
InvalidEnvironmentFileLine(String),
}

impl From<std::io::Error> for ConfigurationError {
Expand Down Expand Up @@ -856,6 +863,69 @@ pub enum HydrateError {
InvalidValue(String),
}

struct RcFile {
path: Path,
text: Option<String>,
}

impl RcFile {
fn try_read(dir: Option<&Path>, rc_filename: &str, last_modified_at: &mut LastModifiedAt) -> Result<Option<Self>, ConfigurationError> {
let Some(dir) = dir else {
return Ok(None);
};

let path
= dir.with_join_str(rc_filename);

let metadata
= path.fs_metadata()
.ok_missing()?;

let Some(metadata) = metadata else {
return Ok(Some(RcFile {path, text: None}));
};

let changed_at
= metadata.modified()?
.duration_since(UNIX_EPOCH).unwrap()
.as_nanos();

last_modified_at.update(changed_at);

let text
= path.fs_read_text_with_size(metadata.len())?;

Ok(Some(RcFile {path, text: Some(text)}))
}

fn deserialize(&self) -> Option<Result<intermediate::Settings, ConfigurationError>> {
self.text.as_ref().map(|text| {
Ok(serde_yaml::from_str(text)?)
})
}

/// Extract the `injectEnvironmentFiles` value from the raw YAML text.
/// Uses a minimal struct to avoid full deserialization, which would fail
/// if config values reference env vars not yet loaded from .env files.
fn extract_inject_environment_files(&self) -> Result<Option<Vec<String>>, ConfigurationError> {
let Some(text) = &self.text else {
return Ok(None);
};

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PartialSettings {
#[serde(default)]
inject_environment_files: Option<Vec<String>>,
}

let partial: PartialSettings
= serde_yaml::from_str(text)?;

Ok(partial.inject_environment_files)
}
}

impl Configuration {
pub fn tree_node(&self) -> tree::Node<'_> {
self.settings.tree_node(None, None)
Expand All @@ -869,74 +939,120 @@ impl Configuration {
self.settings.get(path)
}

pub fn load(context: &ConfigurationContext, last_modified_at: &mut LastModifiedAt) -> Result<Configuration, ConfigurationError> {
let rc_filename
= std::env::var("YARN_RC_FILENAME")
.unwrap_or_else(|_| ".yarnrc.yml".to_string());

let user_config_path = context.user_cwd
.as_ref()
.map(|path| path.with_join_str(&rc_filename));
fn load_env_files(
project_cwd: &Path,
env_file_paths: &[String],
) -> Result<BTreeMap<String, String>, ConfigurationError> {
let mut env_vars: BTreeMap<String, String>
= BTreeMap::new();

let project_config_path = context.project_cwd
.as_ref()
.map(|path| path.with_join_str(&rc_filename));
for path_str in env_file_paths {
let (actual_path, optional) = if let Some(stripped) = path_str.strip_suffix('?') {
(stripped, true)
} else {
(path_str.as_str(), false)
};

let mut intermediate_user_config
= Partial::Missing;
let mut intermediate_project_config
= Partial::Missing;
let full_path
= project_cwd.with_join_str(actual_path);

if let Some(user_config_path) = user_config_path.as_ref() {
let metadata
= user_config_path.fs_metadata()
= full_path.fs_metadata()
.ok_missing()?;

if let Some(metadata) = metadata {
let user_last_changed_at
= metadata.modified()?
.duration_since(UNIX_EPOCH).unwrap()
.as_nanos();
match metadata {
Some(metadata) => {
let content
= full_path
.fs_read_text_with_size(metadata.len())?;

for item in dotenvy::from_read_iter(content.as_bytes()) {
let (key, value)
= item
.map_err(|e| ConfigurationError::InvalidEnvironmentFileLine(e.to_string()))?;

env_vars.insert(key, value);
}
},

None => {
if !optional {
return Err(ConfigurationError::EnvironmentFileNotFound(path_str.clone()));
}
},
}
}

last_modified_at.update(user_last_changed_at);
Ok(env_vars)
}

let user_config_text
= user_config_path
.fs_read_text_with_size(metadata.len())?;
pub fn load(context: &ConfigurationContext, last_modified_at: &mut LastModifiedAt) -> Result<Configuration, ConfigurationError> {
let project_cwd
= context.project_cwd
.as_ref()
.expect("A project directory should be set");

let user_config: intermediate::Settings
= serde_yaml::from_str(&user_config_text)?;
let rc_filename
= std::env::var("YARN_RC_FILENAME")
.unwrap_or_else(|_| ".yarnrc.yml".to_string());

intermediate_user_config = Partial::Value(user_config);
}
// Read both rc files upfront (once each)
let user_rc
= RcFile::try_read(context.user_cwd.as_ref(), &rc_filename, last_modified_at)?;
let project_rc
= RcFile::try_read(Some(project_cwd), &rc_filename, last_modified_at)?;

// Phase 1: Extract injectEnvironmentFiles from the raw YAML text.
// We check the project rc first, falling back to the user rc, then
// to the default. This uses a minimal parse that tolerates config
// values referencing env vars that don't exist yet.
let inject_environment_files = project_rc.as_ref()
.and_then(|rc| rc.extract_inject_environment_files().ok().flatten())
.or_else(|| user_rc.as_ref()
.and_then(|rc| rc.extract_inject_environment_files().ok().flatten()))
.unwrap_or_else(|| vec![".env.yarn?".to_string()]);

// Phase 2: Load .env files and collect variables
let env_files = Self::load_env_files(
project_cwd,
&inject_environment_files,
)?;

// Phase 3: Set env file variables in the process environment so that
// shellexpand::env() (used by the Interpolated deserializer) can
// resolve them when deserializing config values like ${VAR}.
let mut enriched_context
= context.clone();

for (key, value) in &env_files {
// SAFETY: Configuration loading happens during startup before any
// threads are spawned, so concurrent access to the environment is
// not a concern.
unsafe { std::env::set_var(key, value); }
enriched_context.env.insert(key.clone(), value.clone());
}

if let Some(project_config_path) = project_config_path.as_ref() {
let metadata
= project_config_path.fs_metadata()
.ok_missing()?;

if let Some(metadata) = metadata {
let project_last_changed_at
= metadata.modified()?
.duration_since(UNIX_EPOCH).unwrap()
.as_nanos();

last_modified_at.update(project_last_changed_at);
// Phase 4: Deserialize the already-read rc files (no re-read)
let user_config_path
= user_rc.as_ref()
.map(|rc| rc.path.clone());

let project_config_text
= project_config_path
.fs_read_text_with_size(metadata.len())?;
let project_config_path
= project_rc.as_ref()
.map(|rc| rc.path.clone());

let project_config: intermediate::Settings
= serde_yaml::from_str(&project_config_text)?;
let intermediate_user_config = match user_rc.and_then(|rc| rc.deserialize()) {
Some(result) => Partial::Value(result?),
None => Partial::Missing,
};

intermediate_project_config = Partial::Value(project_config);
}
}
let intermediate_project_config = match project_rc.and_then(|rc| rc.deserialize()) {
Some(result) => Partial::Value(result?),
None => Partial::Missing,
};

let mut settings = Settings::merge(
&context,
&enriched_context,
intermediate_user_config,
intermediate_project_config,
|| panic!("No configuration found")
Expand All @@ -950,6 +1066,7 @@ impl Configuration {
settings,
user_config_path,
project_config_path,
env_files,
})
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/zpm/src/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,11 @@ impl ScriptEnvironment {
pub fn with_project(mut self, project: &Project) -> Self {
self.remove_pnp_loader();

// Inject environment variables from .env files
for (key, value) in &project.config.env_files {
self.env.insert(key.clone(), Some(value.clone()));
}

if let Some(pnp_path) = project.pnp_path().if_exists() {
self.append_env("NODE_OPTIONS", ' ', &format!("--require {}", pnp_path.to_file_string()));
}
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe(`DotEnv files`, () => {

await xfs.writeFilePromise(ppath.join(path, `.env.yarn`), [
`INJECTED_FROM_ENV_FILE_1=hello\n`,
`INJECTED_FROM_ENV_FILE_2=\${INJECTED_FROM_ENV_FILE_1} world\n`,
`INJECTED_FROM_ENV_FILE_2="\${INJECTED_FROM_ENV_FILE_1} world"\n`,
].join(``));

await expect(run(`exec`, `env`, {env: {FOO: `foo`}})).resolves.toMatchObject({
Expand Down
Loading