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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [Unreleased]

### Fixed
- tmux startup sources `.env`, `.env.local`, and `.env.ecluse` with an explicit `./` prefix (`. './.env'`). zsh's POSIX `.` builtin does not search cwd unless `.` is in `$PATH`, so the previous bare `. '.env'` form silently failed on zsh (the default macOS shell), leaving services in tmux windows without the per-slot env. (#27)
- `parse_env_file` now strips a matched outer pair of `"..."` or `'...'` from dotenv values. The per-slug preamble previously echoed `ONYX_ENVIRONMENT='"development"'`, leaking the literal double-quote characters into the runtime env and breaking strict consumers (e.g. zod `z.literal("development")`). Unquoted values like `postgres://x:5433/db?a=b` are unaffected. (#28)

---

## [0.3.0] — 2026-06-10

### Added
Expand Down
93 changes: 89 additions & 4 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,14 +554,85 @@ mod tests {
let keys: Vec<String> = parse_env_file(&path).into_iter().map(|(k, _)| k).collect();
assert_eq!(keys, vec!["B", "A"]);
}

#[test]
fn parse_env_file_strips_outer_double_quotes() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "ONYX_ENVIRONMENT=\"development\"\n").unwrap();
let pairs = parse_env_file(&path);
assert_eq!(pairs[0].1, "development");
}

#[test]
fn parse_env_file_strips_outer_single_quotes() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "FOO='bar baz'\n").unwrap();
let pairs = parse_env_file(&path);
assert_eq!(pairs[0].1, "bar baz");
}

#[test]
fn parse_env_file_keeps_unquoted_values_verbatim() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "URL=postgres://x:5433/db?a=b\n").unwrap();
let pairs = parse_env_file(&path);
assert_eq!(pairs[0].1, "postgres://x:5433/db?a=b");
}

#[test]
fn parse_env_file_keeps_unmatched_quotes() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "FOO=\"unmatched\nBAR='also unmatched\n").unwrap();
let pairs = parse_env_file(&path);
assert_eq!(pairs[0].1, "\"unmatched");
assert_eq!(pairs[1].1, "'also unmatched");
}

#[test]
fn parse_env_file_keeps_inner_quotes() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "MSG=\"hello \\\"world\\\"\"\n").unwrap();
let pairs = parse_env_file(&path);
// Outer pair stripped, inner literal backslash-escaped quotes preserved as-is.
assert_eq!(pairs[0].1, "hello \\\"world\\\"");
}

#[test]
fn parse_env_file_empty_quoted_value() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "EMPTY=\"\"\n").unwrap();
let pairs = parse_env_file(&path);
assert_eq!(pairs[0].1, "");
}

#[test]
fn parse_env_file_single_quote_char_value() {
// A single quote character alone is not a matched pair — kept verbatim.
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
std::fs::write(&path, "Q=\"\n").unwrap();
let pairs = parse_env_file(&path);
assert_eq!(pairs[0].1, "\"");
}
}

/// Parse a .env-style file into key/value pairs, in file order.
/// Blank lines and `#` comments are skipped; each line splits on the first
/// `=`; keys are trimmed, values taken as-is. Missing file → empty.
/// `=`; keys are trimmed.
///
/// The single parser for `.env.ecluse` — `shell`, `env`, `up --json`, and
/// process spawning must all read the file the same way.
/// Values: a matched outer pair of `"..."` or `'...'` is stripped (dotenv
/// convention — `FOO="bar"` and `FOO=bar` both mean `FOO=bar`). Unmatched
/// quotes and values without quotes are kept verbatim, so URL/path values
/// like `postgres://x:5433/db?a=b` round-trip unchanged.
///
/// The single parser for `.env`-style files — `shell`, `env`, `up --json`,
/// and process spawning must all read the file the same way.
pub fn parse_env_file(path: &Path) -> Vec<(String, String)> {
let Ok(content) = std::fs::read_to_string(path) else {
return vec![];
Expand All @@ -578,11 +649,25 @@ pub fn parse_env_file(path: &Path) -> Vec<(String, String)> {
if k.is_empty() {
return None;
}
Some((k.to_string(), v.to_string()))
Some((k.to_string(), strip_dotenv_quotes(v).to_string()))
})
.collect()
}

/// Strip a matched outer pair of `"..."` or `'...'` from a dotenv value.
/// Unmatched or absent quotes return the input unchanged.
fn strip_dotenv_quotes(v: &str) -> &str {
let bytes = v.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &v[1..v.len() - 1];
}
}
v
}

pub fn write_env_file(worktree: &Path, env: &HashMap<String, String>) -> Result<()> {
let mut lines: Vec<String> = env.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
lines.sort();
Expand Down
52 changes: 51 additions & 1 deletion src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,17 @@ pub fn merge_worktree_env(
/// Files that don't exist are silently skipped.
/// Sent as a separate command before the service command so that
/// manual restarts inside the tmux window (`↑ Enter`) also have the env.
///
/// Paths are emitted with an explicit `./` prefix because zsh's POSIX `.`
/// builtin does NOT search the current directory unless `.` is in `$PATH` —
/// `. .env` silently fails as "no such file or directory" under zsh
/// (the default macOS shell) while working in bash. `./` works in both.
fn build_source_preamble(worktree: &Path) -> String {
let files = [".env", ".env.local", ".env.ecluse"];
files
.iter()
.filter(|f| worktree.join(f).exists())
.map(|f| format!("set -a; . {}; set +a", shell_escape(f)))
.map(|f| format!("set -a; . {}; set +a", shell_escape(&format!("./{}", f))))
.collect::<Vec<_>>()
.join("; ")
}
Expand Down Expand Up @@ -1097,6 +1102,51 @@ mod tests {
"service one's pid file should be removed by partial-spawn cleanup"
);
}

// ── build_source_preamble ─────────────────────────────────────────────────

#[test]
fn build_source_preamble_prefixes_relative_paths_with_dot_slash() {
// zsh's POSIX `.` builtin does NOT search cwd unless `.` is in $PATH.
// The emitted command must use `./<name>` so it works in both bash and zsh.
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar").unwrap();
std::fs::write(dir.path().join(".env.local"), "BAR=baz").unwrap();
let cmd = build_source_preamble(dir.path());
assert!(
cmd.contains(". './.env'"),
"expected `. './.env'` in: {}",
cmd
);
assert!(
cmd.contains(". './.env.local'"),
"expected `. './.env.local'` in: {}",
cmd
);
// Never emit the bare `. '.env'` form — that's the regression we're fixing.
assert!(
!cmd.contains(". '.env'"),
"must not emit zsh-incompatible `. '.env'`: {}",
cmd
);
}

#[test]
fn build_source_preamble_skips_missing_files() {
let dir = TempDir::new().unwrap();
// Only .env present.
std::fs::write(dir.path().join(".env"), "FOO=bar").unwrap();
let cmd = build_source_preamble(dir.path());
assert!(cmd.contains(". './.env'"));
assert!(!cmd.contains(".env.local"));
assert!(!cmd.contains(".env.ecluse"));
}

#[test]
fn build_source_preamble_empty_when_no_files() {
let dir = TempDir::new().unwrap();
assert_eq!(build_source_preamble(dir.path()), "");
}
}

#[cfg(test)]
Expand Down
Loading