From c4f8daf772d1209f80eb7967c6c3e958bd320875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Mon, 15 Jun 2026 09:20:58 +0100 Subject: [PATCH 1/3] fix(process): use `./` prefix when sourcing env files in tmux for zsh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zsh's POSIX `.` builtin does not search the current directory unless `.` is in `$PATH` — the default macOS shell is zsh, so the bare `. '.env'` form silently failed there with "no such file or directory" even after the cd-first fix from #18. Services in tmux windows ended up without the per-slot env: `ECLUSE_*` vars from the per-slug preamble were fine, but anything from `.env`, `.env.local`, or `.env.ecluse` that wasn't duplicated into the preamble was lost. build_source_preamble now emits `. './.env'` (and the same for `.env.local`/`.env.ecluse`). The `./` prefix is POSIX-correct and works in bash, zsh, and dash. Adds three regression tests covering the correct prefix, the missing-files-skip behavior, and the empty-tree case. Fixes #27 --- src/process.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/process.rs b/src/process.rs index cc53cfb..b48d291 100644 --- a/src/process.rs +++ b/src/process.rs @@ -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::>() .join("; ") } @@ -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 `./` 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)] From b0975e5273f8253a84432249bd5003d3266eaed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Mon, 15 Jun 2026 09:21:08 +0100 Subject: [PATCH 2/3] fix(env): strip outer dotenv quotes when parsing .env files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_env_file treated everything after `=` as the literal value, which round-tripped `FOO="bar"` as the 5-character value `"bar"` (quotes included) when the value got re-emitted in the per-slug tmux preamble as `export FOO='"bar"'`. Downstream consumers that check against a literal string (e.g. `z.literal("development")`) would reject the quoted value, breaking any framework using conventional dotenv `KEY="value"` style. A matched outer pair of `"..."` or `'...'` is now stripped — the standard dotenv convention where `FOO=bar` and `FOO="bar"` mean the same thing. Unmatched quotes (`FOO="oops`), inner escaped quotes (`MSG="hello \"world\""`), and unquoted values containing literal quote chars or `=` (`URL=postgres://x:5433/db?a=b`) are preserved verbatim, so URLs and query strings round-trip unchanged. Adds seven regression tests covering double, single, unquoted, unmatched, inner-escaped, empty-quoted, and bare-quote-char cases. Fixes #28 --- src/env.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/src/env.rs b/src/env.rs index b2d740b..8d60851 100644 --- a/src/env.rs +++ b/src/env.rs @@ -554,14 +554,85 @@ mod tests { let keys: Vec = 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![]; @@ -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) -> Result<()> { let mut lines: Vec = env.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); lines.sort(); From 53d812db620575dca828a36ec7e987e07e74c518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Mon, 15 Jun 2026 09:21:13 +0100 Subject: [PATCH 3/3] docs: changelog entries for #27 and #28 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 480d28a..35e2e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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