From 7793b8205865607a12308f1dd0adcdbae3d47b4a Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:26:36 -0400 Subject: [PATCH 01/13] add godotenv dependency --- go.mod | 3 ++- go.sum | 16 +++------------- internal/dotenv/loader.go | 11 +++++++++++ 3 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 internal/dotenv/loader.go 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..f4bb882 --- /dev/null +++ b/internal/dotenv/loader.go @@ -0,0 +1,11 @@ +package dotenv + +import ( + _ "github.com/joho/godotenv" // Will be used in implementation +) + +// Load loads .env files from the specified directory. +// This is a placeholder that will be implemented via TDD. +func Load(dir string) error { + return nil +} From 8153e71e972b3ddd23851400dbf8dfa9b04e5680 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:26:47 -0400 Subject: [PATCH 02/13] add dotenv loader with file not found handling --- internal/dotenv/loader_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 internal/dotenv/loader_test.go diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go new file mode 100644 index 0000000..2833921 --- /dev/null +++ b/internal/dotenv/loader_test.go @@ -0,0 +1,13 @@ +package dotenv + +import ( + "testing" +) + +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) + } +} From 1bce80a1cbee87616306ff017faa96722766ebe7 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:27:18 -0400 Subject: [PATCH 03/13] load env vars from dotenv file --- internal/dotenv/loader.go | 18 +++++++++++++++--- internal/dotenv/loader_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/internal/dotenv/loader.go b/internal/dotenv/loader.go index f4bb882..adf7c41 100644 --- a/internal/dotenv/loader.go +++ b/internal/dotenv/loader.go @@ -1,11 +1,23 @@ package dotenv import ( - _ "github.com/joho/godotenv" // Will be used in implementation + "errors" + "os" + "path/filepath" + + "github.com/joho/godotenv" ) // Load loads .env files from the specified directory. -// This is a placeholder that will be implemented via TDD. +// If .env does not exist, no error is returned. func Load(dir string) error { - return nil + envPath := filepath.Join(dir, ".env") + + // Check if file exists + if _, err := os.Stat(envPath); errors.Is(err, os.ErrNotExist) { + return nil // File not found is OK + } + + // Load the .env file + return godotenv.Load(envPath) } diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go index 2833921..ba4e6a7 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -1,6 +1,8 @@ package dotenv import ( + "os" + "path/filepath" "testing" ) @@ -11,3 +13,33 @@ func TestLoad_FileNotFound_NoError(t *testing.T) { t.Errorf("expected no error when .env not found, got %v", err) } } + +func TestLoad_ValidEnv_LoadsVariables(t *testing.T) { + // 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") + } + + // Cleanup + t.Cleanup(func() { + os.Unsetenv("TEST_KEY") + os.Unsetenv("ANOTHER") + }) +} From 121274899fc33a4245ebbc9703d7cae672271177 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:28:08 -0400 Subject: [PATCH 04/13] support dotenv local overrides --- internal/dotenv/loader.go | 22 +++++++++++++----- internal/dotenv/loader_test.go | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/internal/dotenv/loader.go b/internal/dotenv/loader.go index adf7c41..4e7f080 100644 --- a/internal/dotenv/loader.go +++ b/internal/dotenv/loader.go @@ -9,15 +9,25 @@ import ( ) // Load loads .env files from the specified directory. -// If .env does not exist, no error is returned. +// Loads .env first, then .env.local (which overrides .env values). +// If neither file exists, no error is returned. func Load(dir string) error { + // Load .env envPath := filepath.Join(dir, ".env") + if _, err := os.Stat(envPath); !errors.Is(err, os.ErrNotExist) { + if err := godotenv.Load(envPath); err != nil { + return err + } + } - // Check if file exists - if _, err := os.Stat(envPath); errors.Is(err, os.ErrNotExist) { - return nil // File not found is OK + // Load .env.local (overrides .env) + localPath := filepath.Join(dir, ".env.local") + if _, err := os.Stat(localPath); !errors.Is(err, os.ErrNotExist) { + // Use Overload to override existing vars from .env + if err := godotenv.Overload(localPath); err != nil { + return err + } } - // Load the .env file - return godotenv.Load(envPath) + return nil } diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go index ba4e6a7..b72553e 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -43,3 +43,44 @@ func TestLoad_ValidEnv_LoadsVariables(t *testing.T) { os.Unsetenv("ANOTHER") }) } + +func TestLoad_WithLocal_OverridesBase(t *testing.T) { + // 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") + } + + // Cleanup + t.Cleanup(func() { + os.Unsetenv("KEY") + os.Unsetenv("ONLY_BASE") + os.Unsetenv("ONLY_LOCAL") + }) +} From 191bc2026086e133d9f6e3295a0b4497297093a9 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:53:26 -0400 Subject: [PATCH 05/13] add security check for world readable dotenv --- internal/dotenv/loader.go | 44 +++++++++++++++++++++++++++------- internal/dotenv/loader_test.go | 34 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/internal/dotenv/loader.go b/internal/dotenv/loader.go index 4e7f080..841e15c 100644 --- a/internal/dotenv/loader.go +++ b/internal/dotenv/loader.go @@ -2,8 +2,10 @@ package dotenv import ( "errors" + "log" "os" "path/filepath" + "runtime" "github.com/joho/godotenv" ) @@ -11,23 +13,47 @@ import ( // 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 := os.Stat(envPath); !errors.Is(err, os.ErrNotExist) { - if err := godotenv.Load(envPath); err != nil { - return err - } + if err := loadFile(envPath, false); err != nil { + return err } // Load .env.local (overrides .env) localPath := filepath.Join(dir, ".env.local") - if _, err := os.Stat(localPath); !errors.Is(err, os.ErrNotExist) { - // Use Overload to override existing vars from .env - if err := godotenv.Overload(localPath); err != nil { - return err - } + if err := loadFile(localPath, true); err != nil { + return err } return nil } + +// 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 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 { + return godotenv.Overload(path) + } + return godotenv.Load(path) +} diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go index b72553e..7dcb4e9 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -84,3 +84,37 @@ func TestLoad_WithLocal_OverridesBase(t *testing.T) { os.Unsetenv("ONLY_LOCAL") }) } + +func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { + // This test only runs on Unix systems + if os.Getenv("GOOS") == "windows" { + t.Skip("skipping permission test on Windows") + } + + // 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) + } + + // Cleanup + t.Cleanup(func() { + os.Unsetenv("SECRET") + }) +} From 49c9e45e7de077df105953cf55f546546156a39e Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:54:39 -0400 Subject: [PATCH 06/13] integrate dotenv loading into main --- cmd/xc/main.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/xc/main.go b/cmd/xc/main.go index 2c4f6ba..4513d5f 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" @@ -215,6 +216,17 @@ func runMain() error { <-c cancel() }() + + // Load .env files before parsing tasks + cwd, err := os.Getwd() + if err != nil { + log.Printf("warning: failed to get current directory: %v", err) + } else { + if err := dotenv.Load(cwd); err != nil { + log.Printf("warning: failed to load .env: %v", err) + } + } + cfg := flags() if cfg.uncomplete { return install.Uninstall("xc") From 9b64eca49f99def85f193d1195e63ffebf526d1e Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:55:24 -0400 Subject: [PATCH 07/13] add env file cli flags --- cmd/xc/main.go | 35 ++++++++++++++++++++++++----------- internal/dotenv/loader.go | 5 +++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/cmd/xc/main.go b/cmd/xc/main.go index 4513d5f..da08618 100644 --- a/cmd/xc/main.go +++ b/cmd/xc/main.go @@ -31,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 = "" @@ -77,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 } @@ -217,17 +220,27 @@ func runMain() error { cancel() }() - // Load .env files before parsing tasks - cwd, err := os.Getwd() - if err != nil { - log.Printf("warning: failed to get current directory: %v", err) - } else { - if err := dotenv.Load(cwd); err != nil { - log.Printf("warning: failed to load .env: %v", err) + cfg := flags() + + // 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) + } + } } } - - cfg := flags() if cfg.uncomplete { return install.Uninstall("xc") } diff --git a/internal/dotenv/loader.go b/internal/dotenv/loader.go index 841e15c..71b1e2b 100644 --- a/internal/dotenv/loader.go +++ b/internal/dotenv/loader.go @@ -30,6 +30,11 @@ func Load(dir string) error { 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 { From e77d4bfee46f535ede0a09e7b6e27d631231ecd9 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 14:56:02 -0400 Subject: [PATCH 08/13] document dotenv support in readme --- .env.example | 11 +++++++ .gitignore | 5 +++ README.md | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 .env.example 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: From 089406487b8c94b49a59092c4b7efb69821225f4 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 15:26:25 -0400 Subject: [PATCH 09/13] fix windows test skip to use runtime goos --- internal/dotenv/loader_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go index 7dcb4e9..0fd161c 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -3,6 +3,7 @@ package dotenv import ( "os" "path/filepath" + "runtime" "testing" ) @@ -87,7 +88,7 @@ func TestLoad_WithLocal_OverridesBase(t *testing.T) { func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { // This test only runs on Unix systems - if os.Getenv("GOOS") == "windows" { + if runtime.GOOS == "windows" { t.Skip("skipping permission test on Windows") } From 3b11b4c34a7180dc3bfa0771e0ed7f6b7a84abb0 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 15:27:00 -0400 Subject: [PATCH 10/13] add test environment isolation helper --- internal/dotenv/loader_test.go | 49 +++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go index 0fd161c..721793c 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -7,6 +7,31 @@ import ( "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) @@ -16,6 +41,8 @@ func TestLoad_FileNotFound_NoError(t *testing.T) { } func TestLoad_ValidEnv_LoadsVariables(t *testing.T) { + preserveEnv(t, "TEST_KEY", "ANOTHER") + // Arrange tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") @@ -37,15 +64,11 @@ func TestLoad_ValidEnv_LoadsVariables(t *testing.T) { if got := os.Getenv("ANOTHER"); got != "value2" { t.Errorf("ANOTHER = %q, want %q", got, "value2") } - - // Cleanup - t.Cleanup(func() { - os.Unsetenv("TEST_KEY") - os.Unsetenv("ANOTHER") - }) } func TestLoad_WithLocal_OverridesBase(t *testing.T) { + preserveEnv(t, "KEY", "ONLY_BASE", "ONLY_LOCAL") + // Arrange tmpDir := t.TempDir() @@ -77,13 +100,6 @@ func TestLoad_WithLocal_OverridesBase(t *testing.T) { if got := os.Getenv("ONLY_LOCAL"); got != "local_value" { t.Errorf("ONLY_LOCAL = %q, want %q", got, "local_value") } - - // Cleanup - t.Cleanup(func() { - os.Unsetenv("KEY") - os.Unsetenv("ONLY_BASE") - os.Unsetenv("ONLY_LOCAL") - }) } func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { @@ -92,6 +108,8 @@ func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { t.Skip("skipping permission test on Windows") } + preserveEnv(t, "SECRET") + // Arrange tmpDir := t.TempDir() envFile := filepath.Join(tmpDir, ".env") @@ -113,9 +131,4 @@ func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { if got := os.Getenv("SECRET"); got != "" { t.Errorf("SECRET should not be loaded from world-readable file, got %q", got) } - - // Cleanup - t.Cleanup(func() { - os.Unsetenv("SECRET") - }) } From 9a4e5af48f5e43b6937172cf606683168b002b77 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 15:27:29 -0400 Subject: [PATCH 11/13] move env loading after early exit checks --- cmd/xc/main.go | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/cmd/xc/main.go b/cmd/xc/main.go index da08618..2727035 100644 --- a/cmd/xc/main.go +++ b/cmd/xc/main.go @@ -222,6 +222,22 @@ func runMain() error { cfg := flags() + // Early exits (don't load .env for these) + if cfg.uncomplete { + return install.Uninstall("xc") + } + if cfg.complete { + return install.Install("xc") + } + if cfg.version { + fmt.Printf("xc version: %s\n", getVersion()) + return nil + } + 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() @@ -241,26 +257,11 @@ func runMain() error { } } } - 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 - } if err != nil { return err } From f7b803f32781bc4eb4d51dfc024ed6909b7251c7 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 15:28:09 -0400 Subject: [PATCH 12/13] add error context wrapping for file operations --- internal/dotenv/loader.go | 14 +++++++++++--- internal/dotenv/loader_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/internal/dotenv/loader.go b/internal/dotenv/loader.go index 71b1e2b..35968c3 100644 --- a/internal/dotenv/loader.go +++ b/internal/dotenv/loader.go @@ -2,6 +2,7 @@ package dotenv import ( "errors" + "fmt" "log" "os" "path/filepath" @@ -43,7 +44,7 @@ func loadFile(path string, override bool) error { return nil // File not found is OK } if err != nil { - return err + return fmt.Errorf("failed to check %s: %w", path, err) } // Security check: Skip world-readable or group-readable files (Unix only) @@ -58,7 +59,14 @@ func loadFile(path string, override bool) error { // Load the file if override { - return godotenv.Overload(path) + if err := godotenv.Overload(path); err != nil { + return fmt.Errorf("failed to load %s: %w", path, err) + } + return nil } - return godotenv.Load(path) + + 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 index 721793c..887fdc7 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -132,3 +133,29 @@ func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { t.Errorf("SECRET should not be loaded from world-readable file, got %q", got) } } + +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) + } +} From 913bd578e48d562a9612da28839d8d8515ac9797 Mon Sep 17 00:00:00 2001 From: Thiago Pacheco Date: Sun, 5 Apr 2026 15:28:28 -0400 Subject: [PATCH 13/13] add test for loadfile function --- internal/dotenv/loader_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/dotenv/loader_test.go b/internal/dotenv/loader_test.go index 887fdc7..5b7d72b 100644 --- a/internal/dotenv/loader_test.go +++ b/internal/dotenv/loader_test.go @@ -134,6 +134,25 @@ func TestLoad_WorldReadable_LogsWarningAndSkips(t *testing.T) { } } +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")