diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..925d6e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Example environment variables for xc tasks +# Copy this to .env and customize for your environment + +# Example: API keys +# API_KEY=your_api_key_here + +# Example: Database connection +# DATABASE_URL=postgres://localhost/mydb + +# Example: Environment +# ENV=development diff --git a/.gitignore b/.gitignore index 7f548d8..6b88568 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ coverage.out dist/ doc/public/ result + +# Environment files +.env +.env.local +.env.*.local diff --git a/README.md b/README.md index 203503b..2947034 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,97 @@ Find our guidelines & HOWTOs at [CONTRIBUTING.md](https://github.com/joerdav/xc/ ![emacs demo](https://codeberg.org/ryanprior/xc.el/media/branch/main/screenshot-v1.png) - See also: [`org-mode` specific features](https://xcfile.dev/org-mode-features). + +# Environment Variables + +xc supports loading environment variables from `.env` files, making it easy to manage different configurations without cluttering your task definitions. + +## Basic Usage + +Create a `.env` file in your project root: + +``` +API_KEY=your_api_key_here +DATABASE_URL=postgres://localhost/mydb +ENV=development +``` + +xc will automatically load these variables before running tasks. They will be available to all tasks in your documentation file. + +## Load Order + +Environment variables are loaded in the following order (later values override earlier ones): + +1. System environment variables +2. `.env` file (if present) +3. `.env.local` file (if present) +4. Task-level `Env:` statements +5. Command-line input values +6. Inline `export` statements in task scripts + +This allows you to: +- Set defaults in `.env` +- Override with local values in `.env.local` (great for secrets, add to `.gitignore`) +- Still use task-level env for task-specific configuration + +## CLI Options + +```bash +# Skip loading .env files +xc --no-env + +# Load a custom env file +xc --env-file .env.prod +``` + +## Security + +For security, xc will skip `.env` files with world-readable or group-readable permissions and log a warning. Ensure your `.env` files have restricted permissions: + +```bash +chmod 600 .env +chmod 600 .env.local +``` + +## Example + +**Before** (cluttered task definition): +```markdown +## deploy + +Deploy the application. + +Env: DATABASE_URL=postgres://prod/db, API_KEY=secret123, ENV=production + +\``` +./deploy.sh +\``` +``` + +**After** (with .env): +```markdown +## deploy + +Deploy the application. + +\``` +./deploy.sh +\``` +``` + +With `.env` file: +``` +DATABASE_URL=postgres://prod/db +API_KEY=secret123 +ENV=production +``` + +And `.env.local` for local overrides (gitignored): +``` +DATABASE_URL=postgres://localhost/db +ENV=development +``` + # Example Take the `tag` task in the [README.md](https://github.com/joerdav/xc#tag) of the `xc` repository: diff --git a/cmd/xc/main.go b/cmd/xc/main.go index 2c4f6ba..2727035 100644 --- a/cmd/xc/main.go +++ b/cmd/xc/main.go @@ -14,6 +14,7 @@ import ( "runtime/debug" "strings" + "github.com/joerdav/xc/internal/dotenv" "github.com/joerdav/xc/models" "github.com/joerdav/xc/parser/parsemd" "github.com/joerdav/xc/parser/parseorg" @@ -30,8 +31,8 @@ var usage string var ErrNoTaskFile = errors.New("no xc compatible documentation file found") type config struct { - version, help, short, display, noTTY, complete, uncomplete bool - filename, heading, filetype string + version, help, short, display, noTTY, complete, uncomplete, noEnv bool + filename, heading, filetype, envFile string } var version = "" @@ -76,6 +77,9 @@ func flags() config { flag.BoolVar(&cfg.noTTY, "no-tty", false, "disable interactive picker") + flag.BoolVar(&cfg.noEnv, "no-env", false, "skip loading .env files") + flag.StringVar(&cfg.envFile, "env-file", "", "load environment from specified file") + flag.Parse() return cfg } @@ -215,27 +219,49 @@ func runMain() error { <-c cancel() }() + cfg := flags() + + // Early exits (don't load .env for these) if cfg.uncomplete { return install.Uninstall("xc") } if cfg.complete { return install.Install("xc") } - tasks, dir, err := parse(cfg.filename, cfg.heading, cfg.filetype) - // TODO remove the Interactive attribute & this deprecation warning - warnInteractive(tasks) - completion(tasks).Complete("xc") - // xc -version if cfg.version { fmt.Printf("xc version: %s\n", getVersion()) return nil } - // xc -h / xc -help if cfg.help { flag.Usage() return nil } + + // Load .env files before parsing tasks (unless --no-env is set) + if !cfg.noEnv { + cwd, err := os.Getwd() + if err != nil { + log.Printf("warning: failed to get current directory: %v", err) + } else { + if cfg.envFile != "" { + // Load custom env file + if err := dotenv.LoadFile(filepath.Join(cwd, cfg.envFile)); err != nil { + log.Printf("warning: failed to load %s: %v", cfg.envFile, err) + } + } else { + // Load default .env and .env.local + if err := dotenv.Load(cwd); err != nil { + log.Printf("warning: failed to load .env: %v", err) + } + } + } + } + + tasks, dir, err := parse(cfg.filename, cfg.heading, cfg.filetype) + // TODO remove the Interactive attribute & this deprecation warning + warnInteractive(tasks) + completion(tasks).Complete("xc") if err != nil { return err } diff --git a/go.mod b/go.mod index 3236905..47893a5 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/charmbracelet/bubbletea v0.24.2 github.com/charmbracelet/lipgloss v0.7.1 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/joho/godotenv v1.5.1 github.com/posener/complete/v2 v2.0.1-alpha.13 + golang.org/x/term v0.8.0 mvdan.cc/sh/v3 v3.7.0 ) @@ -30,6 +32,5 @@ require ( github.com/sahilm/fuzzy v0.1.0 // indirect golang.org/x/sync v0.2.0 // indirect golang.org/x/sys v0.8.0 // indirect - golang.org/x/term v0.8.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 7309751..22c5942 100644 --- a/go.sum +++ b/go.sum @@ -13,7 +13,6 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:Yyn github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -24,8 +23,11 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -54,28 +56,18 @@ github.com/posener/script v1.1.5/go.mod h1:Rg3ijooqulo05aGLyGsHoLmIOUzHUVK19WVgr github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= @@ -83,7 +75,5 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -mvdan.cc/sh/v3 v3.6.0 h1:gtva4EXJ0dFNvl5bHjcUEvws+KRcDslT8VKheTYkbGU= -mvdan.cc/sh/v3 v3.6.0/go.mod h1:U4mhtBLZ32iWhif5/lD+ygy1zrgaQhUu+XFy7C8+TTA= mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= diff --git a/internal/dotenv/loader.go b/internal/dotenv/loader.go new file mode 100644 index 0000000..35968c3 --- /dev/null +++ b/internal/dotenv/loader.go @@ -0,0 +1,72 @@ +package dotenv + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + + "github.com/joho/godotenv" +) + +// Load loads .env files from the specified directory. +// Loads .env first, then .env.local (which overrides .env values). +// If neither file exists, no error is returned. +// Files with world-readable permissions are skipped with a warning. +func Load(dir string) error { + // Load .env + envPath := filepath.Join(dir, ".env") + if err := loadFile(envPath, false); err != nil { + return err + } + + // Load .env.local (overrides .env) + localPath := filepath.Join(dir, ".env.local") + if err := loadFile(localPath, true); err != nil { + return err + } + + return nil +} + +// LoadFile loads a single environment file (for custom --env-file flag). +func LoadFile(path string) error { + return loadFile(path, false) +} + +// loadFile loads a single env file with security checks. +// If override is true, uses Overload instead of Load. +func loadFile(path string, override bool) error { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return nil // File not found is OK + } + if err != nil { + return fmt.Errorf("failed to check %s: %w", path, err) + } + + // Security check: Skip world-readable or group-readable files (Unix only) + if runtime.GOOS != "windows" { + perm := info.Mode().Perm() + // Check if world-readable (others can read: 0004) or group-readable (0040) + if perm&0044 != 0 { + log.Printf("warning: %s is world/group readable (permissions: %o), skipping for security", path, perm) + return nil + } + } + + // Load the file + if override { + if err := godotenv.Overload(path); err != nil { + return fmt.Errorf("failed to load %s: %w", path, err) + } + return nil + } + + if err := godotenv.Load(path); err != nil { + return fmt.Errorf("failed to load %s: %w", path, err) + } + return nil +} diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go new file mode 100644 index 0000000..5b7d72b --- /dev/null +++ b/internal/dotenv/loader_test.go @@ -0,0 +1,180 @@ +package dotenv + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// preserveEnv saves the current values of the given environment variables +// and restores them after the test completes. +func preserveEnv(t *testing.T, keys ...string) { + t.Helper() + saved := make(map[string]string) + hasValue := make(map[string]bool) + + for _, key := range keys { + if val, ok := os.LookupEnv(key); ok { + saved[key] = val + hasValue[key] = true + } + } + + t.Cleanup(func() { + for _, key := range keys { + if hasValue[key] { + os.Setenv(key, saved[key]) + } else { + os.Unsetenv(key) + } + } + }) +} + +func TestLoad_FileNotFound_NoError(t *testing.T) { + tmpDir := t.TempDir() + err := Load(tmpDir) + if err != nil { + t.Errorf("expected no error when .env not found, got %v", err) + } +} + +func TestLoad_ValidEnv_LoadsVariables(t *testing.T) { + preserveEnv(t, "TEST_KEY", "ANOTHER") + + // Arrange + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + content := "TEST_KEY=test_value\nANOTHER=value2" + if err := os.WriteFile(envFile, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + // Act + err := Load(tmpDir) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv("TEST_KEY"); got != "test_value" { + t.Errorf("TEST_KEY = %q, want %q", got, "test_value") + } + if got := os.Getenv("ANOTHER"); got != "value2" { + t.Errorf("ANOTHER = %q, want %q", got, "value2") + } +} + +func TestLoad_WithLocal_OverridesBase(t *testing.T) { + preserveEnv(t, "KEY", "ONLY_BASE", "ONLY_LOCAL") + + // Arrange + tmpDir := t.TempDir() + + // Create .env with base values + envFile := filepath.Join(tmpDir, ".env") + if err := os.WriteFile(envFile, []byte("KEY=base\nONLY_BASE=base_value"), 0600); err != nil { + t.Fatal(err) + } + + // Create .env.local with overrides + localFile := filepath.Join(tmpDir, ".env.local") + if err := os.WriteFile(localFile, []byte("KEY=local\nONLY_LOCAL=local_value"), 0600); err != nil { + t.Fatal(err) + } + + // Act + err := Load(tmpDir) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv("KEY"); got != "local" { + t.Errorf("KEY = %q, want %q (should be overridden)", got, "local") + } + if got := os.Getenv("ONLY_BASE"); got != "base_value" { + t.Errorf("ONLY_BASE = %q, want %q", got, "base_value") + } + if got := os.Getenv("ONLY_LOCAL"); got != "local_value" { + t.Errorf("ONLY_LOCAL = %q, want %q", got, "local_value") + } +} + +func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { + // This test only runs on Unix systems + if runtime.GOOS == "windows" { + t.Skip("skipping permission test on Windows") + } + + preserveEnv(t, "SECRET") + + // Arrange + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + + // Create world-readable .env file + if err := os.WriteFile(envFile, []byte("SECRET=exposed"), 0644); err != nil { + t.Fatal(err) + } + + // Act + err := Load(tmpDir) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify SECRET was NOT loaded (file was skipped) + if got := os.Getenv("SECRET"); got != "" { + t.Errorf("SECRET should not be loaded from world-readable file, got %q", got) + } +} + +func TestLoadFile_CustomPath_LoadsVariables(t *testing.T) { + preserveEnv(t, "CUSTOM_VAR") + + tmpDir := t.TempDir() + customFile := filepath.Join(tmpDir, ".env.custom") + if err := os.WriteFile(customFile, []byte("CUSTOM_VAR=custom_value"), 0600); err != nil { + t.Fatal(err) + } + + err := LoadFile(customFile) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv("CUSTOM_VAR"); got != "custom_value" { + t.Errorf("CUSTOM_VAR = %q, want %q", got, "custom_value") + } +} + +func TestLoadFile_MalformedFile_ReturnsErrorWithContext(t *testing.T) { + preserveEnv(t, "TEST_VAR") + + tmpDir := t.TempDir() + malformedFile := filepath.Join(tmpDir, ".env.bad") + // Create malformed env file (unclosed quote) + if err := os.WriteFile(malformedFile, []byte(`KEY="unclosed`), 0600); err != nil { + t.Fatal(err) + } + + err := LoadFile(malformedFile) + + if err == nil { + t.Fatal("expected error for malformed file, got nil") + } + // Check that error includes filename + if !filepath.IsAbs(malformedFile) { + t.Fatalf("test setup error: expected absolute path, got %q", malformedFile) + } + // Error should mention the file + errStr := err.Error() + if !strings.Contains(errStr, malformedFile) && !strings.Contains(errStr, filepath.Base(malformedFile)) { + t.Errorf("error %q should mention file %q", errStr, malformedFile) + } +}