diff --git a/scripts/lib/env.py b/scripts/lib/env.py index e7f3a4d..3cc8837 100644 --- a/scripts/lib/env.py +++ b/scripts/lib/env.py @@ -47,11 +47,28 @@ class OpenAIAuth: codex_auth_file: str +def _check_file_permissions(path: Path) -> None: + """Warn to stderr if a secrets file has overly permissive permissions.""" + try: + mode = path.stat().st_mode + # Check if group or other can read (bits 0o044) + if mode & 0o044: + import sys + sys.stderr.write( + f"[last30days] WARNING: {path} is readable by other users. " + f"Run: chmod 600 {path}\n" + ) + sys.stderr.flush() + except OSError: + pass + + def load_env_file(path: Path) -> Dict[str, str]: """Load environment variables from a file.""" env = {} - if not path.exists(): + if not path or not path.exists(): return env + _check_file_permissions(path) with open(path, 'r') as f: for line in f: @@ -178,14 +195,44 @@ def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth: ) +def _find_project_env() -> Optional[Path]: + """Find per-project .env by walking up from cwd. + + Searches for .claude/last30days.env in each parent directory, + stopping at the user's home directory or filesystem root. + """ + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + candidate = parent / '.claude' / 'last30days.env' + if candidate.exists(): + return candidate + # Stop at filesystem root or home + if parent == Path.home() or parent == parent.parent: + break + return None + + def get_config() -> Dict[str, Any]: - """Load configuration from ~/.config/last30days/.env and environment.""" - # Load from config file first (if configured) + """Load configuration from multiple sources. + + Priority (highest wins): + 1. Environment variables (os.environ) + 2. .claude/last30days.env (per-project config) + 3. ~/.config/last30days/.env (global config) + """ + # Load from global config file file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {} - openai_auth = get_openai_auth(file_env) + # Load from per-project config (overrides global) + project_env_path = _find_project_env() + project_env = load_env_file(project_env_path) if project_env_path else {} - # Build config: Codex/OpenAI auth + process.env > .env file + # Merge: project overrides global + merged_env = {**file_env, **project_env} + + openai_auth = get_openai_auth(merged_env) + + # Build config: Codex/OpenAI auth + process.env > project .env > global .env config = { 'OPENAI_API_KEY': openai_auth.token, 'OPENAI_AUTH_SOURCE': openai_auth.source, @@ -211,14 +258,26 @@ def get_config() -> Dict[str, Any]: ] for key, default in keys: - config[key] = os.environ.get(key) or file_env.get(key, default) + config[key] = os.environ.get(key) or merged_env.get(key, default) + + # Track which config source was used + if project_env_path: + config['_CONFIG_SOURCE'] = f'project:{project_env_path}' + elif CONFIG_FILE and CONFIG_FILE.exists(): + config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}' + else: + config['_CONFIG_SOURCE'] = 'env_only' return config def config_exists() -> bool: - """Check if configuration file exists.""" - return CONFIG_FILE.exists() + """Check if any configuration source exists.""" + if _find_project_env(): + return True + if CONFIG_FILE: + return CONFIG_FILE.exists() + return False def is_reddit_available(config: Dict[str, Any]) -> bool: diff --git a/tests/test_env_project.py b/tests/test_env_project.py new file mode 100644 index 0000000..9b1f330 --- /dev/null +++ b/tests/test_env_project.py @@ -0,0 +1,177 @@ +"""Tests for per-project .env config discovery and precedence.""" + +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +# Add lib to path +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from lib import env + + +class TestFindProjectEnv(unittest.TestCase): + """Tests for _find_project_env() directory walking.""" + + def test_finds_env_in_cwd(self, ): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + env_file = claude_dir / "last30days.env" + env_file.write_text("OPENAI_API_KEY=sk-test\n") + with patch.object(Path, 'cwd', return_value=Path(tmpdir)): + result = env._find_project_env() + self.assertIsNotNone(result) + self.assertEqual(result.name, "last30days.env") + + def test_returns_none_when_no_config(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'cwd', return_value=Path(tmpdir)): + result = env._find_project_env() + self.assertIsNone(result) + + +class TestConfigPrecedence(unittest.TestCase): + """Tests for config source priority.""" + + def test_project_overrides_global(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + # Create global config + global_dir = Path(tmpdir) / "global" + global_dir.mkdir() + global_env = global_dir / ".env" + global_env.write_text("BRAVE_API_KEY=global-key\nPARALLEL_API_KEY=global-only\n") + + # Create project config + project_dir = Path(tmpdir) / "project" / ".claude" + project_dir.mkdir(parents=True) + project_env = project_dir / "last30days.env" + project_env.write_text("BRAVE_API_KEY=project-key\n") + + with patch.object(Path, 'cwd', return_value=Path(tmpdir) / "project"), \ + patch.object(env, 'CONFIG_FILE', global_env), \ + patch.dict(os.environ, {}, clear=False): + # Remove any env vars that would override + for k in ('BRAVE_API_KEY', 'PARALLEL_API_KEY', 'OPENAI_API_KEY'): + os.environ.pop(k, None) + config = env.get_config() + # Project value wins over global + self.assertEqual(config['BRAVE_API_KEY'], 'project-key') + # Global value still available for keys not in project + self.assertEqual(config['PARALLEL_API_KEY'], 'global-only') + + def test_env_var_overrides_project(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) / ".claude" + project_dir.mkdir() + project_env = project_dir / "last30days.env" + project_env.write_text("BRAVE_API_KEY=project-key\n") + + with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \ + patch.object(env, 'CONFIG_FILE', None), \ + patch.dict(os.environ, {'BRAVE_API_KEY': 'env-key'}): + config = env.get_config() + self.assertEqual(config['BRAVE_API_KEY'], 'env-key') + + +class TestConfigSource(unittest.TestCase): + """Tests for _CONFIG_SOURCE tracking.""" + + def test_tracks_project_source(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) / ".claude" + project_dir.mkdir() + project_env = project_dir / "last30days.env" + project_env.write_text("BRAVE_API_KEY=test\n") + + with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \ + patch.object(env, 'CONFIG_FILE', None): + config = env.get_config() + self.assertIn('project:', config['_CONFIG_SOURCE']) + + def test_tracks_global_source(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + global_env = Path(tmpdir) / ".env" + global_env.write_text("BRAVE_API_KEY=test\n") + + with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \ + patch.object(env, 'CONFIG_FILE', global_env): + config = env.get_config() + self.assertIn('global:', config['_CONFIG_SOURCE']) + + def test_tracks_env_only(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \ + patch.object(env, 'CONFIG_FILE', None): + config = env.get_config() + self.assertEqual(config['_CONFIG_SOURCE'], 'env_only') + + +class TestConfigExists(unittest.TestCase): + """Tests for config_exists() with project support.""" + + def test_true_with_project_env(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) / ".claude" + project_dir.mkdir() + (project_dir / "last30days.env").write_text("KEY=val\n") + with patch.object(Path, 'cwd', return_value=Path(tmpdir)): + self.assertTrue(env.config_exists()) + + def test_true_with_global_env(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + global_env = Path(tmpdir) / ".env" + global_env.write_text("KEY=val\n") + with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \ + patch.object(env, 'CONFIG_FILE', global_env): + self.assertTrue(env.config_exists()) + + def test_false_with_nothing(self): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \ + patch.object(env, 'CONFIG_FILE', None): + self.assertFalse(env.config_exists()) + + +class TestFilePermissions(unittest.TestCase): + """Tests for _check_file_permissions() warnings.""" + + def test_warns_on_world_readable(self): + import tempfile + import io + with tempfile.TemporaryDirectory() as tmpdir: + f = Path(tmpdir) / ".env" + f.write_text("KEY=val\n") + f.chmod(0o644) + stderr = io.StringIO() + with patch('sys.stderr', stderr): + env._check_file_permissions(f) + self.assertIn("WARNING", stderr.getvalue()) + + def test_no_warning_on_600(self): + import tempfile + import io + with tempfile.TemporaryDirectory() as tmpdir: + f = Path(tmpdir) / ".env" + f.write_text("KEY=val\n") + f.chmod(0o600) + stderr = io.StringIO() + with patch('sys.stderr', stderr): + env._check_file_permissions(f) + self.assertEqual(stderr.getvalue(), "") + + +if __name__ == "__main__": + unittest.main()